create-freakpunch 0.0.0-stage → 0.2.0

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.
Files changed (216) hide show
  1. package/README.md +24 -3
  2. package/bin/create-freakpunch.mjs +227 -0
  3. package/files/README.md +19 -0
  4. package/files/app.config.ts +50 -0
  5. package/files/locales.ts +10 -0
  6. package/files/registry.ts +17 -0
  7. package/package.json +23 -6
  8. package/template/.dockerignore +17 -0
  9. package/template/.oxlintrc.json +9 -0
  10. package/template/AGENTS.md +6 -0
  11. package/template/BACKEND.md +108 -0
  12. package/template/CLAUDE.md +297 -0
  13. package/template/Dockerfile +19 -0
  14. package/template/_gitignore +26 -0
  15. package/template/components.json +27 -0
  16. package/template/design/COMPONENTS.md +145 -0
  17. package/template/design/DESIGN.md +212 -0
  18. package/template/design/PATTERNS.md +110 -0
  19. package/template/index.html +25 -0
  20. package/template/nginx.conf +32 -0
  21. package/template/package-lock.json +6484 -0
  22. package/template/package.json +51 -0
  23. package/template/public/favicon.svg +1 -0
  24. package/template/public/robots.txt +2 -0
  25. package/template/src/app.config.ts +50 -0
  26. package/template/src/app.roles.ts +24 -0
  27. package/template/src/components/ui/alert-dialog.tsx +181 -0
  28. package/template/src/components/ui/avatar.tsx +116 -0
  29. package/template/src/components/ui/badge.tsx +57 -0
  30. package/template/src/components/ui/breadcrumb.tsx +130 -0
  31. package/template/src/components/ui/button.tsx +58 -0
  32. package/template/src/components/ui/calendar.tsx +277 -0
  33. package/template/src/components/ui/card.tsx +99 -0
  34. package/template/src/components/ui/chart.tsx +370 -0
  35. package/template/src/components/ui/checkbox.tsx +28 -0
  36. package/template/src/components/ui/combobox.tsx +329 -0
  37. package/template/src/components/ui/command.tsx +191 -0
  38. package/template/src/components/ui/dialog.tsx +162 -0
  39. package/template/src/components/ui/dropdown-menu.tsx +271 -0
  40. package/template/src/components/ui/empty.tsx +103 -0
  41. package/template/src/components/ui/field.tsx +236 -0
  42. package/template/src/components/ui/input-group.tsx +263 -0
  43. package/template/src/components/ui/input.tsx +45 -0
  44. package/template/src/components/ui/kbd.tsx +31 -0
  45. package/template/src/components/ui/label.tsx +17 -0
  46. package/template/src/components/ui/pagination.tsx +134 -0
  47. package/template/src/components/ui/popover.tsx +87 -0
  48. package/template/src/components/ui/radio-group.tsx +35 -0
  49. package/template/src/components/ui/select.tsx +204 -0
  50. package/template/src/components/ui/separator.tsx +25 -0
  51. package/template/src/components/ui/sheet.tsx +139 -0
  52. package/template/src/components/ui/sidebar.tsx +741 -0
  53. package/template/src/components/ui/skeleton.tsx +13 -0
  54. package/template/src/components/ui/sonner.tsx +78 -0
  55. package/template/src/components/ui/spinner.tsx +42 -0
  56. package/template/src/components/ui/switch.tsx +29 -0
  57. package/template/src/components/ui/table.tsx +121 -0
  58. package/template/src/components/ui/tabs.tsx +191 -0
  59. package/template/src/components/ui/textarea.tsx +17 -0
  60. package/template/src/components/ui/toggle-group.tsx +94 -0
  61. package/template/src/components/ui/toggle.tsx +48 -0
  62. package/template/src/components/ui/tooltip.tsx +91 -0
  63. package/template/src/core/api/demo.ts +8 -0
  64. package/template/src/core/api/http.ts +76 -0
  65. package/template/src/core/api/mock.ts +88 -0
  66. package/template/src/core/api/rest.ts +67 -0
  67. package/template/src/core/api/types.ts +21 -0
  68. package/template/src/core/audit/audit.service.ts +94 -0
  69. package/template/src/core/audit/hooks.ts +19 -0
  70. package/template/src/core/auth/auth-store.ts +52 -0
  71. package/template/src/core/auth/auth.service.ts +86 -0
  72. package/template/src/core/auth/can.tsx +39 -0
  73. package/template/src/core/auth/dev-role-switcher.tsx +64 -0
  74. package/template/src/core/auth/local-credentials.ts +46 -0
  75. package/template/src/core/auth/permissions.ts +115 -0
  76. package/template/src/core/auth/require-auth.tsx +16 -0
  77. package/template/src/core/auth/require-permission.tsx +24 -0
  78. package/template/src/core/auth/roles.ts +9 -0
  79. package/template/src/core/auth/types.ts +20 -0
  80. package/template/src/core/auth/use-auth.ts +19 -0
  81. package/template/src/core/company/company.service.ts +94 -0
  82. package/template/src/core/company/hooks.ts +40 -0
  83. package/template/src/core/company/password-policy.ts +40 -0
  84. package/template/src/core/company/types.ts +46 -0
  85. package/template/src/core/company/use-app-name.ts +7 -0
  86. package/template/src/core/components/button-link.tsx +14 -0
  87. package/template/src/core/components/confirm-dialog.tsx +116 -0
  88. package/template/src/core/components/data-table/data-table.tsx +297 -0
  89. package/template/src/core/components/data-table/features.ts +24 -0
  90. package/template/src/core/components/data-table/table-header-cell.tsx +13 -0
  91. package/template/src/core/components/data-table/toolbar.tsx +100 -0
  92. package/template/src/core/components/data-table/use-list-url-state.ts +108 -0
  93. package/template/src/core/components/date-picker.tsx +71 -0
  94. package/template/src/core/components/file-attachments.tsx +84 -0
  95. package/template/src/core/components/form-error.tsx +9 -0
  96. package/template/src/core/components/form-page.tsx +93 -0
  97. package/template/src/core/components/image-upload.tsx +86 -0
  98. package/template/src/core/components/page.tsx +75 -0
  99. package/template/src/core/components/phone-input.tsx +58 -0
  100. package/template/src/core/components/search-select.tsx +57 -0
  101. package/template/src/core/components/segmented.tsx +41 -0
  102. package/template/src/core/components/states.tsx +97 -0
  103. package/template/src/core/components/status-badge.tsx +26 -0
  104. package/template/src/core/components/user-avatar.tsx +23 -0
  105. package/template/src/core/config/runtime.ts +16 -0
  106. package/template/src/core/config/types.ts +37 -0
  107. package/template/src/core/i18n/i18n.ts +69 -0
  108. package/template/src/core/i18n/i18next.d.ts +12 -0
  109. package/template/src/core/i18n/keys.ts +37 -0
  110. package/template/src/core/i18n/language-switcher.tsx +45 -0
  111. package/template/src/core/i18n/types.ts +17 -0
  112. package/template/src/core/i18n/use-format.ts +31 -0
  113. package/template/src/core/layout/app-shell.tsx +50 -0
  114. package/template/src/core/layout/app-sidebar.tsx +285 -0
  115. package/template/src/core/layout/command-menu.tsx +117 -0
  116. package/template/src/core/layout/top-bar.tsx +100 -0
  117. package/template/src/core/lib/csv.ts +27 -0
  118. package/template/src/core/lib/format.ts +87 -0
  119. package/template/src/core/lib/phone.ts +50 -0
  120. package/template/src/core/lib/storage.ts +27 -0
  121. package/template/src/core/modules/lazy-page.ts +9 -0
  122. package/template/src/core/modules/navigation.ts +39 -0
  123. package/template/src/core/modules/types.ts +55 -0
  124. package/template/src/core/notifications/hooks.ts +84 -0
  125. package/template/src/core/notifications/notification-center.tsx +251 -0
  126. package/template/src/core/notifications/notifications.service.ts +132 -0
  127. package/template/src/core/notifications/types.ts +70 -0
  128. package/template/src/core/preferences/preferences-store.ts +70 -0
  129. package/template/src/core/setup/setup-gate.tsx +20 -0
  130. package/template/src/core/setup/setup.service.ts +101 -0
  131. package/template/src/core/theme/brand-context.tsx +53 -0
  132. package/template/src/core/theme/brand.ts +29 -0
  133. package/template/src/core/theme/theme-context.tsx +62 -0
  134. package/template/src/core/theme/theme-options.ts +8 -0
  135. package/template/src/core/theme/theme-toggle.tsx +45 -0
  136. package/template/src/core/users/hooks.ts +51 -0
  137. package/template/src/core/users/types.ts +37 -0
  138. package/template/src/core/users/users.service.ts +167 -0
  139. package/template/src/hooks/use-mobile.ts +19 -0
  140. package/template/src/index.css +321 -0
  141. package/template/src/lib/utils.ts +1 -0
  142. package/template/src/locales/en/account.ts +180 -0
  143. package/template/src/locales/en/auth.ts +18 -0
  144. package/template/src/locales/en/common.ts +151 -0
  145. package/template/src/locales/en/company.ts +380 -0
  146. package/template/src/locales/en/dashboard.ts +46 -0
  147. package/template/src/locales/en/errors.ts +18 -0
  148. package/template/src/locales/en/notifications.ts +43 -0
  149. package/template/src/locales/en/settings.ts +25 -0
  150. package/template/src/locales/en/setup.ts +45 -0
  151. package/template/src/locales/index.ts +58 -0
  152. package/template/src/locales/tr/account.ts +184 -0
  153. package/template/src/locales/tr/auth.ts +22 -0
  154. package/template/src/locales/tr/common.ts +154 -0
  155. package/template/src/locales/tr/company.ts +384 -0
  156. package/template/src/locales/tr/dashboard.ts +50 -0
  157. package/template/src/locales/tr/errors.ts +22 -0
  158. package/template/src/locales/tr/notifications.ts +47 -0
  159. package/template/src/locales/tr/settings.ts +29 -0
  160. package/template/src/locales/tr/setup.ts +49 -0
  161. package/template/src/main.tsx +16 -0
  162. package/template/src/modules/locales.ts +10 -0
  163. package/template/src/modules/registry.ts +17 -0
  164. package/template/src/pages/dashboard/dashboard-page.tsx +55 -0
  165. package/template/src/pages/dashboard/quick-actions.tsx +37 -0
  166. package/template/src/pages/login-page.tsx +152 -0
  167. package/template/src/pages/no-access-page.tsx +11 -0
  168. package/template/src/pages/not-found-page.tsx +61 -0
  169. package/template/src/pages/settings/account/account.service.ts +128 -0
  170. package/template/src/pages/settings/account/components/mock-qr.tsx +50 -0
  171. package/template/src/pages/settings/account/components/password-strength.tsx +38 -0
  172. package/template/src/pages/settings/account/components/two-factor-dialog.tsx +140 -0
  173. package/template/src/pages/settings/account/hooks.ts +73 -0
  174. package/template/src/pages/settings/account/notifications-page.tsx +103 -0
  175. package/template/src/pages/settings/account/preferences-page.tsx +157 -0
  176. package/template/src/pages/settings/account/profile-page.tsx +242 -0
  177. package/template/src/pages/settings/account/security-page.tsx +364 -0
  178. package/template/src/pages/settings/account/types.ts +37 -0
  179. package/template/src/pages/settings/company/audit-log-page.tsx +126 -0
  180. package/template/src/pages/settings/company/branding-page.tsx +278 -0
  181. package/template/src/pages/settings/company/components/invite-dialog.tsx +106 -0
  182. package/template/src/pages/settings/company/components/read-only-notice.tsx +13 -0
  183. package/template/src/pages/settings/company/danger-zone-page.tsx +127 -0
  184. package/template/src/pages/settings/company/general-page.tsx +234 -0
  185. package/template/src/pages/settings/company/integrations/api-keys-panel.tsx +170 -0
  186. package/template/src/pages/settings/company/integrations/hooks.ts +37 -0
  187. package/template/src/pages/settings/company/integrations/integrations-page.tsx +57 -0
  188. package/template/src/pages/settings/company/integrations/integrations.service.ts +153 -0
  189. package/template/src/pages/settings/company/integrations/settings-forms.tsx +299 -0
  190. package/template/src/pages/settings/company/integrations/webhooks-panel.tsx +179 -0
  191. package/template/src/pages/settings/company/modules-page.tsx +65 -0
  192. package/template/src/pages/settings/company/roles-page.tsx +116 -0
  193. package/template/src/pages/settings/company/security-policies-page.tsx +164 -0
  194. package/template/src/pages/settings/company/users-page.tsx +262 -0
  195. package/template/src/pages/settings/settings-layout.tsx +57 -0
  196. package/template/src/pages/settings/settings-nav.ts +69 -0
  197. package/template/src/pages/settings/settings-routes.tsx +49 -0
  198. package/template/src/pages/setup-page.tsx +387 -0
  199. package/template/src/providers.tsx +68 -0
  200. package/template/src/router.tsx +59 -0
  201. package/template/tools/check-design.mjs +73 -0
  202. package/template/tools/new-module.mjs +90 -0
  203. package/template/tools/templates/module/api/__id__.mock.ts.tpl +17 -0
  204. package/template/tools/templates/module/api/__id__.service.ts.tpl +51 -0
  205. package/template/tools/templates/module/hooks.ts.tpl +57 -0
  206. package/template/tools/templates/module/index.ts.tpl +44 -0
  207. package/template/tools/templates/module/locales/en.ts.tpl +51 -0
  208. package/template/tools/templates/module/locales/tr.ts.tpl +55 -0
  209. package/template/tools/templates/module/pages/__id__-detail-page.tsx.tpl +103 -0
  210. package/template/tools/templates/module/pages/__id__-form-page.tsx.tpl +121 -0
  211. package/template/tools/templates/module/pages/__id__-list-page.tsx.tpl +234 -0
  212. package/template/tools/templates/module/types.ts.tpl +32 -0
  213. package/template/tsconfig.app.json +32 -0
  214. package/template/tsconfig.json +12 -0
  215. package/template/tsconfig.node.json +23 -0
  216. package/template/vite.config.ts +47 -0
