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.
- package/README.md +24 -3
- package/bin/create-freakpunch.mjs +227 -0
- package/files/README.md +19 -0
- package/files/app.config.ts +50 -0
- package/files/locales.ts +10 -0
- package/files/registry.ts +17 -0
- package/package.json +23 -6
- package/template/.dockerignore +17 -0
- package/template/.oxlintrc.json +9 -0
- package/template/AGENTS.md +6 -0
- package/template/BACKEND.md +108 -0
- package/template/CLAUDE.md +297 -0
- package/template/Dockerfile +19 -0
- package/template/_gitignore +26 -0
- package/template/components.json +27 -0
- package/template/design/COMPONENTS.md +145 -0
- package/template/design/DESIGN.md +212 -0
- package/template/design/PATTERNS.md +110 -0
- package/template/index.html +25 -0
- package/template/nginx.conf +32 -0
- package/template/package-lock.json +6484 -0
- package/template/package.json +51 -0
- package/template/public/favicon.svg +1 -0
- package/template/public/robots.txt +2 -0
- package/template/src/app.config.ts +50 -0
- package/template/src/app.roles.ts +24 -0
- package/template/src/components/ui/alert-dialog.tsx +181 -0
- package/template/src/components/ui/avatar.tsx +116 -0
- package/template/src/components/ui/badge.tsx +57 -0
- package/template/src/components/ui/breadcrumb.tsx +130 -0
- package/template/src/components/ui/button.tsx +58 -0
- package/template/src/components/ui/calendar.tsx +277 -0
- package/template/src/components/ui/card.tsx +99 -0
- package/template/src/components/ui/chart.tsx +370 -0
- package/template/src/components/ui/checkbox.tsx +28 -0
- package/template/src/components/ui/combobox.tsx +329 -0
- package/template/src/components/ui/command.tsx +191 -0
- package/template/src/components/ui/dialog.tsx +162 -0
- package/template/src/components/ui/dropdown-menu.tsx +271 -0
- package/template/src/components/ui/empty.tsx +103 -0
- package/template/src/components/ui/field.tsx +236 -0
- package/template/src/components/ui/input-group.tsx +263 -0
- package/template/src/components/ui/input.tsx +45 -0
- package/template/src/components/ui/kbd.tsx +31 -0
- package/template/src/components/ui/label.tsx +17 -0
- package/template/src/components/ui/pagination.tsx +134 -0
- package/template/src/components/ui/popover.tsx +87 -0
- package/template/src/components/ui/radio-group.tsx +35 -0
- package/template/src/components/ui/select.tsx +204 -0
- package/template/src/components/ui/separator.tsx +25 -0
- package/template/src/components/ui/sheet.tsx +139 -0
- package/template/src/components/ui/sidebar.tsx +741 -0
- package/template/src/components/ui/skeleton.tsx +13 -0
- package/template/src/components/ui/sonner.tsx +78 -0
- package/template/src/components/ui/spinner.tsx +42 -0
- package/template/src/components/ui/switch.tsx +29 -0
- package/template/src/components/ui/table.tsx +121 -0
- package/template/src/components/ui/tabs.tsx +191 -0
- package/template/src/components/ui/textarea.tsx +17 -0
- package/template/src/components/ui/toggle-group.tsx +94 -0
- package/template/src/components/ui/toggle.tsx +48 -0
- package/template/src/components/ui/tooltip.tsx +91 -0
- package/template/src/core/api/demo.ts +8 -0
- package/template/src/core/api/http.ts +76 -0
- package/template/src/core/api/mock.ts +88 -0
- package/template/src/core/api/rest.ts +67 -0
- package/template/src/core/api/types.ts +21 -0
- package/template/src/core/audit/audit.service.ts +94 -0
- package/template/src/core/audit/hooks.ts +19 -0
- package/template/src/core/auth/auth-store.ts +52 -0
- package/template/src/core/auth/auth.service.ts +86 -0
- package/template/src/core/auth/can.tsx +39 -0
- package/template/src/core/auth/dev-role-switcher.tsx +64 -0
- package/template/src/core/auth/local-credentials.ts +46 -0
- package/template/src/core/auth/permissions.ts +115 -0
- package/template/src/core/auth/require-auth.tsx +16 -0
- package/template/src/core/auth/require-permission.tsx +24 -0
- package/template/src/core/auth/roles.ts +9 -0
- package/template/src/core/auth/types.ts +20 -0
- package/template/src/core/auth/use-auth.ts +19 -0
- package/template/src/core/company/company.service.ts +94 -0
- package/template/src/core/company/hooks.ts +40 -0
- package/template/src/core/company/password-policy.ts +40 -0
- package/template/src/core/company/types.ts +46 -0
- package/template/src/core/company/use-app-name.ts +7 -0
- package/template/src/core/components/button-link.tsx +14 -0
- package/template/src/core/components/confirm-dialog.tsx +116 -0
- package/template/src/core/components/data-table/data-table.tsx +297 -0
- package/template/src/core/components/data-table/features.ts +24 -0
- package/template/src/core/components/data-table/table-header-cell.tsx +13 -0
- package/template/src/core/components/data-table/toolbar.tsx +100 -0
- package/template/src/core/components/data-table/use-list-url-state.ts +108 -0
- package/template/src/core/components/date-picker.tsx +71 -0
- package/template/src/core/components/file-attachments.tsx +84 -0
- package/template/src/core/components/form-error.tsx +9 -0
- package/template/src/core/components/form-page.tsx +93 -0
- package/template/src/core/components/image-upload.tsx +86 -0
- package/template/src/core/components/page.tsx +75 -0
- package/template/src/core/components/phone-input.tsx +58 -0
- package/template/src/core/components/search-select.tsx +57 -0
- package/template/src/core/components/segmented.tsx +41 -0
- package/template/src/core/components/states.tsx +97 -0
- package/template/src/core/components/status-badge.tsx +26 -0
- package/template/src/core/components/user-avatar.tsx +23 -0
- package/template/src/core/config/runtime.ts +16 -0
- package/template/src/core/config/types.ts +37 -0
- package/template/src/core/i18n/i18n.ts +69 -0
- package/template/src/core/i18n/i18next.d.ts +12 -0
- package/template/src/core/i18n/keys.ts +37 -0
- package/template/src/core/i18n/language-switcher.tsx +45 -0
- package/template/src/core/i18n/types.ts +17 -0
- package/template/src/core/i18n/use-format.ts +31 -0
- package/template/src/core/layout/app-shell.tsx +50 -0
- package/template/src/core/layout/app-sidebar.tsx +285 -0
- package/template/src/core/layout/command-menu.tsx +117 -0
- package/template/src/core/layout/top-bar.tsx +100 -0
- package/template/src/core/lib/csv.ts +27 -0
- package/template/src/core/lib/format.ts +87 -0
- package/template/src/core/lib/phone.ts +50 -0
- package/template/src/core/lib/storage.ts +27 -0
- package/template/src/core/modules/lazy-page.ts +9 -0
- package/template/src/core/modules/navigation.ts +39 -0
- package/template/src/core/modules/types.ts +55 -0
- package/template/src/core/notifications/hooks.ts +84 -0
- package/template/src/core/notifications/notification-center.tsx +251 -0
- package/template/src/core/notifications/notifications.service.ts +132 -0
- package/template/src/core/notifications/types.ts +70 -0
- package/template/src/core/preferences/preferences-store.ts +70 -0
- package/template/src/core/setup/setup-gate.tsx +20 -0
- package/template/src/core/setup/setup.service.ts +101 -0
- package/template/src/core/theme/brand-context.tsx +53 -0
- package/template/src/core/theme/brand.ts +29 -0
- package/template/src/core/theme/theme-context.tsx +62 -0
- package/template/src/core/theme/theme-options.ts +8 -0
- package/template/src/core/theme/theme-toggle.tsx +45 -0
- package/template/src/core/users/hooks.ts +51 -0
- package/template/src/core/users/types.ts +37 -0
- package/template/src/core/users/users.service.ts +167 -0
- package/template/src/hooks/use-mobile.ts +19 -0
- package/template/src/index.css +321 -0
- package/template/src/lib/utils.ts +1 -0
- package/template/src/locales/en/account.ts +180 -0
- package/template/src/locales/en/auth.ts +18 -0
- package/template/src/locales/en/common.ts +151 -0
- package/template/src/locales/en/company.ts +380 -0
- package/template/src/locales/en/dashboard.ts +46 -0
- package/template/src/locales/en/errors.ts +18 -0
- package/template/src/locales/en/notifications.ts +43 -0
- package/template/src/locales/en/settings.ts +25 -0
- package/template/src/locales/en/setup.ts +45 -0
- package/template/src/locales/index.ts +58 -0
- package/template/src/locales/tr/account.ts +184 -0
- package/template/src/locales/tr/auth.ts +22 -0
- package/template/src/locales/tr/common.ts +154 -0
- package/template/src/locales/tr/company.ts +384 -0
- package/template/src/locales/tr/dashboard.ts +50 -0
- package/template/src/locales/tr/errors.ts +22 -0
- package/template/src/locales/tr/notifications.ts +47 -0
- package/template/src/locales/tr/settings.ts +29 -0
- package/template/src/locales/tr/setup.ts +49 -0
- package/template/src/main.tsx +16 -0
- package/template/src/modules/locales.ts +10 -0
- package/template/src/modules/registry.ts +17 -0
- package/template/src/pages/dashboard/dashboard-page.tsx +55 -0
- package/template/src/pages/dashboard/quick-actions.tsx +37 -0
- package/template/src/pages/login-page.tsx +152 -0
- package/template/src/pages/no-access-page.tsx +11 -0
- package/template/src/pages/not-found-page.tsx +61 -0
- package/template/src/pages/settings/account/account.service.ts +128 -0
- package/template/src/pages/settings/account/components/mock-qr.tsx +50 -0
- package/template/src/pages/settings/account/components/password-strength.tsx +38 -0
- package/template/src/pages/settings/account/components/two-factor-dialog.tsx +140 -0
- package/template/src/pages/settings/account/hooks.ts +73 -0
- package/template/src/pages/settings/account/notifications-page.tsx +103 -0
- package/template/src/pages/settings/account/preferences-page.tsx +157 -0
- package/template/src/pages/settings/account/profile-page.tsx +242 -0
- package/template/src/pages/settings/account/security-page.tsx +364 -0
- package/template/src/pages/settings/account/types.ts +37 -0
- package/template/src/pages/settings/company/audit-log-page.tsx +126 -0
- package/template/src/pages/settings/company/branding-page.tsx +278 -0
- package/template/src/pages/settings/company/components/invite-dialog.tsx +106 -0
- package/template/src/pages/settings/company/components/read-only-notice.tsx +13 -0
- package/template/src/pages/settings/company/danger-zone-page.tsx +127 -0
- package/template/src/pages/settings/company/general-page.tsx +234 -0
- package/template/src/pages/settings/company/integrations/api-keys-panel.tsx +170 -0
- package/template/src/pages/settings/company/integrations/hooks.ts +37 -0
- package/template/src/pages/settings/company/integrations/integrations-page.tsx +57 -0
- package/template/src/pages/settings/company/integrations/integrations.service.ts +153 -0
- package/template/src/pages/settings/company/integrations/settings-forms.tsx +299 -0
- package/template/src/pages/settings/company/integrations/webhooks-panel.tsx +179 -0
- package/template/src/pages/settings/company/modules-page.tsx +65 -0
- package/template/src/pages/settings/company/roles-page.tsx +116 -0
- package/template/src/pages/settings/company/security-policies-page.tsx +164 -0
- package/template/src/pages/settings/company/users-page.tsx +262 -0
- package/template/src/pages/settings/settings-layout.tsx +57 -0
- package/template/src/pages/settings/settings-nav.ts +69 -0
- package/template/src/pages/settings/settings-routes.tsx +49 -0
- package/template/src/pages/setup-page.tsx +387 -0
- package/template/src/providers.tsx +68 -0
- package/template/src/router.tsx +59 -0
- package/template/tools/check-design.mjs +73 -0
- package/template/tools/new-module.mjs +90 -0
- package/template/tools/templates/module/api/__id__.mock.ts.tpl +17 -0
- package/template/tools/templates/module/api/__id__.service.ts.tpl +51 -0
- package/template/tools/templates/module/hooks.ts.tpl +57 -0
- package/template/tools/templates/module/index.ts.tpl +44 -0
- package/template/tools/templates/module/locales/en.ts.tpl +51 -0
- package/template/tools/templates/module/locales/tr.ts.tpl +55 -0
- package/template/tools/templates/module/pages/__id__-detail-page.tsx.tpl +103 -0
- package/template/tools/templates/module/pages/__id__-form-page.tsx.tpl +121 -0
- package/template/tools/templates/module/pages/__id__-list-page.tsx.tpl +234 -0
- package/template/tools/templates/module/types.ts.tpl +32 -0
- package/template/tsconfig.app.json +32 -0
- package/template/tsconfig.json +12 -0
- package/template/tsconfig.node.json +23 -0
- package/template/vite.config.ts +47 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Badge } from '@/components/ui/badge'
|
|
2
|
+
import { cn } from '@/lib/utils'
|
|
3
|
+
|
|
4
|
+
export type StatusTone = 'success' | 'warning' | 'danger' | 'info' | 'neutral'
|
|
5
|
+
|
|
6
|
+
const dot: Record<StatusTone, string> = {
|
|
7
|
+
success: 'bg-success',
|
|
8
|
+
warning: 'bg-warning',
|
|
9
|
+
danger: 'bg-danger',
|
|
10
|
+
info: 'bg-info',
|
|
11
|
+
neutral: 'bg-muted-foreground/50',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Neutral outline pill with a semantic color dot: color only where it carries meaning. */
|
|
15
|
+
export function StatusBadge({ tone, children, className }: { tone: StatusTone; children: React.ReactNode; className?: string }) {
|
|
16
|
+
return (
|
|
17
|
+
<Badge variant="outline" className={cn('gap-1.5 font-medium', className)}>
|
|
18
|
+
<span aria-hidden className={cn('size-1.5 rounded-full', dot[tone])} />
|
|
19
|
+
{children}
|
|
20
|
+
</Badge>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function StatusDot({ tone, className }: { tone: StatusTone; className?: string }) {
|
|
25
|
+
return <span aria-hidden className={cn('inline-block size-1.5 shrink-0 rounded-full', dot[tone], className)} />
|
|
26
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
|
2
|
+
import { initials } from '@/core/lib/format'
|
|
3
|
+
import { fullName, type User } from '@/core/users/types'
|
|
4
|
+
import { cn } from '@/lib/utils'
|
|
5
|
+
|
|
6
|
+
/** Square-ish avatar: uploaded image, else initials (or the email's first letter for invites). */
|
|
7
|
+
export function UserAvatar({
|
|
8
|
+
user,
|
|
9
|
+
className,
|
|
10
|
+
}: {
|
|
11
|
+
user: Pick<User, 'firstName' | 'lastName' | 'email' | 'avatarUrl'>
|
|
12
|
+
className?: string
|
|
13
|
+
}) {
|
|
14
|
+
const name = fullName(user)
|
|
15
|
+
return (
|
|
16
|
+
<Avatar className={cn('size-8 rounded-lg', className)}>
|
|
17
|
+
{user.avatarUrl && <AvatarImage src={user.avatarUrl} alt="" className="rounded-lg object-cover" />}
|
|
18
|
+
<AvatarFallback className="rounded-lg bg-fill-2 text-xs font-medium">
|
|
19
|
+
{name ? initials(name) : user.email[0]?.toUpperCase()}
|
|
20
|
+
</AvatarFallback>
|
|
21
|
+
</Avatar>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { appConfig } from '@/app.config'
|
|
2
|
+
import type { AppConfig, FeatureId } from './types'
|
|
3
|
+
|
|
4
|
+
// Typed as AppConfig so a project's app.config.ts may omit newer optional keys.
|
|
5
|
+
const config: AppConfig = appConfig
|
|
6
|
+
|
|
7
|
+
/** Optional built-in areas are on unless app.config.ts switches them off. */
|
|
8
|
+
export const isFeatureOn = (feature: FeatureId) => config.features?.[feature] ?? true
|
|
9
|
+
|
|
10
|
+
export const isDemo = config.demo ?? false
|
|
11
|
+
|
|
12
|
+
export const authMode = config.auth?.mode ?? 'mock'
|
|
13
|
+
export const csrfUrl = config.auth?.csrfUrl
|
|
14
|
+
|
|
15
|
+
/** `/` shows the landing page, or goes straight to the panel. */
|
|
16
|
+
export const showLanding = config.landing ?? true
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { I18nKey } from '@/core/i18n/keys'
|
|
2
|
+
import type { BrandSetting } from '@/core/theme/brand'
|
|
3
|
+
import type { ModuleId } from '@/modules/registry'
|
|
4
|
+
|
|
5
|
+
export type Workspace = { id: string; name: string; plan?: I18nKey }
|
|
6
|
+
|
|
7
|
+
export type NavGroupConfig = { id: string; label: I18nKey }
|
|
8
|
+
|
|
9
|
+
/** Built-in areas a project can switch off; see core/config/runtime.ts. */
|
|
10
|
+
export type FeatureId =
|
|
11
|
+
| 'notifications'
|
|
12
|
+
| 'users'
|
|
13
|
+
| 'roles'
|
|
14
|
+
| 'securityPolicies'
|
|
15
|
+
| 'modules'
|
|
16
|
+
| 'integrations'
|
|
17
|
+
| 'auditLog'
|
|
18
|
+
| 'dangerZone'
|
|
19
|
+
|
|
20
|
+
/** mock: demo data; bearer: token in the Authorization header; cookie: session cookie + XSRF (Laravel Sanctum). */
|
|
21
|
+
export type AuthMode = 'mock' | 'bearer' | 'cookie'
|
|
22
|
+
|
|
23
|
+
export type AppConfig = {
|
|
24
|
+
name: string
|
|
25
|
+
brand: BrandSetting
|
|
26
|
+
workspaces: Workspace[]
|
|
27
|
+
navGroups: NavGroupConfig[]
|
|
28
|
+
modules: Partial<Record<ModuleId, boolean>>
|
|
29
|
+
currency: string
|
|
30
|
+
apiBaseUrl: string
|
|
31
|
+
/** The public demo: sample data, any password, no setup wizard. Real installs leave this off. */
|
|
32
|
+
demo?: boolean
|
|
33
|
+
/** Show the marketing landing page at `/`; when false, `/` opens the panel. Default true. */
|
|
34
|
+
landing?: boolean
|
|
35
|
+
features?: Partial<Record<FeatureId, boolean>>
|
|
36
|
+
auth?: { mode: AuthMode; csrfUrl?: string }
|
|
37
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import i18n from 'i18next'
|
|
2
|
+
import { initReactI18next } from 'react-i18next'
|
|
3
|
+
|
|
4
|
+
import { customPermissions, customRoles, type CustomRole, type LocalizedText } from '@/app.roles'
|
|
5
|
+
import { storage } from '@/core/lib/storage'
|
|
6
|
+
import { namespaces, resources } from '@/locales'
|
|
7
|
+
import { languages, type Language } from './types'
|
|
8
|
+
|
|
9
|
+
/** localStorage key (`fp:lang`). */
|
|
10
|
+
const STORAGE_KEY = 'lang'
|
|
11
|
+
|
|
12
|
+
const isLanguage = (value: unknown): value is Language => languages.includes(value as Language)
|
|
13
|
+
|
|
14
|
+
/** Saved choice first; on the first visit Turkish browsers get TR, everyone else EN. */
|
|
15
|
+
function detectLanguage(): Language {
|
|
16
|
+
const saved = storage.get<string>(STORAGE_KEY)
|
|
17
|
+
if (isLanguage(saved)) return saved
|
|
18
|
+
const browser = typeof navigator === 'undefined' ? [] : (navigator.languages ?? [navigator.language])
|
|
19
|
+
return browser.some((l) => l?.toLowerCase().startsWith('tr')) ? 'tr' : 'en'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
void i18n.use(initReactI18next).init({
|
|
23
|
+
resources,
|
|
24
|
+
lng: detectLanguage(),
|
|
25
|
+
fallbackLng: 'en',
|
|
26
|
+
supportedLngs: languages,
|
|
27
|
+
ns: namespaces,
|
|
28
|
+
defaultNS: 'common',
|
|
29
|
+
interpolation: { escapeValue: false }, // React already escapes.
|
|
30
|
+
returnNull: false,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// Labels of project-defined roles and permissions live in src/app.roles.ts.
|
|
34
|
+
for (const lng of languages) {
|
|
35
|
+
const extraRoles = Object.entries(customRoles as Record<string, CustomRole>)
|
|
36
|
+
const extraPermissions = customPermissions as readonly { id: string; label: LocalizedText }[]
|
|
37
|
+
const pick = (list: [string, LocalizedText][]) => Object.fromEntries(list.map(([id, text]) => [id, text[lng]]))
|
|
38
|
+
i18n.addResourceBundle(lng, 'common', { roles: pick(extraRoles.map(([id, r]) => [id, r.label])) }, true, true)
|
|
39
|
+
i18n.addResourceBundle(
|
|
40
|
+
lng,
|
|
41
|
+
'company',
|
|
42
|
+
{
|
|
43
|
+
roles: {
|
|
44
|
+
descriptions: pick(extraRoles.map(([id, r]) => [id, r.description])),
|
|
45
|
+
// Same id as customResourceId() in core/auth/permissions.ts.
|
|
46
|
+
resources: pick(extraPermissions.map((p) => [`custom_${p.id.replaceAll('.', '_')}`, p.label])),
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
true,
|
|
50
|
+
true,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function syncDocument(lng: string) {
|
|
55
|
+
document.documentElement.lang = lng
|
|
56
|
+
}
|
|
57
|
+
syncDocument(i18n.language)
|
|
58
|
+
i18n.on('languageChanged', syncDocument)
|
|
59
|
+
|
|
60
|
+
export function setLanguage(lng: Language) {
|
|
61
|
+
storage.set(STORAGE_KEY, lng)
|
|
62
|
+
void i18n.changeLanguage(lng)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function currentLanguage(): Language {
|
|
66
|
+
return isLanguage(i18n.resolvedLanguage) ? i18n.resolvedLanguage : 'en'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export { i18n }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import 'i18next'
|
|
2
|
+
|
|
3
|
+
import type { Resources } from '@/locales'
|
|
4
|
+
|
|
5
|
+
// Typed keys: t('…') only accepts keys that exist in the English resources.
|
|
6
|
+
declare module 'i18next' {
|
|
7
|
+
interface CustomTypeOptions {
|
|
8
|
+
defaultNS: 'common'
|
|
9
|
+
resources: Resources
|
|
10
|
+
returnNull: false
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useCallback } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import type { ParseKeys, TOptions } from 'i18next'
|
|
4
|
+
|
|
5
|
+
import { namespaces, type Namespace } from '@/locales'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A key from any namespace, written `ns:key` (or a bare `common` key).
|
|
9
|
+
* Use it in config-like data that is translated later: nav labels, route
|
|
10
|
+
* crumbs, quick actions, validation messages.
|
|
11
|
+
*/
|
|
12
|
+
export type I18nKey = ParseKeys<Namespace[]>
|
|
13
|
+
|
|
14
|
+
type AnyT = (key: I18nKey, options?: TOptions) => string
|
|
15
|
+
|
|
16
|
+
/** `t` for I18nKey values. In screens prefer useTranslation(['<ns>', 'common']). */
|
|
17
|
+
export function useAnyT(): AnyT {
|
|
18
|
+
const { t } = useTranslation(namespaces)
|
|
19
|
+
return useCallback<AnyT>((key, options) => (t as unknown as AnyT)(key, options), [t])
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validation message for Zod schemas. Schemas are created once at module
|
|
24
|
+
* level, so they store a key (plus values) that FormFieldError translates
|
|
25
|
+
* at render time, and errors switch language instantly.
|
|
26
|
+
*
|
|
27
|
+
* z.string().min(2, vmsg('validation.minChars', { min: 2 }))
|
|
28
|
+
*/
|
|
29
|
+
export function vmsg(key: I18nKey, values?: Record<string, string | number>) {
|
|
30
|
+
return values ? `${key}|${JSON.stringify(values)}` : key
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function translateMessage(t: AnyT, message: string) {
|
|
34
|
+
const [key, json] = message.split(/\|(.*)/s)
|
|
35
|
+
const values = json ? (JSON.parse(json) as Record<string, unknown>) : undefined
|
|
36
|
+
return t(key as I18nKey, { ...values, defaultValue: message })
|
|
37
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useTranslation } from 'react-i18next'
|
|
2
|
+
import { Languages } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
import { Button } from '@/components/ui/button'
|
|
5
|
+
import {
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
DropdownMenuContent,
|
|
8
|
+
DropdownMenuGroup,
|
|
9
|
+
DropdownMenuLabel,
|
|
10
|
+
DropdownMenuRadioGroup,
|
|
11
|
+
DropdownMenuRadioItem,
|
|
12
|
+
DropdownMenuTrigger,
|
|
13
|
+
} from '@/components/ui/dropdown-menu'
|
|
14
|
+
import { currentLanguage, setLanguage } from './i18n'
|
|
15
|
+
import { languageNames, languages, type Language } from './types'
|
|
16
|
+
|
|
17
|
+
/** Ghost button + menu; sits next to ThemeToggle in headers. */
|
|
18
|
+
export function LanguageSwitcher() {
|
|
19
|
+
const { t } = useTranslation()
|
|
20
|
+
const active = currentLanguage()
|
|
21
|
+
return (
|
|
22
|
+
<DropdownMenu>
|
|
23
|
+
<DropdownMenuTrigger
|
|
24
|
+
render={
|
|
25
|
+
<Button variant="ghost" size="sm" aria-label={t('language.change')} className="gap-1 px-2 text-muted-foreground">
|
|
26
|
+
<Languages />
|
|
27
|
+
<span className="tabular-nums text-[11px] uppercase">{active}</span>
|
|
28
|
+
</Button>
|
|
29
|
+
}
|
|
30
|
+
/>
|
|
31
|
+
<DropdownMenuContent align="end" className="w-40">
|
|
32
|
+
<DropdownMenuGroup>
|
|
33
|
+
<DropdownMenuLabel>{t('language.label')}</DropdownMenuLabel>
|
|
34
|
+
<DropdownMenuRadioGroup value={active} onValueChange={(v) => setLanguage(v as Language)}>
|
|
35
|
+
{languages.map((lng) => (
|
|
36
|
+
<DropdownMenuRadioItem key={lng} value={lng} lang={lng}>
|
|
37
|
+
{languageNames[lng]}
|
|
38
|
+
</DropdownMenuRadioItem>
|
|
39
|
+
))}
|
|
40
|
+
</DropdownMenuRadioGroup>
|
|
41
|
+
</DropdownMenuGroup>
|
|
42
|
+
</DropdownMenuContent>
|
|
43
|
+
</DropdownMenu>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shape of a translation file derived from the English source. Turkish (and
|
|
3
|
+
* any future language) files are typed with this, so a missing or extra key
|
|
4
|
+
* is a compile error:
|
|
5
|
+
*
|
|
6
|
+
* const tr: Messages<typeof en> = { … }
|
|
7
|
+
*/
|
|
8
|
+
export type Messages<T> = { [K in keyof T]: T[K] extends string ? string : Messages<T[K]> }
|
|
9
|
+
|
|
10
|
+
export const languages = ['en', 'tr'] as const
|
|
11
|
+
export type Language = (typeof languages)[number]
|
|
12
|
+
|
|
13
|
+
/** BCP 47 locale used by Intl for each language. */
|
|
14
|
+
export const intlLocales: Record<Language, string> = { en: 'en-US', tr: 'tr-TR' }
|
|
15
|
+
|
|
16
|
+
/** Native names: always shown in their own language. */
|
|
17
|
+
export const languageNames: Record<Language, string> = { en: 'English', tr: 'Türkçe' }
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
|
|
4
|
+
import { appConfig } from '@/app.config'
|
|
5
|
+
import { useCompanySettings } from '@/core/company/hooks'
|
|
6
|
+
import { createFormatters } from '@/core/lib/format'
|
|
7
|
+
import { usePreferences } from '@/core/preferences/preferences-store'
|
|
8
|
+
import { intlLocales, type Language } from './types'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Formatters for the active language, the user's time zone and date format,
|
|
12
|
+
* and the company currency. Re-renders the caller when any of them change.
|
|
13
|
+
* Use in components (and pass to memoized table columns as a dependency).
|
|
14
|
+
*/
|
|
15
|
+
export function useFormat() {
|
|
16
|
+
const { i18n } = useTranslation()
|
|
17
|
+
const { timeZone, dateFormat } = usePreferences()
|
|
18
|
+
const lng = (i18n.resolvedLanguage ?? 'en') as Language
|
|
19
|
+
const currency = useCompanySettings().data?.general.currency ?? appConfig.currency
|
|
20
|
+
|
|
21
|
+
return useMemo(
|
|
22
|
+
() =>
|
|
23
|
+
createFormatters({
|
|
24
|
+
locale: intlLocales[lng] ?? intlLocales.en,
|
|
25
|
+
currency,
|
|
26
|
+
timeZone: timeZone === 'auto' ? undefined : timeZone,
|
|
27
|
+
dateFormat,
|
|
28
|
+
}),
|
|
29
|
+
[lng, currency, timeZone, dateFormat],
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Outlet } from 'react-router'
|
|
3
|
+
|
|
4
|
+
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
|
|
5
|
+
import { RequireAuth } from '@/core/auth/require-auth'
|
|
6
|
+
import { RequirePermissions } from '@/core/auth/require-permission'
|
|
7
|
+
import { storage } from '@/core/lib/storage'
|
|
8
|
+
import { preferencesStore } from '@/core/preferences/preferences-store'
|
|
9
|
+
import { AppSidebar } from './app-sidebar'
|
|
10
|
+
import { CommandMenuProvider } from './command-menu'
|
|
11
|
+
import { TopBar } from './top-bar'
|
|
12
|
+
|
|
13
|
+
const SIDEBAR_KEY = 'sidebar-open'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Warm canvas with the sidebar sitting on it and the page in a floating,
|
|
17
|
+
* rounded panel inset from the canvas edges. The panel scrolls internally.
|
|
18
|
+
*/
|
|
19
|
+
export function AppShell() {
|
|
20
|
+
// Settings → Preferences → Sidebar: remember the last state, or always start expanded/collapsed.
|
|
21
|
+
const [sidebarOpen, setSidebarOpen] = useState(() => {
|
|
22
|
+
const start = preferencesStore.get().sidebarDefault
|
|
23
|
+
return start === 'remember' ? (storage.get<boolean>(SIDEBAR_KEY) ?? true) : start === 'expanded'
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<RequireAuth>
|
|
28
|
+
<CommandMenuProvider>
|
|
29
|
+
<SidebarProvider
|
|
30
|
+
open={sidebarOpen}
|
|
31
|
+
onOpenChange={(open) => {
|
|
32
|
+
storage.set(SIDEBAR_KEY, open)
|
|
33
|
+
setSidebarOpen(open)
|
|
34
|
+
}}
|
|
35
|
+
className="bg-canvas"
|
|
36
|
+
>
|
|
37
|
+
<AppSidebar />
|
|
38
|
+
<SidebarInset className="min-w-0 md:peer-data-[variant=inset]:h-[calc(100svh-var(--panel-inset)*2)] md:peer-data-[variant=inset]:overflow-hidden md:peer-data-[variant=inset]:rounded-(--radius-panel) md:peer-data-[variant=inset]:shadow-(--shadow-panel) md:peer-data-[variant=inset]:ring-1 md:peer-data-[variant=inset]:ring-border">
|
|
39
|
+
<TopBar />
|
|
40
|
+
<div id="main" className="flex-1 overflow-y-auto px-(--page-gutter-mobile) py-6 md:px-(--page-gutter) md:py-8">
|
|
41
|
+
<RequirePermissions>
|
|
42
|
+
<Outlet />
|
|
43
|
+
</RequirePermissions>
|
|
44
|
+
</div>
|
|
45
|
+
</SidebarInset>
|
|
46
|
+
</SidebarProvider>
|
|
47
|
+
</CommandMenuProvider>
|
|
48
|
+
</RequireAuth>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { Link, useLocation, useNavigate } from 'react-router'
|
|
4
|
+
import { Check, ChevronsUpDown, LogOut, Plus, Search, UserRound } from 'lucide-react'
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
DropdownMenu,
|
|
8
|
+
DropdownMenuContent,
|
|
9
|
+
DropdownMenuGroup,
|
|
10
|
+
DropdownMenuItem,
|
|
11
|
+
DropdownMenuLabel,
|
|
12
|
+
DropdownMenuSeparator,
|
|
13
|
+
DropdownMenuTrigger,
|
|
14
|
+
} from '@/components/ui/dropdown-menu'
|
|
15
|
+
import { Kbd } from '@/components/ui/kbd'
|
|
16
|
+
import {
|
|
17
|
+
Sidebar,
|
|
18
|
+
SidebarContent,
|
|
19
|
+
SidebarFooter,
|
|
20
|
+
SidebarGroup,
|
|
21
|
+
SidebarGroupContent,
|
|
22
|
+
SidebarGroupLabel,
|
|
23
|
+
SidebarHeader,
|
|
24
|
+
SidebarMenu,
|
|
25
|
+
SidebarMenuBadge,
|
|
26
|
+
SidebarMenuButton,
|
|
27
|
+
SidebarMenuItem,
|
|
28
|
+
SidebarRail,
|
|
29
|
+
useSidebar,
|
|
30
|
+
} from '@/components/ui/sidebar'
|
|
31
|
+
import { appConfig } from '@/app.config'
|
|
32
|
+
import { useAppName } from '@/core/company/use-app-name'
|
|
33
|
+
import type { Workspace } from '@/core/config/types'
|
|
34
|
+
import { useAuth } from '@/core/auth/use-auth'
|
|
35
|
+
import { useCompanySettings } from '@/core/company/hooks'
|
|
36
|
+
import { useTheme } from '@/core/theme/theme-context'
|
|
37
|
+
import { useAnyT } from '@/core/i18n/keys'
|
|
38
|
+
import { storage } from '@/core/lib/storage'
|
|
39
|
+
import { usePermissions } from '@/core/auth/can'
|
|
40
|
+
import { getNavGroups, isNavItemActive } from '@/core/modules/navigation'
|
|
41
|
+
import { fullName } from '@/core/users/types'
|
|
42
|
+
import { UserAvatar } from '@/core/components/user-avatar'
|
|
43
|
+
import type { NavItem } from '@/core/modules/types'
|
|
44
|
+
import { cn } from '@/lib/utils'
|
|
45
|
+
import { isMac, useCommandMenu } from './command-menu'
|
|
46
|
+
|
|
47
|
+
/** Raised, bordered pill for the active item; brand-colored icon. */
|
|
48
|
+
const navButtonClass = cn(
|
|
49
|
+
'h-8 text-[13px] text-muted-foreground hover:text-foreground',
|
|
50
|
+
'data-active:bg-raised data-active:text-foreground data-active:shadow-(--shadow-raised) data-active:ring-1 data-active:ring-border data-active:hover:bg-raised',
|
|
51
|
+
'data-active:[&>svg]:text-primary',
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
export function AppSidebar() {
|
|
56
|
+
const t = useAnyT()
|
|
57
|
+
const { can } = usePermissions()
|
|
58
|
+
const navGroups = getNavGroups(can)
|
|
59
|
+
return (
|
|
60
|
+
<Sidebar variant="inset" collapsible="icon">
|
|
61
|
+
<SidebarHeader className="gap-2 pb-0">
|
|
62
|
+
<WorkspaceSwitcher />
|
|
63
|
+
<SearchButton />
|
|
64
|
+
</SidebarHeader>
|
|
65
|
+
|
|
66
|
+
<SidebarContent className="pt-2">
|
|
67
|
+
{navGroups.map((group) => (
|
|
68
|
+
<SidebarGroup key={group.id} className="py-1.5">
|
|
69
|
+
<SidebarGroupLabel className="label-caps h-7 text-[10.5px]">{t(group.label)}</SidebarGroupLabel>
|
|
70
|
+
<SidebarGroupContent>
|
|
71
|
+
<SidebarMenu className="gap-0.5">
|
|
72
|
+
{group.items.map((item) => (
|
|
73
|
+
<NavMenuItem key={item.path} item={item} />
|
|
74
|
+
))}
|
|
75
|
+
</SidebarMenu>
|
|
76
|
+
</SidebarGroupContent>
|
|
77
|
+
</SidebarGroup>
|
|
78
|
+
))}
|
|
79
|
+
</SidebarContent>
|
|
80
|
+
|
|
81
|
+
<SidebarFooter>
|
|
82
|
+
<UserCard />
|
|
83
|
+
</SidebarFooter>
|
|
84
|
+
<SidebarRail />
|
|
85
|
+
</Sidebar>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function NavMenuItem({ item }: { item: NavItem }) {
|
|
90
|
+
const { pathname } = useLocation()
|
|
91
|
+
const { setOpenMobile } = useSidebar()
|
|
92
|
+
const badge = item.useBadge?.()
|
|
93
|
+
const t = useAnyT()
|
|
94
|
+
const label = t(item.label)
|
|
95
|
+
const active = isNavItemActive(item, pathname)
|
|
96
|
+
const Icon = item.icon
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<SidebarMenuItem>
|
|
100
|
+
<SidebarMenuButton
|
|
101
|
+
isActive={active}
|
|
102
|
+
tooltip={label}
|
|
103
|
+
className={navButtonClass}
|
|
104
|
+
render={<Link to={item.path} aria-current={active ? 'page' : undefined} onClick={() => setOpenMobile(false)} />}
|
|
105
|
+
>
|
|
106
|
+
<Icon />
|
|
107
|
+
<span>{label}</span>
|
|
108
|
+
</SidebarMenuButton>
|
|
109
|
+
{badge ? (
|
|
110
|
+
<SidebarMenuBadge className="top-1.5! rounded-md bg-fill-2 tabular-nums text-[10.5px]">{badge}</SidebarMenuBadge>
|
|
111
|
+
) : null}
|
|
112
|
+
</SidebarMenuItem>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function LogoTile({ name, logo, className }: { name: string; logo?: string; className?: string }) {
|
|
117
|
+
if (logo) {
|
|
118
|
+
return (
|
|
119
|
+
<div className={cn('flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg', className)}>
|
|
120
|
+
<img src={logo} alt="" className="max-h-full max-w-full object-contain" />
|
|
121
|
+
</div>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
return (
|
|
125
|
+
<div
|
|
126
|
+
className={cn(
|
|
127
|
+
'flex size-8 shrink-0 items-center justify-center rounded-lg bg-foreground text-sm font-semibold text-background',
|
|
128
|
+
className,
|
|
129
|
+
)}
|
|
130
|
+
>
|
|
131
|
+
{name[0]?.toUpperCase()}
|
|
132
|
+
</div>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function WorkspaceSwitcher() {
|
|
137
|
+
const workspaces: Workspace[] = appConfig.workspaces
|
|
138
|
+
const appName = useAppName()
|
|
139
|
+
const [activeId, setActiveId] = useState(() => storage.get<string>('workspace') ?? workspaces[0]!.id)
|
|
140
|
+
const active = workspaces.find((w) => w.id === activeId) ?? workspaces[0]!
|
|
141
|
+
const t = useAnyT()
|
|
142
|
+
// The active workspace shows the company logo from Settings → Company → Branding.
|
|
143
|
+
const branding = useCompanySettings().data?.branding
|
|
144
|
+
const { resolvedTheme } = useTheme()
|
|
145
|
+
const logo = resolvedTheme === 'dark' ? (branding?.logoDark ?? branding?.logoLight) : (branding?.logoLight ?? branding?.logoDark)
|
|
146
|
+
|
|
147
|
+
const select = (id: string) => {
|
|
148
|
+
storage.set('workspace', id)
|
|
149
|
+
setActiveId(id)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// One workspace: just the company name, nothing to switch.
|
|
153
|
+
if (workspaces.length === 1) {
|
|
154
|
+
return (
|
|
155
|
+
<SidebarMenu>
|
|
156
|
+
<SidebarMenuItem>
|
|
157
|
+
<div className="flex h-12 items-center gap-2 rounded-xl bg-raised p-2 shadow-(--shadow-raised) ring-1 ring-border group-data-[collapsible=icon]:size-8 group-data-[collapsible=icon]:p-0 group-data-[collapsible=icon]:shadow-none group-data-[collapsible=icon]:ring-0">
|
|
158
|
+
<LogoTile name={appName} logo={logo} />
|
|
159
|
+
<span className="truncate text-sm font-semibold group-data-[collapsible=icon]:hidden">{appName}</span>
|
|
160
|
+
</div>
|
|
161
|
+
</SidebarMenuItem>
|
|
162
|
+
</SidebarMenu>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<SidebarMenu>
|
|
168
|
+
<SidebarMenuItem>
|
|
169
|
+
<DropdownMenu>
|
|
170
|
+
<DropdownMenuTrigger
|
|
171
|
+
render={
|
|
172
|
+
<SidebarMenuButton
|
|
173
|
+
size="lg"
|
|
174
|
+
className="h-12 rounded-xl bg-raised shadow-(--shadow-raised) ring-1 ring-border hover:bg-raised data-popup-open:bg-raised group-data-[collapsible=icon]:ring-0 group-data-[collapsible=icon]:shadow-none"
|
|
175
|
+
aria-label={t('shell.switchWorkspace')}
|
|
176
|
+
/>
|
|
177
|
+
}
|
|
178
|
+
>
|
|
179
|
+
<LogoTile name={active.name} logo={logo} />
|
|
180
|
+
<div className="grid min-w-0 flex-1 text-left leading-tight">
|
|
181
|
+
<span className="truncate text-sm font-semibold">{active.name}</span>
|
|
182
|
+
{active.plan && <span className="truncate text-2xs text-muted-foreground">{t(active.plan)}</span>}
|
|
183
|
+
</div>
|
|
184
|
+
<ChevronsUpDown className="ml-auto size-4 text-muted-foreground" />
|
|
185
|
+
</DropdownMenuTrigger>
|
|
186
|
+
<DropdownMenuContent align="start" className="w-(--anchor-width) min-w-60">
|
|
187
|
+
<DropdownMenuGroup>
|
|
188
|
+
<DropdownMenuLabel className="label-caps">{t('shell.workspaces')}</DropdownMenuLabel>
|
|
189
|
+
{workspaces.map((w) => (
|
|
190
|
+
<DropdownMenuItem key={w.id} onClick={() => select(w.id)}>
|
|
191
|
+
<LogoTile name={w.name} className="size-6 rounded-md text-xs" />
|
|
192
|
+
<span className="flex-1 truncate">{w.name}</span>
|
|
193
|
+
{w.id === active.id && <Check className="text-primary" />}
|
|
194
|
+
</DropdownMenuItem>
|
|
195
|
+
))}
|
|
196
|
+
</DropdownMenuGroup>
|
|
197
|
+
<DropdownMenuSeparator />
|
|
198
|
+
<DropdownMenuItem disabled>
|
|
199
|
+
<Plus />
|
|
200
|
+
{t('shell.addWorkspace')}
|
|
201
|
+
</DropdownMenuItem>
|
|
202
|
+
</DropdownMenuContent>
|
|
203
|
+
</DropdownMenu>
|
|
204
|
+
</SidebarMenuItem>
|
|
205
|
+
</SidebarMenu>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function SearchButton() {
|
|
210
|
+
const { open } = useCommandMenu()
|
|
211
|
+
const { t } = useTranslation()
|
|
212
|
+
const shortcut = isMac ? '⌘K' : 'Ctrl K'
|
|
213
|
+
return (
|
|
214
|
+
<SidebarMenu>
|
|
215
|
+
<SidebarMenuItem>
|
|
216
|
+
<SidebarMenuButton
|
|
217
|
+
onClick={open}
|
|
218
|
+
tooltip={t('shell.search')}
|
|
219
|
+
aria-label={t('shell.searchShortcut', { shortcut })}
|
|
220
|
+
aria-keyshortcuts={isMac ? 'Meta+K' : 'Control+K'}
|
|
221
|
+
className="h-8 rounded-lg bg-fill-1 text-muted-foreground ring-1 ring-border/60 hover:bg-fill-2 group-data-[collapsible=icon]:bg-transparent group-data-[collapsible=icon]:ring-0"
|
|
222
|
+
>
|
|
223
|
+
<Search />
|
|
224
|
+
<span>{t('shell.search')}</span>
|
|
225
|
+
<Kbd className="ml-auto tabular-nums text-[10.5px] group-data-[collapsible=icon]:hidden">{shortcut}</Kbd>
|
|
226
|
+
</SidebarMenuButton>
|
|
227
|
+
</SidebarMenuItem>
|
|
228
|
+
</SidebarMenu>
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function UserCard() {
|
|
233
|
+
const { user, logout } = useAuth()
|
|
234
|
+
const navigate = useNavigate()
|
|
235
|
+
const { t } = useTranslation()
|
|
236
|
+
if (!user) return null
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<SidebarMenu>
|
|
240
|
+
<SidebarMenuItem>
|
|
241
|
+
<DropdownMenu>
|
|
242
|
+
<DropdownMenuTrigger
|
|
243
|
+
render={
|
|
244
|
+
<SidebarMenuButton
|
|
245
|
+
size="lg"
|
|
246
|
+
className="h-12 rounded-xl bg-raised shadow-(--shadow-raised) ring-1 ring-border hover:bg-raised data-popup-open:bg-raised group-data-[collapsible=icon]:ring-0 group-data-[collapsible=icon]:shadow-none"
|
|
247
|
+
aria-label={t('shell.accountMenu')}
|
|
248
|
+
/>
|
|
249
|
+
}
|
|
250
|
+
>
|
|
251
|
+
<UserAvatar user={user} className="size-8" />
|
|
252
|
+
<div className="grid min-w-0 flex-1 text-left leading-tight">
|
|
253
|
+
<span className="truncate text-sm font-medium">{fullName(user)}</span>
|
|
254
|
+
<span className="truncate text-2xs text-muted-foreground">{t(`roles.${user.role as 'owner'}`)}</span>
|
|
255
|
+
</div>
|
|
256
|
+
<ChevronsUpDown className="ml-auto size-4 text-muted-foreground" />
|
|
257
|
+
</DropdownMenuTrigger>
|
|
258
|
+
<DropdownMenuContent side="top" align="start" className="w-(--anchor-width) min-w-60">
|
|
259
|
+
<DropdownMenuGroup>
|
|
260
|
+
<DropdownMenuLabel className="flex flex-col">
|
|
261
|
+
<span className="text-sm font-medium text-foreground">{fullName(user)}</span>
|
|
262
|
+
<span className="text-xs font-normal text-muted-foreground">{user.email}</span>
|
|
263
|
+
</DropdownMenuLabel>
|
|
264
|
+
</DropdownMenuGroup>
|
|
265
|
+
<DropdownMenuSeparator />
|
|
266
|
+
<DropdownMenuItem onClick={() => navigate('/app/settings/profile')}>
|
|
267
|
+
<UserRound />
|
|
268
|
+
{t('shell.profile')}
|
|
269
|
+
</DropdownMenuItem>
|
|
270
|
+
<DropdownMenuSeparator />
|
|
271
|
+
<DropdownMenuItem
|
|
272
|
+
onClick={async () => {
|
|
273
|
+
await logout()
|
|
274
|
+
navigate('/login')
|
|
275
|
+
}}
|
|
276
|
+
>
|
|
277
|
+
<LogOut />
|
|
278
|
+
{t('shell.signOut')}
|
|
279
|
+
</DropdownMenuItem>
|
|
280
|
+
</DropdownMenuContent>
|
|
281
|
+
</DropdownMenu>
|
|
282
|
+
</SidebarMenuItem>
|
|
283
|
+
</SidebarMenu>
|
|
284
|
+
)
|
|
285
|
+
}
|