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,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Creates a module with the standard structure and registers it:
|
|
4
|
+
*
|
|
5
|
+
* npm run new-module -- orders --en "Orders" --tr "Siparişler" --group content
|
|
6
|
+
*
|
|
7
|
+
* The id is one lowercase word; it becomes the URL (/app/orders), the
|
|
8
|
+
* translation namespace and the permission prefix (orders.view, …).
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
|
|
13
|
+
const root = path.resolve(import.meta.dirname, '..')
|
|
14
|
+
const templates = path.join(import.meta.dirname, 'templates/module')
|
|
15
|
+
|
|
16
|
+
function fail(message) {
|
|
17
|
+
console.error(`new-module: ${message}`)
|
|
18
|
+
process.exit(1)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const args = process.argv.slice(2)
|
|
22
|
+
const option = (name, fallback) => {
|
|
23
|
+
const i = args.indexOf(`--${name}`)
|
|
24
|
+
return i >= 0 ? args[i + 1] : fallback
|
|
25
|
+
}
|
|
26
|
+
const id = args[0]
|
|
27
|
+
if (!id || id.startsWith('--')) fail('usage: npm run new-module -- <id> --en "Label" --tr "Etiket" [--group content]')
|
|
28
|
+
if (!/^[a-z][a-z0-9]*$/.test(id)) fail(`"${id}" must be one lowercase word (letters and digits), e.g. orders`)
|
|
29
|
+
|
|
30
|
+
const Name = id[0].toUpperCase() + id.slice(1)
|
|
31
|
+
const values = { __id__: id, __Name__: Name, __labelEn__: option('en', Name), __labelTr__: option('tr', Name), __group__: option('group', 'content') }
|
|
32
|
+
const fill = (text) => text.replace(/__(id|Name|labelEn|labelTr|group)__/g, (m) => values[m])
|
|
33
|
+
|
|
34
|
+
const moduleDir = path.join(root, 'src/modules', id)
|
|
35
|
+
if (existsSync(moduleDir)) fail(`src/modules/${id} already exists`)
|
|
36
|
+
|
|
37
|
+
/** Inserts `line` above the `// marker` comment, keeping its indentation and the file's line endings. */
|
|
38
|
+
function insert(file, entries) {
|
|
39
|
+
const abs = path.join(root, file)
|
|
40
|
+
let text = readFileSync(abs, 'utf8')
|
|
41
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n'
|
|
42
|
+
for (const [marker, line] of entries) {
|
|
43
|
+
const match = new RegExp(`^([ \\t]*)// ${marker}\\s*$`, 'm').exec(text)
|
|
44
|
+
if (!match) fail(`marker "// ${marker}" is missing from ${file}; add it where new entries belong`)
|
|
45
|
+
text = text.slice(0, match.index) + match[1] + line + eol + text.slice(match.index)
|
|
46
|
+
}
|
|
47
|
+
return () => writeFileSync(abs, text)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const config = readFileSync(path.join(root, 'src/app.config.ts'), 'utf8')
|
|
51
|
+
if (!config.includes(`id: '${values.__group__}'`)) fail(`no nav group "${values.__group__}" in src/app.config.ts → navGroups`)
|
|
52
|
+
|
|
53
|
+
// Check all three files first so a missing marker leaves nothing half-done.
|
|
54
|
+
const writes = [
|
|
55
|
+
insert('src/modules/registry.ts', [
|
|
56
|
+
['fp:module-imports', `import { ${id}Module } from './${id}'`],
|
|
57
|
+
['fp:module-registry', `${id}: ${id}Module,`],
|
|
58
|
+
]),
|
|
59
|
+
insert('src/modules/locales.ts', [
|
|
60
|
+
['fp:locale-imports', `import ${id}En from './${id}/locales/en'`],
|
|
61
|
+
['fp:locale-imports', `import ${id}Tr from './${id}/locales/tr'`],
|
|
62
|
+
['fp:locale-en', `${id}: ${id}En,`],
|
|
63
|
+
['fp:locale-tr', `${id}: ${id}Tr,`],
|
|
64
|
+
]),
|
|
65
|
+
insert('src/app.config.ts', [['fp:modules', `${id}: true,`]]),
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
function copy(dir) {
|
|
69
|
+
for (const name of readdirSync(dir)) {
|
|
70
|
+
const source = path.join(dir, name)
|
|
71
|
+
if (statSync(source).isDirectory()) {
|
|
72
|
+
copy(source)
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
const relative = fill(path.relative(templates, source)).replace(/\.tpl$/, '')
|
|
76
|
+
const target = path.join(moduleDir, relative)
|
|
77
|
+
mkdirSync(path.dirname(target), { recursive: true })
|
|
78
|
+
writeFileSync(target, fill(readFileSync(source, 'utf8')))
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
copy(templates)
|
|
83
|
+
for (const write of writes) write()
|
|
84
|
+
|
|
85
|
+
console.log(`Created src/modules/${id} and registered it (sidebar, routes, permissions, translations).
|
|
86
|
+
|
|
87
|
+
Next:
|
|
88
|
+
1. Replace the sample fields in types.ts, the pages and both locale files.
|
|
89
|
+
2. Open /app/${id} in npm run dev.
|
|
90
|
+
3. Run npm run check.`)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { __id__Statuses, type __Name__Record } from '../types'
|
|
2
|
+
|
|
3
|
+
/** 24 deterministic sample records. */
|
|
4
|
+
export function create__Name__Seed(): __Name__Record[] {
|
|
5
|
+
const now = Date.now()
|
|
6
|
+
return Array.from({ length: 24 }, (_, i) => {
|
|
7
|
+
const at = new Date(now - i * 86_400_000).toISOString()
|
|
8
|
+
return {
|
|
9
|
+
id: `__id___${String(i + 1).padStart(4, '0')}`,
|
|
10
|
+
name: `__Name__ ${i + 1}`,
|
|
11
|
+
status: __id__Statuses[i % __id__Statuses.length],
|
|
12
|
+
notes: '',
|
|
13
|
+
createdAt: at,
|
|
14
|
+
updatedAt: at,
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createMockCollection, delay, notFound, paginate } from '@/core/api/mock'
|
|
2
|
+
import { createRestService, type CrudService } from '@/core/api/rest'
|
|
3
|
+
import { authMode } from '@/core/config/runtime'
|
|
4
|
+
import { __id__FormSchema, type __Name__Filters, type __Name__Input, type __Name__Record } from '../types'
|
|
5
|
+
import { create__Name__Seed } from './__id__.mock'
|
|
6
|
+
|
|
7
|
+
/** The only contract screens depend on. Add module-specific methods here. */
|
|
8
|
+
export type __Name__Service = CrudService<__Name__Record, __Name__Input, __Name__Filters>
|
|
9
|
+
|
|
10
|
+
function createMock__Name__Service(): __Name__Service {
|
|
11
|
+
const db = createMockCollection('__id__', create__Name__Seed)
|
|
12
|
+
const find = (id: string) => db.find(id) ?? notFound('__Name__', id)
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
async list(params) {
|
|
16
|
+
await delay()
|
|
17
|
+
const { status } = params.filters ?? {}
|
|
18
|
+
return paginate(db.all().filter((r) => !status || r.status === status), params, ['name'])
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
async get(id) {
|
|
22
|
+
await delay(150, 350)
|
|
23
|
+
return find(id)
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
async create(input) {
|
|
27
|
+
await delay()
|
|
28
|
+
const now = new Date().toISOString()
|
|
29
|
+
const record: __Name__Record = { ...__id__FormSchema.parse(input), id: crypto.randomUUID(), createdAt: now, updatedAt: now }
|
|
30
|
+
db.save([record, ...db.all()])
|
|
31
|
+
return record
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async update(id, input) {
|
|
35
|
+
await delay()
|
|
36
|
+
const updated: __Name__Record = { ...find(id), ...__id__FormSchema.parse(input), updatedAt: new Date().toISOString() }
|
|
37
|
+
db.save(db.all().map((r) => (r.id === id ? updated : r)))
|
|
38
|
+
return updated
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
async remove(id) {
|
|
42
|
+
await delay()
|
|
43
|
+
find(id)
|
|
44
|
+
db.save(db.all().filter((r) => r.id !== id))
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Demo data until auth points at a real backend; then the REST contract in BACKEND.md applies. */
|
|
50
|
+
export const __id__Service: __Name__Service =
|
|
51
|
+
authMode === 'mock' ? createMock__Name__Service() : createRestService('/__id__')
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
2
|
+
|
|
3
|
+
import type { ListParams } from '@/core/api/types'
|
|
4
|
+
import { __id__Service } from './api/__id__.service'
|
|
5
|
+
import type { __Name__Filters, __Name__Input } from './types'
|
|
6
|
+
|
|
7
|
+
/** Query keys: invalidate `__id__Keys.all` after any write. */
|
|
8
|
+
export const __id__Keys = {
|
|
9
|
+
all: ['__id__'] as const,
|
|
10
|
+
list: (params: ListParams<__Name__Filters>) => [...__id__Keys.all, 'list', params] as const,
|
|
11
|
+
detail: (id: string) => [...__id__Keys.all, 'detail', id] as const,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function use__Name__List(params: ListParams<__Name__Filters>) {
|
|
15
|
+
return useQuery({
|
|
16
|
+
queryKey: __id__Keys.list(params),
|
|
17
|
+
queryFn: () => __id__Service.list(params),
|
|
18
|
+
placeholderData: keepPreviousData,
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function use__Name__Record(id: string | undefined) {
|
|
23
|
+
return useQuery({
|
|
24
|
+
queryKey: __id__Keys.detail(id ?? ''),
|
|
25
|
+
queryFn: () => __id__Service.get(id!),
|
|
26
|
+
enabled: Boolean(id),
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Every row matching the current search and filters, for CSV export. */
|
|
31
|
+
export function useExport__Name__(params: ListParams<__Name__Filters>) {
|
|
32
|
+
return async () => (await __id__Service.list({ ...params, page: 1, pageSize: 10_000 })).items
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function useCreate__Name__() {
|
|
36
|
+
const qc = useQueryClient()
|
|
37
|
+
return useMutation({
|
|
38
|
+
mutationFn: (input: __Name__Input) => __id__Service.create(input),
|
|
39
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: __id__Keys.all }),
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function useUpdate__Name__(id: string) {
|
|
44
|
+
const qc = useQueryClient()
|
|
45
|
+
return useMutation({
|
|
46
|
+
mutationFn: (input: __Name__Input) => __id__Service.update(id, input),
|
|
47
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: __id__Keys.all }),
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function useDelete__Name__() {
|
|
52
|
+
const qc = useQueryClient()
|
|
53
|
+
return useMutation({
|
|
54
|
+
mutationFn: (id: string) => __id__Service.remove(id),
|
|
55
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: __id__Keys.all }),
|
|
56
|
+
})
|
|
57
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Blocks, Plus } from 'lucide-react'
|
|
2
|
+
|
|
3
|
+
import { lazyPage } from '@/core/modules/lazy-page'
|
|
4
|
+
import type { AppModule } from '@/core/modules/types'
|
|
5
|
+
|
|
6
|
+
export const __id__Module: AppModule = {
|
|
7
|
+
id: '__id__',
|
|
8
|
+
nav: [
|
|
9
|
+
{
|
|
10
|
+
label: '__id__:nav',
|
|
11
|
+
path: '/app/__id__',
|
|
12
|
+
icon: Blocks,
|
|
13
|
+
group: '__group__',
|
|
14
|
+
permission: '__id__.view',
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
quickActions: [
|
|
18
|
+
{
|
|
19
|
+
label: '__id__:quickActions.new',
|
|
20
|
+
description: '__id__:quickActions.newDescription',
|
|
21
|
+
path: '/app/__id__/new',
|
|
22
|
+
icon: Plus,
|
|
23
|
+
permission: '__id__.create',
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
routes: [
|
|
27
|
+
{
|
|
28
|
+
path: '__id__',
|
|
29
|
+
handle: { crumb: '__id__:crumbs.list', permission: '__id__.view' },
|
|
30
|
+
children: [
|
|
31
|
+
{ index: true, lazy: lazyPage(() => import('./pages/__id__-list-page')) },
|
|
32
|
+
{ path: 'new', handle: { crumb: '__id__:crumbs.new', permission: '__id__.create' }, lazy: lazyPage(() => import('./pages/__id__-form-page')) },
|
|
33
|
+
{
|
|
34
|
+
path: ':id',
|
|
35
|
+
handle: { crumb: '__id__:crumbs.details' },
|
|
36
|
+
children: [
|
|
37
|
+
{ index: true, lazy: lazyPage(() => import('./pages/__id__-detail-page')) },
|
|
38
|
+
{ path: 'edit', handle: { crumb: '__id__:crumbs.edit', permission: '__id__.edit' }, lazy: lazyPage(() => import('./pages/__id__-form-page')) },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** __Name__ module strings (namespace `__id__`). */
|
|
2
|
+
export default {
|
|
3
|
+
nav: '__labelEn__',
|
|
4
|
+
crumbs: { list: '__labelEn__', new: 'New', details: 'Details', edit: 'Edit' },
|
|
5
|
+
quickActions: { new: 'New record', newDescription: 'Add to __labelEn__' },
|
|
6
|
+
noun: 'records',
|
|
7
|
+
status: { active: 'Active', draft: 'Draft', archived: 'Archived' },
|
|
8
|
+
list: {
|
|
9
|
+
title: '__labelEn__',
|
|
10
|
+
description: 'Manage your records.',
|
|
11
|
+
new: 'New record',
|
|
12
|
+
export: 'Export CSV',
|
|
13
|
+
search: 'Search…',
|
|
14
|
+
filterStatus: 'Status',
|
|
15
|
+
allStatuses: 'All statuses',
|
|
16
|
+
columns: { name: 'Name', status: 'Status', updated: 'Updated' },
|
|
17
|
+
noMatchesTitle: 'No matching records',
|
|
18
|
+
noMatchesDescription: 'Try a different search or clear the filters.',
|
|
19
|
+
emptyTitle: 'No records yet',
|
|
20
|
+
emptyDescription: 'Create your first record to get started.',
|
|
21
|
+
},
|
|
22
|
+
delete: {
|
|
23
|
+
title: 'Delete “{{name}}”?',
|
|
24
|
+
description: 'This cannot be undone.',
|
|
25
|
+
deleting: 'Deleting…',
|
|
26
|
+
deleted: '{{name}} deleted',
|
|
27
|
+
bulkTitle_one: 'Delete {{count}} record?',
|
|
28
|
+
bulkTitle_other: 'Delete {{count}} records?',
|
|
29
|
+
bulkDeleted_one: '{{count}} record deleted',
|
|
30
|
+
bulkDeleted_other: '{{count}} records deleted',
|
|
31
|
+
},
|
|
32
|
+
detail: {
|
|
33
|
+
overview: 'Overview',
|
|
34
|
+
summary: 'Summary',
|
|
35
|
+
noNotes: 'No notes.',
|
|
36
|
+
fields: { status: 'Status', created: 'Created', updated: 'Updated' },
|
|
37
|
+
},
|
|
38
|
+
form: {
|
|
39
|
+
newTitle: 'New record',
|
|
40
|
+
editTitle: 'Edit {{name}}',
|
|
41
|
+
newDescription: 'Add a record.',
|
|
42
|
+
editDescription: 'Update this record.',
|
|
43
|
+
create: 'Create record',
|
|
44
|
+
created: 'Record created',
|
|
45
|
+
saved: 'Changes saved',
|
|
46
|
+
saveFailed: 'Could not save the record',
|
|
47
|
+
back: 'Back to the list',
|
|
48
|
+
sections: { details: 'Details', detailsDescription: 'The main information about this record.' },
|
|
49
|
+
fields: { name: 'Name', status: 'Status', notes: 'Notes', notesHint: 'Up to 500 characters.' },
|
|
50
|
+
},
|
|
51
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Messages } from '@/core/i18n/types'
|
|
2
|
+
import type en from './en'
|
|
3
|
+
|
|
4
|
+
const tr: Messages<typeof en> = {
|
|
5
|
+
nav: '__labelTr__',
|
|
6
|
+
crumbs: { list: '__labelTr__', new: 'Yeni', details: 'Ayrıntılar', edit: 'Düzenle' },
|
|
7
|
+
quickActions: { new: 'Yeni kayıt', newDescription: 'Kayıt ekleyin' },
|
|
8
|
+
noun: 'kayıt',
|
|
9
|
+
status: { active: 'Aktif', draft: 'Taslak', archived: 'Arşivlendi' },
|
|
10
|
+
list: {
|
|
11
|
+
title: '__labelTr__',
|
|
12
|
+
description: 'Kayıtlarınızı yönetin.',
|
|
13
|
+
new: 'Yeni kayıt',
|
|
14
|
+
export: 'CSV olarak dışa aktar',
|
|
15
|
+
search: 'Ara…',
|
|
16
|
+
filterStatus: 'Durum',
|
|
17
|
+
allStatuses: 'Tüm durumlar',
|
|
18
|
+
columns: { name: 'Ad', status: 'Durum', updated: 'Güncellenme' },
|
|
19
|
+
noMatchesTitle: 'Eşleşen kayıt yok',
|
|
20
|
+
noMatchesDescription: 'Farklı bir arama deneyin veya filtreleri temizleyin.',
|
|
21
|
+
emptyTitle: 'Henüz kayıt yok',
|
|
22
|
+
emptyDescription: 'Başlamak için ilk kaydınızı oluşturun.',
|
|
23
|
+
},
|
|
24
|
+
delete: {
|
|
25
|
+
title: '“{{name}}” silinsin mi?',
|
|
26
|
+
description: 'Bu işlem geri alınamaz.',
|
|
27
|
+
deleting: 'Siliniyor…',
|
|
28
|
+
deleted: '{{name}} silindi',
|
|
29
|
+
bulkTitle_one: '{{count}} kayıt silinsin mi?',
|
|
30
|
+
bulkTitle_other: '{{count}} kayıt silinsin mi?',
|
|
31
|
+
bulkDeleted_one: '{{count}} kayıt silindi',
|
|
32
|
+
bulkDeleted_other: '{{count}} kayıt silindi',
|
|
33
|
+
},
|
|
34
|
+
detail: {
|
|
35
|
+
overview: 'Genel bakış',
|
|
36
|
+
summary: 'Özet',
|
|
37
|
+
noNotes: 'Not yok.',
|
|
38
|
+
fields: { status: 'Durum', created: 'Oluşturulma', updated: 'Güncellenme' },
|
|
39
|
+
},
|
|
40
|
+
form: {
|
|
41
|
+
newTitle: 'Yeni kayıt',
|
|
42
|
+
editTitle: '{{name}} kaydını düzenle',
|
|
43
|
+
newDescription: 'Kayıt ekleyin.',
|
|
44
|
+
editDescription: 'Bu kaydı güncelleyin.',
|
|
45
|
+
create: 'Kayıt oluştur',
|
|
46
|
+
created: 'Kayıt oluşturuldu',
|
|
47
|
+
saved: 'Değişiklikler kaydedildi',
|
|
48
|
+
saveFailed: 'Kayıt kaydedilemedi',
|
|
49
|
+
back: 'Listeye dön',
|
|
50
|
+
sections: { details: 'Ayrıntılar', detailsDescription: 'Bu kaydın temel bilgileri.' },
|
|
51
|
+
fields: { name: 'Ad', status: 'Durum', notes: 'Notlar', notesHint: 'En fazla 500 karakter.' },
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default tr
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { useTranslation } from 'react-i18next'
|
|
3
|
+
import { useNavigate, useParams } from 'react-router'
|
|
4
|
+
import { Pencil, Trash2 } from 'lucide-react'
|
|
5
|
+
import { toast } from 'sonner'
|
|
6
|
+
|
|
7
|
+
import { Button } from '@/components/ui/button'
|
|
8
|
+
import { Can } from '@/core/auth/can'
|
|
9
|
+
import { ButtonLink } from '@/core/components/button-link'
|
|
10
|
+
import { useConfirm } from '@/core/components/confirm-dialog'
|
|
11
|
+
import { Page, Panel } from '@/core/components/page'
|
|
12
|
+
import { ErrorState, LoadingState } from '@/core/components/states'
|
|
13
|
+
import { StatusBadge, type StatusTone } from '@/core/components/status-badge'
|
|
14
|
+
import { useFormat } from '@/core/i18n/use-format'
|
|
15
|
+
import { useDelete__Name__, use__Name__Record } from '../hooks'
|
|
16
|
+
import type { __Name__Status } from '../types'
|
|
17
|
+
|
|
18
|
+
const tones: Record<__Name__Status, StatusTone> = { active: 'success', draft: 'neutral', archived: 'warning' }
|
|
19
|
+
|
|
20
|
+
export default function __Name__DetailPage() {
|
|
21
|
+
const { id } = useParams()
|
|
22
|
+
const query = use__Name__Record(id)
|
|
23
|
+
const remove = useDelete__Name__()
|
|
24
|
+
const confirm = useConfirm()
|
|
25
|
+
const navigate = useNavigate()
|
|
26
|
+
const { t } = useTranslation(['__id__', 'common'])
|
|
27
|
+
const fmt = useFormat()
|
|
28
|
+
|
|
29
|
+
if (query.isPending) return <LoadingState />
|
|
30
|
+
if (query.error) {
|
|
31
|
+
return (
|
|
32
|
+
<ErrorState
|
|
33
|
+
error={query.error}
|
|
34
|
+
onRetry={() => query.refetch()}
|
|
35
|
+
notFoundAction={
|
|
36
|
+
<ButtonLink to="/app/__id__" variant="outline">
|
|
37
|
+
{t('form.back')}
|
|
38
|
+
</ButtonLink>
|
|
39
|
+
}
|
|
40
|
+
/>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
const record = query.data
|
|
44
|
+
|
|
45
|
+
async function onDelete() {
|
|
46
|
+
const ok = await confirm({
|
|
47
|
+
title: t('delete.title', { name: record.name }),
|
|
48
|
+
description: t('delete.description'),
|
|
49
|
+
confirmLabel: t('common:actions.delete'),
|
|
50
|
+
destructive: true,
|
|
51
|
+
})
|
|
52
|
+
if (!ok) return
|
|
53
|
+
await remove.mutateAsync(record.id)
|
|
54
|
+
toast.success(t('delete.deleted', { name: record.name }))
|
|
55
|
+
navigate('/app/__id__', { replace: true })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<Page
|
|
60
|
+
title={record.name}
|
|
61
|
+
actions={
|
|
62
|
+
<>
|
|
63
|
+
<Can permission="__id__.delete">
|
|
64
|
+
<Button variant="destructive" onClick={onDelete} disabled={remove.isPending}>
|
|
65
|
+
<Trash2 data-icon="inline-start" />
|
|
66
|
+
{t('common:actions.delete')}
|
|
67
|
+
</Button>
|
|
68
|
+
</Can>
|
|
69
|
+
<Can permission="__id__.edit">
|
|
70
|
+
<ButtonLink to="edit">
|
|
71
|
+
<Pencil data-icon="inline-start" />
|
|
72
|
+
{t('common:actions.edit')}
|
|
73
|
+
</ButtonLink>
|
|
74
|
+
</Can>
|
|
75
|
+
</>
|
|
76
|
+
}
|
|
77
|
+
>
|
|
78
|
+
<div className="grid gap-4 lg:grid-cols-3">
|
|
79
|
+
<Panel label={t('detail.overview')} className="lg:col-span-2" bodyClassName="p-5">
|
|
80
|
+
<p className="text-sm leading-relaxed text-pretty">{record.notes || t('detail.noNotes')}</p>
|
|
81
|
+
</Panel>
|
|
82
|
+
<Panel label={t('detail.summary')}>
|
|
83
|
+
<dl className="divide-y text-sm">
|
|
84
|
+
<Row label={t('detail.fields.status')}>
|
|
85
|
+
<StatusBadge tone={tones[record.status]}>{t(`status.${record.status}`)}</StatusBadge>
|
|
86
|
+
</Row>
|
|
87
|
+
<Row label={t('detail.fields.created')}>{fmt.date(record.createdAt)}</Row>
|
|
88
|
+
<Row label={t('detail.fields.updated')}>{fmt.relative(record.updatedAt)}</Row>
|
|
89
|
+
</dl>
|
|
90
|
+
</Panel>
|
|
91
|
+
</div>
|
|
92
|
+
</Page>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function Row({ label, children }: { label: string; children: ReactNode }) {
|
|
97
|
+
return (
|
|
98
|
+
<div className="flex items-center justify-between gap-4 px-4 py-2.5">
|
|
99
|
+
<dt className="text-muted-foreground">{label}</dt>
|
|
100
|
+
<dd className="text-right font-medium tabular-nums">{children}</dd>
|
|
101
|
+
</div>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Controller, useForm } from 'react-hook-form'
|
|
2
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
3
|
+
import { useTranslation } from 'react-i18next'
|
|
4
|
+
import { useNavigate, useParams } from 'react-router'
|
|
5
|
+
import { toast } from 'sonner'
|
|
6
|
+
|
|
7
|
+
import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
|
|
8
|
+
import { Input } from '@/components/ui/input'
|
|
9
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
10
|
+
import { Textarea } from '@/components/ui/textarea'
|
|
11
|
+
import { ButtonLink } from '@/core/components/button-link'
|
|
12
|
+
import { FormFieldError } from '@/core/components/form-error'
|
|
13
|
+
import { FormLayout, FormSection } from '@/core/components/form-page'
|
|
14
|
+
import { Page } from '@/core/components/page'
|
|
15
|
+
import { ErrorState, LoadingState } from '@/core/components/states'
|
|
16
|
+
import { useCreate__Name__, useUpdate__Name__, use__Name__Record } from '../hooks'
|
|
17
|
+
import { __id__FormSchema, __id__Statuses, type __Name__Input, type __Name__Record } from '../types'
|
|
18
|
+
|
|
19
|
+
const empty: __Name__Input = { name: '', status: 'draft', notes: '' }
|
|
20
|
+
|
|
21
|
+
/** /app/__id__/new and /app/__id__/:id/edit */
|
|
22
|
+
export default function __Name__FormPage() {
|
|
23
|
+
const { id } = useParams()
|
|
24
|
+
const query = use__Name__Record(id)
|
|
25
|
+
const { t } = useTranslation('__id__')
|
|
26
|
+
|
|
27
|
+
if (!id) return <__Name__Form />
|
|
28
|
+
if (query.isPending) return <LoadingState />
|
|
29
|
+
if (query.error) {
|
|
30
|
+
return (
|
|
31
|
+
<ErrorState
|
|
32
|
+
error={query.error}
|
|
33
|
+
onRetry={() => query.refetch()}
|
|
34
|
+
notFoundAction={
|
|
35
|
+
<ButtonLink to="/app/__id__" variant="outline">
|
|
36
|
+
{t('form.back')}
|
|
37
|
+
</ButtonLink>
|
|
38
|
+
}
|
|
39
|
+
/>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
return <__Name__Form key={query.data.id} record={query.data} />
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function __Name__Form({ record }: { record?: __Name__Record }) {
|
|
46
|
+
const navigate = useNavigate()
|
|
47
|
+
const create = useCreate__Name__()
|
|
48
|
+
const update = useUpdate__Name__(record?.id ?? '')
|
|
49
|
+
const { t } = useTranslation(['__id__', 'common'])
|
|
50
|
+
const isEdit = Boolean(record)
|
|
51
|
+
|
|
52
|
+
const form = useForm<__Name__Input>({
|
|
53
|
+
resolver: zodResolver(__id__FormSchema),
|
|
54
|
+
defaultValues: record ? { name: record.name, status: record.status, notes: record.notes } : empty,
|
|
55
|
+
})
|
|
56
|
+
const { register, control, handleSubmit, formState } = form
|
|
57
|
+
const { errors } = formState
|
|
58
|
+
|
|
59
|
+
const onSubmit = handleSubmit(async (values) => {
|
|
60
|
+
try {
|
|
61
|
+
const saved = isEdit ? await update.mutateAsync(values) : await create.mutateAsync(values)
|
|
62
|
+
toast.success(isEdit ? t('form.saved') : t('form.created'), { description: saved.name })
|
|
63
|
+
navigate(`/app/__id__/${saved.id}`)
|
|
64
|
+
} catch (e) {
|
|
65
|
+
toast.error(t('form.saveFailed'), { description: e instanceof Error ? e.message : undefined })
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const statusItems = __id__Statuses.map((s) => ({ value: s, label: t(`status.${s}`) }))
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<Page
|
|
73
|
+
title={isEdit ? t('form.editTitle', { name: record!.name }) : t('form.newTitle')}
|
|
74
|
+
description={isEdit ? t('form.editDescription') : t('form.newDescription')}
|
|
75
|
+
>
|
|
76
|
+
<FormLayout
|
|
77
|
+
onSubmit={onSubmit}
|
|
78
|
+
submitLabel={isEdit ? t('common:actions.saveChanges') : t('form.create')}
|
|
79
|
+
isSubmitting={formState.isSubmitting}
|
|
80
|
+
isDirty={!isEdit || formState.isDirty}
|
|
81
|
+
onCancel={() => navigate(-1)}
|
|
82
|
+
>
|
|
83
|
+
<FormSection title={t('form.sections.details')} description={t('form.sections.detailsDescription')}>
|
|
84
|
+
<Field data-invalid={!!errors.name}>
|
|
85
|
+
<FieldLabel htmlFor="name">{t('form.fields.name')}</FieldLabel>
|
|
86
|
+
<Input id="name" autoFocus={!isEdit} aria-invalid={!!errors.name} {...register('name')} />
|
|
87
|
+
<FormFieldError error={errors.name} />
|
|
88
|
+
</Field>
|
|
89
|
+
<Field data-invalid={!!errors.status}>
|
|
90
|
+
<FieldLabel htmlFor="status">{t('form.fields.status')}</FieldLabel>
|
|
91
|
+
<Controller
|
|
92
|
+
control={control}
|
|
93
|
+
name="status"
|
|
94
|
+
render={({ field }) => (
|
|
95
|
+
<Select items={statusItems} value={field.value} onValueChange={(v) => v && field.onChange(v)}>
|
|
96
|
+
<SelectTrigger id="status" className="w-full" onBlur={field.onBlur}>
|
|
97
|
+
<SelectValue />
|
|
98
|
+
</SelectTrigger>
|
|
99
|
+
<SelectContent>
|
|
100
|
+
{statusItems.map((s) => (
|
|
101
|
+
<SelectItem key={s.value} value={s.value}>
|
|
102
|
+
{s.label}
|
|
103
|
+
</SelectItem>
|
|
104
|
+
))}
|
|
105
|
+
</SelectContent>
|
|
106
|
+
</Select>
|
|
107
|
+
)}
|
|
108
|
+
/>
|
|
109
|
+
<FormFieldError error={errors.status} />
|
|
110
|
+
</Field>
|
|
111
|
+
<Field data-invalid={!!errors.notes}>
|
|
112
|
+
<FieldLabel htmlFor="notes">{t('form.fields.notes')}</FieldLabel>
|
|
113
|
+
<Textarea id="notes" rows={4} aria-invalid={!!errors.notes} {...register('notes')} />
|
|
114
|
+
<FieldDescription>{t('form.fields.notesHint')}</FieldDescription>
|
|
115
|
+
<FormFieldError error={errors.notes} />
|
|
116
|
+
</Field>
|
|
117
|
+
</FormSection>
|
|
118
|
+
</FormLayout>
|
|
119
|
+
</Page>
|
|
120
|
+
)
|
|
121
|
+
}
|