@@ -0,0 +1,71 @@
1
+ import { useState } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { enUS, tr } from 'date-fns/locale'
4
+ import { CalendarDays } from 'lucide-react'
5
+
6
+ import { Button } from '@/components/ui/button'
7
+ import { Calendar } from '@/components/ui/calendar'
8
+ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
9
+ import { useFormat } from '@/core/i18n/use-format'
10
+ import { cn } from '@/lib/utils'
11
+
12
+ const pad = (n: number) => String(n).padStart(2, '0')
13
+ const toValue = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
14
+ /** Local noon, so a time zone offset can't move the calendar day. */
15
+ const fromValue = (value: string) => new Date(`${value}T12:00:00`)
16
+
17
+ /** Date-only field. The value is a `YYYY-MM-DD` string, the shape to send to a backend. */
18
+ export function DatePicker({
19
+ id,
20
+ value,
21
+ onChange,
22
+ placeholder,
23
+ disabled,
24
+ invalid,
25
+ className,
26
+ }: {
27
+ id?: string
28
+ value: string | undefined
29
+ onChange: (value: string | undefined) => void
30
+ placeholder?: string
31
+ disabled?: boolean
32
+ invalid?: boolean
33
+ className?: string
34
+ }) {
35
+ const [open, setOpen] = useState(false)
36
+ const { t, i18n } = useTranslation()
37
+ const fmt = useFormat()
38
+ const selected = value ? fromValue(value) : undefined
39
+
40
+ return (
41
+ <Popover open={open} onOpenChange={setOpen}>
42
+ <PopoverTrigger
43
+ render={
44
+ <Button
45
+ id={id}
46
+ type="button"
47
+ variant="outline"
48
+ disabled={disabled}
49
+ aria-invalid={invalid}
50
+ className={cn('w-full justify-start font-normal', !value && 'text-muted-foreground', className)}
51
+ >
52
+ <CalendarDays data-icon="inline-start" />
53
+ {selected ? fmt.date(selected) : (placeholder ?? t('fields.pickDate'))}
54
+ </Button>
55
+ }
56
+ />
57
+ <PopoverContent align="start" className="w-auto p-0">
58
+ <Calendar
59
+ mode="single"
60
+ locale={i18n.language === 'tr' ? tr : enUS}
61
+ selected={selected}
62
+ defaultMonth={selected}
63
+ onSelect={(day) => {
64
+ onChange(day ? toValue(day) : undefined)
65
+ setOpen(false)
66
+ }}
67
+ />
68
+ </PopoverContent>
69
+ </Popover>
70
+ )
71
+ }
@@ -0,0 +1,84 @@
1
+ import { useRef } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { FileText, Paperclip, X } from 'lucide-react'
4
+
5
+ import { Button } from '@/components/ui/button'
6
+ import { useFormat } from '@/core/i18n/use-format'
7
+
8
+ const DEFAULT_MAX_BYTES = 10 * 1024 * 1024
9
+
10
+ /**
11
+ * Picks files and lists them. It only holds `File` objects: the service uploads
12
+ * them, usually `http.post(path, formData)`. `onTooLarge` receives rejected files.
13
+ */
14
+ export function FileAttachments({
15
+ id,
16
+ files,
17
+ onChange,
18
+ onTooLarge,
19
+ accept,
20
+ maxBytes = DEFAULT_MAX_BYTES,
21
+ disabled,
22
+ }: {
23
+ id?: string
24
+ files: File[]
25
+ onChange: (files: File[]) => void
26
+ onTooLarge?: (files: File[]) => void
27
+ accept?: string
28
+ maxBytes?: number
29
+ disabled?: boolean
30
+ }) {
31
+ const input = useRef<HTMLInputElement>(null)
32
+ const { t } = useTranslation()
33
+ const fmt = useFormat()
34
+
35
+ function add(picked: File[]) {
36
+ const tooLarge = picked.filter((f) => f.size > maxBytes)
37
+ if (tooLarge.length > 0) onTooLarge?.(tooLarge)
38
+ onChange([...files, ...picked.filter((f) => f.size <= maxBytes)])
39
+ }
40
+
41
+ return (
42
+ <div className="flex flex-col gap-2">
43
+ <input
44
+ ref={input}
45
+ id={id}
46
+ type="file"
47
+ multiple
48
+ accept={accept}
49
+ className="sr-only"
50
+ tabIndex={-1}
51
+ disabled={disabled}
52
+ onChange={(e) => {
53
+ add(Array.from(e.target.files ?? []))
54
+ e.target.value = ''
55
+ }}
56
+ />
57
+ <Button type="button" variant="outline" size="sm" disabled={disabled} className="self-start" onClick={() => input.current?.click()}>
58
+ <Paperclip data-icon="inline-start" />
59
+ {t('fields.addFiles')}
60
+ </Button>
61
+ {files.length > 0 && (
62
+ <ul className="divide-y rounded-xl border">
63
+ {files.map((file, i) => (
64
+ <li key={`${file.name}-${i}`} className="flex items-center gap-3 px-3 py-2 text-sm">
65
+ <FileText className="size-4 shrink-0 text-muted-foreground" />
66
+ <span className="min-w-0 flex-1 truncate">{file.name}</span>
67
+ <span className="shrink-0 text-xs text-muted-foreground tabular-nums">{fmt.number(Math.ceil(file.size / 1024))} KB</span>
68
+ <Button
69
+ type="button"
70
+ variant="ghost"
71
+ size="icon-sm"
72
+ disabled={disabled}
73
+ aria-label={t('fields.removeFile', { name: file.name })}
74
+ onClick={() => onChange(files.filter((_, index) => index !== i))}
75
+ >
76
+ <X />
77
+ </Button>
78
+ </li>
79
+ ))}
80
+ </ul>
81
+ )}
82
+ </div>
83
+ )
84
+ }
@@ -0,0 +1,9 @@
1
+ import { FieldError } from '@/components/ui/field'
2
+ import { translateMessage, useAnyT } from '@/core/i18n/keys'
3
+
4
+ /** SortUI FieldError that translates `vmsg()` keys from Zod schemas. */
5
+ export function FormFieldError({ error }: { error?: { message?: string } }) {
6
+ const t = useAnyT()
7
+ const message = error?.message ? translateMessage(t, error.message) : undefined
8
+ return <FieldError errors={message ? [{ message }] : []} />
9
+ }
@@ -0,0 +1,93 @@
1
+ import type { FormEventHandler, ReactNode } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+
4
+ import { Button } from '@/components/ui/button'
5
+ import { Spinner } from '@/components/ui/spinner'
6
+ import { cn } from '@/lib/utils'
7
+
8
+ /**
9
+ * Two-column settings-style form: section titles on the left, fields on the
10
+ * right, sticky action bar at the bottom. Wrap with <Page> for the title.
11
+ *
12
+ * saveBar="always" (create/edit pages) keeps the bar visible; "dirty"
13
+ * (settings pages) shows it only when there are unsaved changes.
14
+ */
15
+ export function FormLayout({
16
+ onSubmit,
17
+ children,
18
+ submitLabel,
19
+ isSubmitting,
20
+ isDirty = true,
21
+ onCancel,
22
+ extraActions,
23
+ saveBar = 'always',
24
+ readOnly,
25
+ }: {
26
+ onSubmit: FormEventHandler<HTMLFormElement>
27
+ children: ReactNode
28
+ submitLabel?: string
29
+ isSubmitting?: boolean
30
+ isDirty?: boolean
31
+ onCancel?: () => void
32
+ /** Left side of the action bar, e.g. a destructive "Delete" button. */
33
+ extraActions?: ReactNode
34
+ saveBar?: 'always' | 'dirty'
35
+ /** Disables every field and hides the action bar (e.g. missing permission). */
36
+ readOnly?: boolean
37
+ }) {
38
+ const { t } = useTranslation()
39
+ const showBar = !readOnly && (saveBar === 'always' || isDirty || isSubmitting)
40
+
41
+ return (
42
+ <form onSubmit={onSubmit} noValidate className="flex flex-col">
43
+ <fieldset disabled={readOnly} className="contents">
44
+ <div className="flex flex-col divide-y rounded-xl border bg-card shadow-(--shadow-card)">{children}</div>
45
+ </fieldset>
46
+ {showBar && (
47
+ <div
48
+ className={cn(
49
+ 'sticky bottom-0 z-10 -mx-1 mt-4 flex items-center justify-between gap-2 bg-background px-1 py-3',
50
+ saveBar === 'dirty' && 'animate-in fade-in slide-in-from-bottom-1',
51
+ )}
52
+ >
53
+ <div className="min-w-0 text-xs text-muted-foreground">
54
+ {extraActions ?? (saveBar === 'dirty' && <span className="truncate">{t('form.unsaved')}</span>)}
55
+ </div>
56
+ <div className="flex shrink-0 items-center gap-2">
57
+ {onCancel && (
58
+ <Button type="button" variant="ghost" onClick={onCancel}>
59
+ {saveBar === 'dirty' ? t('actions.discard') : t('actions.cancel')}
60
+ </Button>
61
+ )}
62
+ <Button type="submit" disabled={isSubmitting || !isDirty}>
63
+ {isSubmitting && <Spinner data-icon="inline-start" />}
64
+ {submitLabel ?? t('actions.saveChanges')}
65
+ </Button>
66
+ </div>
67
+ </div>
68
+ )}
69
+ </form>
70
+ )
71
+ }
72
+
73
+ export function FormSection({
74
+ title,
75
+ description,
76
+ children,
77
+ className,
78
+ }: {
79
+ title: string
80
+ description?: ReactNode
81
+ children: ReactNode
82
+ className?: string
83
+ }) {
84
+ return (
85
+ <section className={cn('grid gap-x-10 gap-y-4 p-5 md:grid-cols-[minmax(0,16rem)_minmax(0,1fr)] md:p-6', className)}>
86
+ <div>
87
+ <h2 className="text-sm font-medium">{title}</h2>
88
+ {description && <p className="mt-1 text-xs text-muted-foreground">{description}</p>}
89
+ </div>
90
+ <div className="flex min-w-0 flex-col gap-4">{children}</div>
91
+ </section>
92
+ )
93
+ }
@@ -0,0 +1,86 @@
1
+ import { useId, useRef, useState, type ReactNode } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { Upload } from 'lucide-react'
4
+
5
+ import { Button } from '@/components/ui/button'
6
+ import { cn } from '@/lib/utils'
7
+
8
+ const ACCEPT = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml', 'image/x-icon', 'image/vnd.microsoft.icon']
9
+ const MAX_BYTES = 2 * 1024 * 1024
10
+
11
+ export type ImageUploadError = 'tooLarge' | 'invalidType'
12
+
13
+ /**
14
+ * File picker with a preview slot. Reads the image as a data URL (mock
15
+ * storage); a real backend would upload the file and return its URL.
16
+ */
17
+ export function ImageUpload({
18
+ value,
19
+ onChange,
20
+ preview,
21
+ label,
22
+ onError,
23
+ accept = ACCEPT,
24
+ className,
25
+ disabled,
26
+ }: {
27
+ value: string | undefined
28
+ onChange: (dataUrl: string | undefined) => void
29
+ /** Rendered as the preview tile (receives the current value). */
30
+ preview: (value: string | undefined) => ReactNode
31
+ /** Accessible label of the upload button, e.g. "Change photo". */
32
+ label: string
33
+ onError?: (error: ImageUploadError) => void
34
+ accept?: string[]
35
+ className?: string
36
+ disabled?: boolean
37
+ }) {
38
+ const { t } = useTranslation()
39
+ const input = useRef<HTMLInputElement>(null)
40
+ const id = useId()
41
+ const [busy, setBusy] = useState(false)
42
+
43
+ function onFile(file: File | undefined) {
44
+ if (!file) return
45
+ if (!accept.includes(file.type)) return onError?.('invalidType')
46
+ if (file.size > MAX_BYTES) return onError?.('tooLarge')
47
+ setBusy(true)
48
+ const reader = new FileReader()
49
+ reader.onload = () => {
50
+ onChange(String(reader.result))
51
+ setBusy(false)
52
+ }
53
+ reader.onerror = () => setBusy(false)
54
+ reader.readAsDataURL(file)
55
+ }
56
+
57
+ return (
58
+ <div className={cn('flex items-center gap-4', className)}>
59
+ {preview(value)}
60
+ <div className="flex flex-wrap items-center gap-2">
61
+ <input
62
+ ref={input}
63
+ id={id}
64
+ type="file"
65
+ accept={accept.join(',')}
66
+ className="sr-only"
67
+ tabIndex={-1}
68
+ disabled={disabled}
69
+ onChange={(e) => {
70
+ onFile(e.target.files?.[0])
71
+ e.target.value = ''
72
+ }}
73
+ />
74
+ <Button type="button" variant="outline" size="sm" disabled={disabled || busy} aria-label={label} onClick={() => input.current?.click()}>
75
+ <Upload data-icon="inline-start" />
76
+ {t('actions.upload')}
77
+ </Button>
78
+ {value && (
79
+ <Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={() => onChange(undefined)}>
80
+ {t('actions.remove')}
81
+ </Button>
82
+ )}
83
+ </div>
84
+ </div>
85
+ )
86
+ }
@@ -0,0 +1,75 @@
1
+ import { useEffect, type ReactNode } from 'react'
2
+
3
+ import { useAppName } from '@/core/company/use-app-name'
4
+ import { cn } from '@/lib/utils'
5
+
6
+ type PageProps = {
7
+ title: ReactNode
8
+ /** Plain-text document title; defaults to `title` when it is a string. */
9
+ documentTitle?: string
10
+ description?: ReactNode
11
+ /** Buttons on the right of the title (primary action last). */
12
+ actions?: ReactNode
13
+ /** Optional element left of the title, e.g. an avatar or icon tile. */
14
+ leading?: ReactNode
15
+ children?: ReactNode
16
+ className?: string
17
+ }
18
+
19
+ /** Standard page frame inside the content panel: title row + body. */
20
+ export function Page({ title, documentTitle, description, actions, leading, children, className }: PageProps) {
21
+ const docTitle = documentTitle ?? (typeof title === 'string' ? title : undefined)
22
+ const appName = useAppName()
23
+
24
+ useEffect(() => {
25
+ document.title = docTitle ? `${docTitle} · ${appName}` : appName
26
+ }, [docTitle, appName])
27
+
28
+ return (
29
+ <div className={cn('mx-auto flex w-full max-w-(--page-max-width) flex-col gap-6', className)}>
30
+ <header className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
31
+ <div className="flex min-w-0 items-center gap-3">
32
+ {leading}
33
+ <div className="min-w-0">
34
+ <h1 className="truncate text-2xl font-semibold tracking-tight">{title}</h1>
35
+ {description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
36
+ </div>
37
+ </div>
38
+ {actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
39
+ </header>
40
+ {children}
41
+ </div>
42
+ )
43
+ }
44
+
45
+ /** Bordered section card with a small uppercase label header, as used across pages. */
46
+ export function Panel({
47
+ label,
48
+ actions,
49
+ children,
50
+ className,
51
+ bodyClassName,
52
+ }: {
53
+ label?: ReactNode
54
+ actions?: ReactNode
55
+ children: ReactNode
56
+ className?: string
57
+ bodyClassName?: string
58
+ }) {
59
+ return (
60
+ <section
61
+ className={cn(
62
+ 'flex min-w-0 flex-col overflow-hidden rounded-xl border bg-card text-card-foreground shadow-(--shadow-card)',
63
+ className,
64
+ )}
65
+ >
66
+ {(label || actions) && (
67
+ <div className="flex min-h-11 items-center justify-between gap-3 border-b px-4 py-2">
68
+ <h2 className="label-caps">{label}</h2>
69
+ {actions && <div className="flex items-center gap-1">{actions}</div>}
70
+ </div>
71
+ )}
72
+ <div className={cn('flex-1', bodyClassName)}>{children}</div>
73
+ </section>
74
+ )
75
+ }
@@ -0,0 +1,58 @@
1
+ import { useMemo, type ComponentProps } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+
4
+ import { Input } from '@/components/ui/input'
5
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
6
+ import { dialCode, phoneCountries } from '@/core/lib/phone'
7
+
8
+ /**
9
+ * Country calling code (localized country names) + national number.
10
+ * Validate with isValidPhone and store with toE164 (core/lib/phone).
11
+ * <PhoneInput id="phone" country={c} onCountryChange={…} invalid={…} inputProps={register('phoneNumber')} />
12
+ */
13
+ export function PhoneInput({
14
+ id,
15
+ country,
16
+ onCountryChange,
17
+ invalid,
18
+ inputProps,
19
+ countryLabel,
20
+ }: {
21
+ id: string
22
+ country: string
23
+ onCountryChange: (code: string) => void
24
+ invalid?: boolean
25
+ inputProps?: ComponentProps<'input'>
26
+ /** Accessible name of the country select. */
27
+ countryLabel: string
28
+ }) {
29
+ const { i18n } = useTranslation()
30
+ const regionNames = useMemo(() => new Intl.DisplayNames([i18n.language], { type: 'region' }), [i18n.language])
31
+ const items = phoneCountries.map((c) => ({ value: c.code, label: `${c.dial} ${regionNames.of(c.code) ?? c.code}` }))
32
+
33
+ return (
34
+ <div className="flex gap-2">
35
+ <Select items={items} value={country} onValueChange={(v) => v && onCountryChange(v)}>
36
+ <SelectTrigger aria-label={countryLabel} className="w-28 shrink-0 tabular-nums">
37
+ <SelectValue>{(v: string) => dialCode(v)}</SelectValue>
38
+ </SelectTrigger>
39
+ <SelectContent>
40
+ {items.map((c) => (
41
+ <SelectItem key={c.value} value={c.value}>
42
+ {c.label}
43
+ </SelectItem>
44
+ ))}
45
+ </SelectContent>
46
+ </Select>
47
+ <Input
48
+ id={id}
49
+ type="tel"
50
+ inputMode="tel"
51
+ autoComplete="tel-national"
52
+ aria-invalid={invalid}
53
+ className="min-w-0 flex-1"
54
+ {...inputProps}
55
+ />
56
+ </div>
57
+ )
58
+ }
@@ -0,0 +1,57 @@
1
+ import { useTranslation } from 'react-i18next'
2
+
3
+ import {
4
+ Combobox,
5
+ ComboboxContent,
6
+ ComboboxEmpty,
7
+ ComboboxInput,
8
+ ComboboxItem,
9
+ ComboboxList,
10
+ } from '@/components/ui/combobox'
11
+
12
+ export type SearchSelectOption = { value: string; label: string }
13
+
14
+ /** Single choice from a long list (employees, assets…) with type-to-search. For short lists use `Select`. */
15
+ export function SearchSelect({
16
+ id,
17
+ value,
18
+ onChange,
19
+ options,
20
+ placeholder,
21
+ disabled,
22
+ invalid,
23
+ }: {
24
+ id?: string
25
+ value: string | undefined
26
+ onChange: (value: string | undefined) => void
27
+ options: SearchSelectOption[]
28
+ placeholder?: string
29
+ disabled?: boolean
30
+ invalid?: boolean
31
+ }) {
32
+ const { t } = useTranslation()
33
+ const selected = options.find((o) => o.value === value) ?? null
34
+
35
+ return (
36
+ <Combobox
37
+ items={options}
38
+ value={selected}
39
+ disabled={disabled}
40
+ onValueChange={(option: SearchSelectOption | null) => onChange(option?.value)}
41
+ itemToStringLabel={(option: SearchSelectOption) => option.label}
42
+ isItemEqualToValue={(a: SearchSelectOption, b: SearchSelectOption) => a.value === b.value}
43
+ >
44
+ <ComboboxInput id={id} placeholder={placeholder ?? t('fields.search')} aria-invalid={invalid} disabled={disabled} className="w-full" />
45
+ <ComboboxContent>
46
+ <ComboboxEmpty>{t('table.noResults')}</ComboboxEmpty>
47
+ <ComboboxList>
48
+ {(option: SearchSelectOption) => (
49
+ <ComboboxItem key={option.value} value={option}>
50
+ {option.label}
51
+ </ComboboxItem>
52
+ )}
53
+ </ComboboxList>
54
+ </ComboboxContent>
55
+ </Combobox>
56
+ )
57
+ }
@@ -0,0 +1,41 @@
1
+ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
2
+ import { cn } from '@/lib/utils'
3
+
4
+ /** iOS-style segmented control built on SortUI ToggleGroup (single value, never empty). */
5
+ export function Segmented<T extends string>({
6
+ value,
7
+ onChange,
8
+ options,
9
+ label,
10
+ className,
11
+ }: {
12
+ value: T
13
+ onChange: (value: T) => void
14
+ options: readonly { value: T; label: string }[]
15
+ label: string
16
+ className?: string
17
+ }) {
18
+ return (
19
+ <ToggleGroup
20
+ aria-label={label}
21
+ value={[value]}
22
+ onValueChange={(next) => {
23
+ const v = next[0] as T | undefined
24
+ if (v) onChange(v)
25
+ }}
26
+ spacing={0}
27
+ className={cn('rounded-lg bg-fill-2 p-0.5', className)}
28
+ >
29
+ {options.map((o) => (
30
+ <ToggleGroupItem
31
+ key={o.value}
32
+ value={o.value}
33
+ size="sm"
34
+ className="h-6 rounded-md px-2.5 text-xs text-muted-foreground aria-pressed:bg-raised aria-pressed:text-foreground aria-pressed:shadow-(--shadow-raised) hover:bg-transparent"
35
+ >
36
+ {o.label}
37
+ </ToggleGroupItem>
38
+ ))}
39
+ </ToggleGroup>
40
+ )
41
+ }
@@ -0,0 +1,97 @@
1
+ import type { ReactNode } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { AlertTriangle, Inbox, RotateCw, SearchX } from 'lucide-react'
4
+
5
+ import { Button } from '@/components/ui/button'
6
+ import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
7
+ import { Spinner } from '@/components/ui/spinner'
8
+ import { ApiError } from '@/core/api/http'
9
+ import { cn } from '@/lib/utils'
10
+
11
+ export function LoadingState({ label, className }: { label?: string; className?: string }) {
12
+ const { t } = useTranslation()
13
+ return (
14
+ <div role="status" className={cn('flex flex-col items-center justify-center gap-3 py-16 text-sm text-muted-foreground', className)}>
15
+ <Spinner className="size-5" />
16
+ <span>{label ?? t('states.loading')}</span>
17
+ </div>
18
+ )
19
+ }
20
+
21
+ export function EmptyState({
22
+ title,
23
+ description,
24
+ icon,
25
+ action,
26
+ className,
27
+ }: {
28
+ title?: string
29
+ description?: ReactNode
30
+ icon?: ReactNode
31
+ action?: ReactNode
32
+ className?: string
33
+ }) {
34
+ const { t } = useTranslation()
35
+ return (
36
+ <Empty className={cn('py-16', className)}>
37
+ <EmptyHeader>
38
+ <EmptyMedia variant="icon">{icon ?? <Inbox />}</EmptyMedia>
39
+ <EmptyTitle>{title ?? t('states.emptyTitle')}</EmptyTitle>
40
+ {description && <EmptyDescription>{description}</EmptyDescription>}
41
+ </EmptyHeader>
42
+ {action && <EmptyContent>{action}</EmptyContent>}
43
+ </Empty>
44
+ )
45
+ }
46
+
47
+ /** Query/route error. 404s from the API render as "not found" without a retry. */
48
+ export function ErrorState({
49
+ title,
50
+ error,
51
+ onRetry,
52
+ notFoundAction,
53
+ className,
54
+ }: {
55
+ title?: string
56
+ error?: unknown
57
+ onRetry?: () => void
58
+ /** Shown instead of "Try again" when the error is a 404, e.g. a link back to the list. */
59
+ notFoundAction?: ReactNode
60
+ className?: string
61
+ }) {
62
+ const { t } = useTranslation()
63
+
64
+ if (error instanceof ApiError && error.status === 404) {
65
+ return (
66
+ <EmptyState
67
+ title={t('states.notFoundTitle')}
68
+ description={t('states.notFoundDescription')}
69
+ icon={<SearchX />}
70
+ action={notFoundAction}
71
+ className={className}
72
+ />
73
+ )
74
+ }
75
+
76
+ // API messages come from the server (already localized by a real backend).
77
+ const message = error instanceof Error ? error.message : t('states.errorDescription')
78
+ return (
79
+ <Empty className={cn('py-16', className)}>
80
+ <EmptyHeader>
81
+ <EmptyMedia variant="icon" className="text-danger">
82
+ <AlertTriangle />
83
+ </EmptyMedia>
84
+ <EmptyTitle>{title ?? t('states.errorTitle')}</EmptyTitle>
85
+ <EmptyDescription>{message}</EmptyDescription>
86
+ </EmptyHeader>
87
+ {onRetry && (
88
+ <EmptyContent>
89
+ <Button variant="outline" onClick={onRetry}>
90
+ <RotateCw data-icon="inline-start" />
91
+ {t('actions.retry')}
92
+ </Button>
93
+ </EmptyContent>
94
+ )}
95
+ </Empty>
96
+ )
97
+ }