create-mantiq 0.7.0 → 0.7.2

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 (220) hide show
  1. package/package.json +2 -1
  2. package/skeleton/.env.example +64 -0
  3. package/skeleton/README.md +46 -0
  4. package/skeleton/app/Console/Commands/.gitkeep +0 -0
  5. package/skeleton/app/Enums/UserStatus.ts +7 -0
  6. package/skeleton/app/Http/Controllers/HomeController.ts +78 -0
  7. package/skeleton/app/Http/Middleware/.gitkeep +0 -0
  8. package/skeleton/app/Models/User.ts +7 -0
  9. package/skeleton/app/Providers/AppServiceProvider.ts +25 -0
  10. package/skeleton/app/Providers/DatabaseServiceProvider.ts +17 -0
  11. package/skeleton/bootstrap/.gitkeep +0 -0
  12. package/skeleton/config/ai.ts +51 -0
  13. package/skeleton/config/app.ts +108 -0
  14. package/skeleton/config/auth.ts +51 -0
  15. package/skeleton/config/broadcasting.ts +93 -0
  16. package/skeleton/config/cache.ts +61 -0
  17. package/skeleton/config/cors.ts +77 -0
  18. package/skeleton/config/database.ts +120 -0
  19. package/skeleton/config/filesystem.ts +58 -0
  20. package/skeleton/config/hashing.ts +47 -0
  21. package/skeleton/config/heartbeat.ts +112 -0
  22. package/skeleton/config/logging.ts +58 -0
  23. package/skeleton/config/mail.ts +93 -0
  24. package/skeleton/config/notify.ts +141 -0
  25. package/skeleton/config/queue.ts +59 -0
  26. package/skeleton/config/search.ts +96 -0
  27. package/skeleton/config/services.ts +110 -0
  28. package/skeleton/config/session.ts +84 -0
  29. package/skeleton/config/vite.ts +33 -0
  30. package/skeleton/database/factories/.gitkeep +0 -0
  31. package/skeleton/database/migrations/001_create_users_table.ts +19 -0
  32. package/skeleton/database/migrations/002_create_personal_access_tokens_table.ts +22 -0
  33. package/skeleton/database/seeders/DatabaseSeeder.ts +7 -0
  34. package/skeleton/index.ts +20 -0
  35. package/skeleton/mantiq.ts +8 -0
  36. package/skeleton/package.json +34 -0
  37. package/skeleton/public/.gitkeep +0 -0
  38. package/skeleton/routes/api.ts +8 -0
  39. package/skeleton/routes/channels.ts +23 -0
  40. package/skeleton/routes/console.ts +24 -0
  41. package/skeleton/routes/web.ts +6 -0
  42. package/skeleton/storage/cache/.gitkeep +0 -0
  43. package/skeleton/storage/framework/.gitkeep +0 -0
  44. package/skeleton/tests/feature/api.test.ts +14 -0
  45. package/skeleton/tests/feature/home.test.ts +17 -0
  46. package/skeleton/tests/unit/example.test.ts +11 -0
  47. package/skeleton/tsconfig.json +27 -0
  48. package/src/index.ts +289 -25
  49. package/src/templates.ts +141 -945
  50. package/src/terminal.ts +64 -0
  51. package/stubs/api-only/routes/api.ts.stub +24 -0
  52. package/stubs/api-only/tests/feature/token-auth.test.ts.stub +69 -0
  53. package/stubs/auth/api/app/Http/Controllers/ApiAuthController.ts.stub +57 -0
  54. package/stubs/auth/api/routes/api.ts.stub +24 -0
  55. package/stubs/auth/api/tests/feature/token-auth.test.ts.stub +69 -0
  56. package/stubs/auth/shared/app/Http/Requests/LoginRequest.ts.stub +10 -0
  57. package/stubs/auth/shared/app/Http/Requests/RegisterRequest.ts.stub +11 -0
  58. package/stubs/auth/web/app/Http/Controllers/AuthController.ts.stub +43 -0
  59. package/stubs/auth/web/app/Http/Controllers/PageController.ts.stub +66 -0
  60. package/stubs/auth/web/routes/web.ts.stub +25 -0
  61. package/stubs/auth/web/svelte/src/App.svelte.stub +77 -0
  62. package/stubs/auth/web/svelte/src/pages.ts.stub +17 -0
  63. package/stubs/auth/web/tests/feature/auth.test.ts.stub +69 -0
  64. package/stubs/auth/web/vue/src/App.vue.stub +74 -0
  65. package/stubs/auth/web/vue/src/pages.ts.stub +17 -0
  66. package/stubs/manifest.json +630 -2
  67. package/stubs/noauth/app/Http/Controllers/PageController.ts.stub +41 -0
  68. package/stubs/noauth/app/Models/User.ts.stub +5 -0
  69. package/stubs/noauth/database/migrations/001_create_users_table.ts.stub +17 -0
  70. package/stubs/noauth/routes/api.ts.stub +16 -0
  71. package/stubs/noauth/routes/web.ts.stub +15 -0
  72. package/stubs/noauth/svelte/src/App.svelte.stub +68 -0
  73. package/stubs/noauth/svelte/src/pages.ts.stub +7 -0
  74. package/stubs/noauth/vue/src/App.vue.stub +62 -0
  75. package/stubs/noauth/vue/src/pages.ts.stub +7 -0
  76. package/stubs/react/src/App.tsx.stub +4 -2
  77. package/stubs/react/src/components/layout/search-dialog.tsx.stub +2 -2
  78. package/stubs/react/src/components/layout/sidebar-data.ts.stub +2 -2
  79. package/stubs/react/src/lib/api.ts.stub +30 -6
  80. package/stubs/react/src/pages/Login.tsx.stub +3 -3
  81. package/stubs/react/src/pages/users/dialogs.tsx.stub +7 -26
  82. package/stubs/react/vite.config.ts.stub +26 -3
  83. package/stubs/shared/app/Http/Controllers/ApiAuthController.ts.stub +57 -0
  84. package/stubs/shared/app/Http/Controllers/AuthController.ts.stub +14 -38
  85. package/stubs/shared/app/Http/Controllers/PageController.ts.stub +3 -3
  86. package/stubs/shared/app/Http/Controllers/UserController.ts.stub +61 -0
  87. package/stubs/shared/app/Http/Requests/LoginRequest.ts.stub +10 -0
  88. package/stubs/shared/app/Http/Requests/RegisterRequest.ts.stub +11 -0
  89. package/stubs/shared/app/Http/Requests/StoreUserRequest.ts.stub +11 -0
  90. package/stubs/shared/app/Http/Requests/UpdateUserRequest.ts.stub +11 -0
  91. package/stubs/shared/config/app.ts.stub +36 -0
  92. package/stubs/shared/config/vite.ts.stub +8 -0
  93. package/stubs/shared/database/factories/UserFactory.ts.stub +4 -6
  94. package/stubs/shared/routes/api.ts.stub +12 -102
  95. package/stubs/shared/routes/web.ts.stub +5 -3
  96. package/stubs/shared/tests/feature/auth.test.ts.stub +69 -0
  97. package/stubs/shared/tests/feature/users.test.ts.stub +90 -0
  98. package/stubs/svelte/src/App.svelte.stub +1 -1
  99. package/stubs/svelte/src/lib/api.ts.stub +30 -6
  100. package/stubs/svelte/src/main.ts.stub +3 -1
  101. package/stubs/svelte/src/pages/Login.svelte.stub +3 -3
  102. package/stubs/svelte/vite.config.ts.stub +20 -1
  103. package/stubs/tailwind-only/react/src/components/layout/app-sidebar.tsx.stub +68 -0
  104. package/stubs/tailwind-only/react/src/components/layout/authenticated-layout.tsx.stub +57 -0
  105. package/stubs/tailwind-only/react/src/components/layout/header.tsx.stub +52 -0
  106. package/stubs/tailwind-only/react/src/components/layout/main.tsx.stub +21 -0
  107. package/stubs/tailwind-only/react/src/components/layout/nav-group.tsx.stub +185 -0
  108. package/stubs/tailwind-only/react/src/components/layout/nav-user.tsx.stub +106 -0
  109. package/stubs/tailwind-only/react/src/components/layout/sidebar-data.ts.stub +58 -0
  110. package/stubs/tailwind-only/react/src/components/layout/theme-toggle.tsx.stub +36 -0
  111. package/stubs/tailwind-only/react/src/components/layout/top-nav.tsx.stub +72 -0
  112. package/stubs/tailwind-only/react/src/lib/utils.ts.stub +6 -0
  113. package/stubs/tailwind-only/react/src/pages/Dashboard.tsx.stub +205 -0
  114. package/stubs/tailwind-only/react/src/pages/Login.tsx.stub +122 -0
  115. package/stubs/tailwind-only/react/src/pages/Register.tsx.stub +137 -0
  116. package/stubs/tailwind-only/react/src/pages/Users.tsx.stub +426 -0
  117. package/stubs/tailwind-only/react/src/pages/account/layout.tsx.stub +64 -0
  118. package/stubs/tailwind-only/react/src/pages/account/preferences.tsx.stub +80 -0
  119. package/stubs/tailwind-only/react/src/pages/account/profile.tsx.stub +67 -0
  120. package/stubs/tailwind-only/react/src/pages/account/security.tsx.stub +91 -0
  121. package/stubs/tailwind-only/react/src/style.css.stub +14 -0
  122. package/stubs/tailwind-only/svelte/src/lib/components/layout/app-sidebar.svelte.stub +104 -0
  123. package/stubs/tailwind-only/svelte/src/lib/components/layout/authenticated-layout.svelte.stub +51 -0
  124. package/stubs/tailwind-only/svelte/src/lib/components/layout/header.svelte.stub +66 -0
  125. package/stubs/tailwind-only/svelte/src/lib/components/layout/main.svelte.stub +26 -0
  126. package/stubs/tailwind-only/svelte/src/lib/components/layout/nav-group.svelte.stub +131 -0
  127. package/stubs/tailwind-only/svelte/src/lib/components/layout/nav-user.svelte.stub +104 -0
  128. package/stubs/tailwind-only/svelte/src/lib/components/layout/sidebar-data.ts.stub +57 -0
  129. package/stubs/tailwind-only/svelte/src/lib/components/layout/theme-toggle.svelte.stub +28 -0
  130. package/stubs/tailwind-only/svelte/src/lib/components/layout/top-nav.svelte.stub +99 -0
  131. package/stubs/tailwind-only/svelte/src/lib/utils.ts.stub +6 -0
  132. package/stubs/tailwind-only/svelte/src/pages/Dashboard.svelte.stub +192 -0
  133. package/stubs/tailwind-only/svelte/src/pages/Login.svelte.stub +120 -0
  134. package/stubs/tailwind-only/svelte/src/pages/Register.svelte.stub +134 -0
  135. package/stubs/tailwind-only/svelte/src/pages/Users.svelte.stub +293 -0
  136. package/stubs/tailwind-only/svelte/src/pages/account/Layout.svelte.stub +61 -0
  137. package/stubs/tailwind-only/svelte/src/pages/account/Preferences.svelte.stub +81 -0
  138. package/stubs/tailwind-only/svelte/src/pages/account/Profile.svelte.stub +76 -0
  139. package/stubs/tailwind-only/svelte/src/pages/account/Security.svelte.stub +140 -0
  140. package/stubs/tailwind-only/svelte/src/style.css.stub +127 -0
  141. package/stubs/tailwind-only/vue/src/components/layout/AppSidebar.vue.stub +60 -0
  142. package/stubs/tailwind-only/vue/src/components/layout/AuthenticatedLayout.vue.stub +73 -0
  143. package/stubs/tailwind-only/vue/src/components/layout/Header.vue.stub +54 -0
  144. package/stubs/tailwind-only/vue/src/components/layout/Main.vue.stub +22 -0
  145. package/stubs/tailwind-only/vue/src/components/layout/NavGroup.vue.stub +107 -0
  146. package/stubs/tailwind-only/vue/src/components/layout/NavUser.vue.stub +104 -0
  147. package/stubs/tailwind-only/vue/src/components/layout/ThemeToggle.vue.stub +28 -0
  148. package/stubs/tailwind-only/vue/src/components/layout/TopNav.vue.stub +86 -0
  149. package/stubs/tailwind-only/vue/src/components/layout/sidebar-data.ts.stub +57 -0
  150. package/stubs/tailwind-only/vue/src/lib/utils.ts.stub +7 -0
  151. package/stubs/tailwind-only/vue/src/pages/Dashboard.vue.stub +195 -0
  152. package/stubs/tailwind-only/vue/src/pages/Login.vue.stub +121 -0
  153. package/stubs/tailwind-only/vue/src/pages/Register.vue.stub +137 -0
  154. package/stubs/tailwind-only/vue/src/pages/Users.vue.stub +401 -0
  155. package/stubs/tailwind-only/vue/src/pages/account/Layout.vue.stub +56 -0
  156. package/stubs/tailwind-only/vue/src/pages/account/Preferences.vue.stub +81 -0
  157. package/stubs/tailwind-only/vue/src/pages/account/Profile.vue.stub +69 -0
  158. package/stubs/tailwind-only/vue/src/pages/account/Security.vue.stub +85 -0
  159. package/stubs/tailwind-only/vue/src/style.css.stub +26 -0
  160. package/stubs/themes/corporate/react/src/components/layout/app-sidebar.tsx.stub +74 -0
  161. package/stubs/themes/corporate/react/src/components/layout/authenticated-layout.tsx.stub +42 -0
  162. package/stubs/themes/corporate/react/src/pages/Dashboard.tsx.stub +310 -0
  163. package/stubs/themes/corporate/react/src/pages/Login.tsx.stub +122 -0
  164. package/stubs/themes/corporate/react/src/pages/Register.tsx.stub +144 -0
  165. package/stubs/themes/corporate/react/src/style.css.stub +135 -0
  166. package/stubs/themes/corporate/svelte/src/pages/Dashboard.svelte.stub +271 -0
  167. package/stubs/themes/corporate/svelte/src/pages/Login.svelte.stub +117 -0
  168. package/stubs/themes/corporate/svelte/src/pages/Register.svelte.stub +138 -0
  169. package/stubs/themes/corporate/svelte/src/style.css.stub +134 -0
  170. package/stubs/themes/corporate/vue/src/pages/Dashboard.vue.stub +325 -0
  171. package/stubs/themes/corporate/vue/src/pages/Login.vue.stub +118 -0
  172. package/stubs/themes/corporate/vue/src/pages/Register.vue.stub +139 -0
  173. package/stubs/themes/corporate/vue/src/style.css.stub +134 -0
  174. package/stubs/themes/minimal/react/src/components/layout/app-sidebar.tsx.stub +68 -0
  175. package/stubs/themes/minimal/react/src/components/layout/authenticated-layout.tsx.stub +42 -0
  176. package/stubs/themes/minimal/react/src/pages/Dashboard.tsx.stub +166 -0
  177. package/stubs/themes/minimal/react/src/pages/Login.tsx.stub +95 -0
  178. package/stubs/themes/minimal/react/src/pages/Register.tsx.stub +73 -0
  179. package/stubs/themes/minimal/react/src/style.css.stub +142 -0
  180. package/stubs/themes/minimal/svelte/src/pages/Dashboard.svelte.stub +149 -0
  181. package/stubs/themes/minimal/svelte/src/pages/Login.svelte.stub +90 -0
  182. package/stubs/themes/minimal/svelte/src/pages/Register.svelte.stub +70 -0
  183. package/stubs/themes/minimal/svelte/src/style.css.stub +142 -0
  184. package/stubs/themes/minimal/vue/src/pages/Dashboard.vue.stub +163 -0
  185. package/stubs/themes/minimal/vue/src/pages/Login.vue.stub +91 -0
  186. package/stubs/themes/minimal/vue/src/pages/Register.vue.stub +73 -0
  187. package/stubs/themes/minimal/vue/src/style.css.stub +142 -0
  188. package/stubs/themes/starter/react/src/components/layout/app-sidebar.tsx.stub +74 -0
  189. package/stubs/themes/starter/react/src/components/layout/authenticated-layout.tsx.stub +42 -0
  190. package/stubs/themes/starter/react/src/pages/Dashboard.tsx.stub +236 -0
  191. package/stubs/themes/starter/react/src/pages/Login.tsx.stub +131 -0
  192. package/stubs/themes/starter/react/src/pages/Register.tsx.stub +145 -0
  193. package/stubs/themes/starter/react/src/style.css.stub +141 -0
  194. package/stubs/themes/starter/svelte/src/pages/Dashboard.svelte.stub +212 -0
  195. package/stubs/themes/starter/svelte/src/pages/Login.svelte.stub +126 -0
  196. package/stubs/themes/starter/svelte/src/pages/Register.svelte.stub +139 -0
  197. package/stubs/themes/starter/svelte/src/style.css.stub +141 -0
  198. package/stubs/themes/starter/vue/src/pages/Dashboard.vue.stub +228 -0
  199. package/stubs/themes/starter/vue/src/pages/Login.vue.stub +127 -0
  200. package/stubs/themes/starter/vue/src/pages/Register.vue.stub +140 -0
  201. package/stubs/themes/starter/vue/src/style.css.stub +141 -0
  202. package/stubs/themes/workspace/react/src/components/layout/app-sidebar.tsx.stub +97 -0
  203. package/stubs/themes/workspace/react/src/components/layout/authenticated-layout.tsx.stub +42 -0
  204. package/stubs/themes/workspace/react/src/pages/Dashboard.tsx.stub +304 -0
  205. package/stubs/themes/workspace/react/src/pages/Login.tsx.stub +131 -0
  206. package/stubs/themes/workspace/react/src/pages/Register.tsx.stub +82 -0
  207. package/stubs/themes/workspace/react/src/style.css.stub +138 -0
  208. package/stubs/themes/workspace/svelte/src/pages/Dashboard.svelte.stub +215 -0
  209. package/stubs/themes/workspace/svelte/src/pages/Login.svelte.stub +124 -0
  210. package/stubs/themes/workspace/svelte/src/pages/Register.svelte.stub +76 -0
  211. package/stubs/themes/workspace/svelte/src/style.css.stub +134 -0
  212. package/stubs/themes/workspace/vue/src/pages/Dashboard.vue.stub +220 -0
  213. package/stubs/themes/workspace/vue/src/pages/Login.vue.stub +128 -0
  214. package/stubs/themes/workspace/vue/src/pages/Register.vue.stub +80 -0
  215. package/stubs/themes/workspace/vue/src/style.css.stub +134 -0
  216. package/stubs/vue/src/App.vue.stub +2 -1
  217. package/stubs/vue/src/lib/api.ts.stub +30 -6
  218. package/stubs/vue/src/main.ts.stub +3 -1
  219. package/stubs/vue/src/pages/Login.vue.stub +3 -3
  220. package/stubs/vue/vite.config.ts.stub +20 -1
@@ -0,0 +1,426 @@
1
+ import { useState, useEffect, useCallback, useMemo } from 'react'
2
+ import { api, post, put, del } from '../lib/api.ts'
3
+ import { AuthenticatedLayout } from '@/components/layout/authenticated-layout'
4
+ import { Header } from '@/components/layout/header'
5
+ import { Main } from '@/components/layout/main'
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Types
9
+ // ---------------------------------------------------------------------------
10
+
11
+ interface UserType {
12
+ id: number
13
+ name: string
14
+ email: string
15
+ status: string
16
+ created_at: string
17
+ }
18
+
19
+ interface UsersProps {
20
+ appName?: string
21
+ currentUser?: { id: number; name: string; email: string } | null
22
+ users?: UserType[]
23
+ navigate: (href: string) => void
24
+ [key: string]: any
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Mock data
29
+ // ---------------------------------------------------------------------------
30
+
31
+ const mockUsers: UserType[] = [
32
+ { id: 1, name: 'Olivia Martin', email: 'olivia@example.com', status: 'active', created_at: '2024-01-15' },
33
+ { id: 2, name: 'Jackson Lee', email: 'jackson@example.com', status: 'active', created_at: '2024-02-20' },
34
+ { id: 3, name: 'Isabella Nguyen', email: 'isabella@example.com', status: 'active', created_at: '2024-02-28' },
35
+ { id: 4, name: 'William Kim', email: 'william@example.com', status: 'active', created_at: '2024-03-05' },
36
+ { id: 5, name: 'Sofia Davis', email: 'sofia@example.com', status: 'inactive', created_at: '2024-03-12' },
37
+ { id: 6, name: 'Liam Johnson', email: 'liam@example.com', status: 'active', created_at: '2024-03-20' },
38
+ { id: 7, name: 'Emma Wilson', email: 'emma@example.com', status: 'active', created_at: '2024-04-01' },
39
+ { id: 8, name: 'Noah Brown', email: 'noah@example.com', status: 'inactive', created_at: '2024-04-10' },
40
+ { id: 9, name: 'Ava Garcia', email: 'ava@example.com', status: 'active', created_at: '2024-04-18' },
41
+ { id: 10, name: 'Ethan Martinez', email: 'ethan@example.com', status: 'active', created_at: '2024-05-02' },
42
+ ]
43
+
44
+ function getInitials(name: string): string {
45
+ return name.split(' ').map((n) => n[0]).join('').toUpperCase().slice(0, 2)
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Component
50
+ // ---------------------------------------------------------------------------
51
+
52
+ export default function UsersPage({
53
+ appName = 'MantiqJS',
54
+ currentUser,
55
+ users: initialUsers,
56
+ navigate,
57
+ }: UsersProps) {
58
+ const [users, setUsers] = useState<UserType[]>(() => {
59
+ if (initialUsers && initialUsers.length > 0) {
60
+ return initialUsers.map((u) => ({
61
+ ...u,
62
+ status: (u as any).status ?? 'active',
63
+ created_at: u.created_at ?? '',
64
+ }))
65
+ }
66
+ return mockUsers
67
+ })
68
+ const [loading, setLoading] = useState(false)
69
+ const [search, setSearch] = useState('')
70
+ const [debouncedSearch, setDebouncedSearch] = useState('')
71
+ const [page, setPage] = useState(1)
72
+ const [perPage, setPerPage] = useState(10)
73
+ const [total, setTotal] = useState(0)
74
+ const [filteredTotal, setFilteredTotal] = useState(0)
75
+ const [sortKey, setSortKey] = useState<string>('created_at')
76
+ const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
77
+
78
+ // CRUD modal state
79
+ const [modalType, setModalType] = useState<'add' | 'edit' | 'delete' | null>(null)
80
+ const [selectedUser, setSelectedUser] = useState<UserType | null>(null)
81
+
82
+ // Debounce search
83
+ useEffect(() => {
84
+ const timer = setTimeout(() => { setDebouncedSearch(search); setPage(1) }, 300)
85
+ return () => clearTimeout(timer)
86
+ }, [search])
87
+
88
+ // Fetch users
89
+ const fetchUsers = useCallback(async () => {
90
+ setLoading(true)
91
+ const params = new URLSearchParams({
92
+ page: String(page),
93
+ per_page: String(perPage),
94
+ sort: sortKey,
95
+ dir: sortDir,
96
+ })
97
+ if (debouncedSearch) params.set('search', debouncedSearch)
98
+
99
+ const { ok, data } = await api(`/api/users?${params}`)
100
+ if (ok && data) {
101
+ setUsers(
102
+ (data.data as any[]).map((u: any) => ({
103
+ id: u.id, name: u.name, email: u.email,
104
+ status: u.status ?? 'active',
105
+ created_at: u.created_at ?? '',
106
+ })),
107
+ )
108
+ if (data.meta) {
109
+ setTotal(data.meta.total)
110
+ setFilteredTotal(data.meta.filtered_total)
111
+ }
112
+ }
113
+ setLoading(false)
114
+ }, [page, perPage, debouncedSearch, sortKey, sortDir])
115
+
116
+ useEffect(() => { fetchUsers() }, [fetchUsers])
117
+
118
+ const totalPages = Math.max(1, Math.ceil(filteredTotal / perPage))
119
+
120
+ function handleSort(key: string) {
121
+ if (sortKey === key) {
122
+ setSortDir(d => d === 'asc' ? 'desc' : 'asc')
123
+ } else {
124
+ setSortKey(key)
125
+ setSortDir('asc')
126
+ }
127
+ setPage(1)
128
+ }
129
+
130
+ function SortIcon({ column }: { column: string }) {
131
+ if (sortKey !== column) return <span className="ml-1 text-gray-300 dark:text-gray-600">&updownarrow;</span>
132
+ return <span className="ml-1">{sortDir === 'asc' ? '\u2191' : '\u2193'}</span>
133
+ }
134
+
135
+ return (
136
+ <AuthenticatedLayout currentUser={currentUser} appName={appName} navigate={navigate} activePath="/users">
137
+ <Header fixed navigate={navigate} />
138
+ <Main>
139
+ <div className="space-y-4">
140
+ {/* Title row */}
141
+ <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
142
+ <div>
143
+ <h2 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-gray-50">User List</h2>
144
+ <p className="text-sm text-gray-500 dark:text-gray-400">
145
+ {total > 0 ? `${total} users total.` : 'Manage your users here.'}
146
+ </p>
147
+ </div>
148
+ <button
149
+ type="button"
150
+ onClick={() => { setSelectedUser(null); setModalType('add') }}
151
+ className="inline-flex items-center gap-1.5 rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-emerald-700"
152
+ >
153
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
154
+ <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 7a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM20 8v6M23 11h-6" />
155
+ </svg>
156
+ Add User
157
+ </button>
158
+ </div>
159
+
160
+ {/* Search */}
161
+ <div className="relative w-full sm:max-w-[250px]">
162
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400">
163
+ <circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
164
+ </svg>
165
+ <input
166
+ placeholder="Filter users..."
167
+ value={search}
168
+ onChange={(e) => setSearch(e.target.value)}
169
+ className="h-8 w-full rounded-md border border-gray-200 bg-white pl-9 pr-3 text-sm outline-none ring-emerald-500 transition focus:border-emerald-500 focus:ring-2 dark:border-gray-700 dark:bg-gray-900"
170
+ />
171
+ </div>
172
+
173
+ {/* Table */}
174
+ <div className="overflow-hidden rounded-md border border-gray-200 dark:border-gray-800">
175
+ <table className="w-full text-sm">
176
+ <thead className="border-b border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-900">
177
+ <tr>
178
+ <th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">
179
+ <button type="button" className="inline-flex items-center hover:text-gray-900 dark:hover:text-gray-50" onClick={() => handleSort('name')}>
180
+ User<SortIcon column="name" />
181
+ </button>
182
+ </th>
183
+ <th className="hidden px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 sm:table-cell">
184
+ <button type="button" className="inline-flex items-center hover:text-gray-900 dark:hover:text-gray-50" onClick={() => handleSort('status')}>
185
+ Status<SortIcon column="status" />
186
+ </button>
187
+ </th>
188
+ <th className="hidden px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 lg:table-cell">
189
+ <button type="button" className="inline-flex items-center hover:text-gray-900 dark:hover:text-gray-50" onClick={() => handleSort('created_at')}>
190
+ Created<SortIcon column="created_at" />
191
+ </button>
192
+ </th>
193
+ <th className="w-10 px-4 py-3" />
194
+ </tr>
195
+ </thead>
196
+ <tbody className="divide-y divide-gray-100 dark:divide-gray-800">
197
+ {loading && Array.from({ length: perPage }).map((_, i) => (
198
+ <tr key={`skel-${i}`}>
199
+ <td className="px-4 py-3"><div className="h-4 w-32 animate-pulse rounded bg-gray-200 dark:bg-gray-800" /></td>
200
+ <td className="hidden px-4 py-3 sm:table-cell"><div className="h-4 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-800" /></td>
201
+ <td className="hidden px-4 py-3 lg:table-cell"><div className="h-4 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-800" /></td>
202
+ <td className="px-4 py-3" />
203
+ </tr>
204
+ ))}
205
+ {!loading && users.map((u) => (
206
+ <tr key={u.id} className="hover:bg-gray-50 dark:hover:bg-gray-900/50">
207
+ <td className="px-4 py-3">
208
+ <div className="flex items-center gap-3">
209
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-100 text-xs font-semibold text-gray-700 dark:bg-gray-800 dark:text-gray-300">
210
+ {getInitials(u.name)}
211
+ </div>
212
+ <div className="min-w-0">
213
+ <div className="truncate font-medium text-gray-900 dark:text-gray-50">{u.name}</div>
214
+ <div className="truncate text-xs text-gray-500 dark:text-gray-400">{u.email}</div>
215
+ </div>
216
+ </div>
217
+ </td>
218
+ <td className="hidden px-4 py-3 sm:table-cell">
219
+ <span className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium capitalize ${
220
+ u.status === 'active'
221
+ ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400'
222
+ : 'border border-gray-200 text-gray-500 dark:border-gray-700 dark:text-gray-400'
223
+ }`}>
224
+ {u.status}
225
+ </span>
226
+ </td>
227
+ <td className="hidden px-4 py-3 text-gray-500 dark:text-gray-400 lg:table-cell">
228
+ {u.created_at ? new Date(u.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '\u2014'}
229
+ </td>
230
+ <td className="px-4 py-3">
231
+ <div className="flex items-center gap-1">
232
+ <button
233
+ type="button"
234
+ onClick={() => { setSelectedUser(u); setModalType('edit') }}
235
+ className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
236
+ title="Edit"
237
+ >
238
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
239
+ <path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
240
+ </svg>
241
+ </button>
242
+ <button
243
+ type="button"
244
+ onClick={() => { setSelectedUser(u); setModalType('delete') }}
245
+ className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950 dark:hover:text-red-400"
246
+ title="Delete"
247
+ >
248
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
249
+ <path d="M3 6h18M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
250
+ </svg>
251
+ </button>
252
+ </div>
253
+ </td>
254
+ </tr>
255
+ ))}
256
+ {!loading && users.length === 0 && (
257
+ <tr>
258
+ <td colSpan={4} className="px-4 py-12 text-center text-sm text-gray-500 dark:text-gray-400">
259
+ {search ? 'No users match the current filters.' : 'No users found.'}
260
+ </td>
261
+ </tr>
262
+ )}
263
+ </tbody>
264
+ </table>
265
+ </div>
266
+
267
+ {/* Pagination */}
268
+ <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
269
+ <p className="text-sm text-gray-500 dark:text-gray-400">{filteredTotal} row(s) total.</p>
270
+ <div className="flex items-center gap-4">
271
+ <div className="flex items-center gap-2">
272
+ <span className="text-sm text-gray-500 dark:text-gray-400">Rows per page</span>
273
+ <select
274
+ value={perPage}
275
+ onChange={(e) => { setPerPage(Number(e.target.value)); setPage(1) }}
276
+ className="h-8 w-16 rounded-md border border-gray-200 bg-white px-2 text-sm dark:border-gray-700 dark:bg-gray-900"
277
+ >
278
+ <option value={10}>10</option>
279
+ <option value={20}>20</option>
280
+ <option value={50}>50</option>
281
+ </select>
282
+ </div>
283
+ <span className="text-sm text-gray-500 dark:text-gray-400">Page {page} of {totalPages}</span>
284
+ <div className="flex items-center gap-1">
285
+ <button type="button" disabled={page === 1} onClick={() => setPage(1)} className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-gray-200 text-gray-500 transition-colors hover:bg-gray-100 disabled:opacity-50 dark:border-gray-700 dark:hover:bg-gray-800">&laquo;</button>
286
+ <button type="button" disabled={page === 1} onClick={() => setPage(p => p - 1)} className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-gray-200 text-gray-500 transition-colors hover:bg-gray-100 disabled:opacity-50 dark:border-gray-700 dark:hover:bg-gray-800">&lsaquo;</button>
287
+ <button type="button" disabled={page === totalPages} onClick={() => setPage(p => p + 1)} className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-gray-200 text-gray-500 transition-colors hover:bg-gray-100 disabled:opacity-50 dark:border-gray-700 dark:hover:bg-gray-800">&rsaquo;</button>
288
+ <button type="button" disabled={page === totalPages} onClick={() => setPage(totalPages)} className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-gray-200 text-gray-500 transition-colors hover:bg-gray-100 disabled:opacity-50 dark:border-gray-700 dark:hover:bg-gray-800">&raquo;</button>
289
+ </div>
290
+ </div>
291
+ </div>
292
+ </div>
293
+
294
+ {/* Modals */}
295
+ {modalType && (
296
+ <ModalBackdrop onClose={() => setModalType(null)}>
297
+ {modalType === 'add' && <AddUserModal onClose={() => setModalType(null)} onSuccess={fetchUsers} />}
298
+ {modalType === 'edit' && selectedUser && <EditUserModal user={selectedUser} onClose={() => setModalType(null)} onSuccess={fetchUsers} />}
299
+ {modalType === 'delete' && selectedUser && <DeleteUserModal user={selectedUser} onClose={() => setModalType(null)} onSuccess={fetchUsers} />}
300
+ </ModalBackdrop>
301
+ )}
302
+ </Main>
303
+ </AuthenticatedLayout>
304
+ )
305
+ }
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // Modal primitives
309
+ // ---------------------------------------------------------------------------
310
+
311
+ function ModalBackdrop({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
312
+ return (
313
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
314
+ <div className="w-full max-w-md rounded-lg border border-gray-200 bg-white p-6 shadow-xl dark:border-gray-700 dark:bg-gray-900" onClick={(e) => e.stopPropagation()}>
315
+ {children}
316
+ </div>
317
+ </div>
318
+ )
319
+ }
320
+
321
+ function AddUserModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
322
+ const [form, setForm] = useState({ name: '', email: '', password: '' })
323
+ const [error, setError] = useState('')
324
+ const [submitting, setSubmitting] = useState(false)
325
+
326
+ async function handleSubmit(e: React.FormEvent) {
327
+ e.preventDefault()
328
+ setSubmitting(true); setError('')
329
+ const { ok, data } = await post('/api/users', form)
330
+ if (ok) { onClose(); onSuccess() }
331
+ else setError(data?.error ?? 'Request failed')
332
+ setSubmitting(false)
333
+ }
334
+
335
+ return (
336
+ <form onSubmit={handleSubmit} className="space-y-4">
337
+ <div>
338
+ <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Add User</h3>
339
+ <p className="text-sm text-gray-500 dark:text-gray-400">Create a new user account.</p>
340
+ </div>
341
+ {error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
342
+ <div className="space-y-2">
343
+ <label className="text-sm font-medium text-gray-900 dark:text-gray-50">Name</label>
344
+ <input value={form.name} onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))} required className="w-full rounded-md border border-gray-200 px-3 py-2 text-sm outline-none ring-emerald-500 focus:ring-2 dark:border-gray-700 dark:bg-gray-800" />
345
+ </div>
346
+ <div className="space-y-2">
347
+ <label className="text-sm font-medium text-gray-900 dark:text-gray-50">Email</label>
348
+ <input type="email" value={form.email} onChange={(e) => setForm(f => ({ ...f, email: e.target.value }))} required className="w-full rounded-md border border-gray-200 px-3 py-2 text-sm outline-none ring-emerald-500 focus:ring-2 dark:border-gray-700 dark:bg-gray-800" />
349
+ </div>
350
+ <div className="space-y-2">
351
+ <label className="text-sm font-medium text-gray-900 dark:text-gray-50">Password</label>
352
+ <input type="password" value={form.password} onChange={(e) => setForm(f => ({ ...f, password: e.target.value }))} required className="w-full rounded-md border border-gray-200 px-3 py-2 text-sm outline-none ring-emerald-500 focus:ring-2 dark:border-gray-700 dark:bg-gray-800" />
353
+ </div>
354
+ <div className="flex justify-end gap-2 pt-2">
355
+ <button type="button" onClick={onClose} className="rounded-md border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800">Cancel</button>
356
+ <button type="submit" disabled={submitting} className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-700 disabled:opacity-50">{submitting ? 'Creating...' : 'Create'}</button>
357
+ </div>
358
+ </form>
359
+ )
360
+ }
361
+
362
+ function EditUserModal({ user, onClose, onSuccess }: { user: UserType; onClose: () => void; onSuccess: () => void }) {
363
+ const [form, setForm] = useState({ name: user.name, email: user.email })
364
+ const [error, setError] = useState('')
365
+ const [submitting, setSubmitting] = useState(false)
366
+
367
+ async function handleSubmit(e: React.FormEvent) {
368
+ e.preventDefault()
369
+ setSubmitting(true); setError('')
370
+ const { ok, data } = await put(`/api/users/${user.id}`, form)
371
+ if (ok) { onClose(); onSuccess() }
372
+ else setError(data?.error ?? 'Request failed')
373
+ setSubmitting(false)
374
+ }
375
+
376
+ return (
377
+ <form onSubmit={handleSubmit} className="space-y-4">
378
+ <div>
379
+ <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Edit User</h3>
380
+ <p className="text-sm text-gray-500 dark:text-gray-400">Update user details for {user.name}.</p>
381
+ </div>
382
+ {error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
383
+ <div className="space-y-2">
384
+ <label className="text-sm font-medium text-gray-900 dark:text-gray-50">Name</label>
385
+ <input value={form.name} onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))} required className="w-full rounded-md border border-gray-200 px-3 py-2 text-sm outline-none ring-emerald-500 focus:ring-2 dark:border-gray-700 dark:bg-gray-800" />
386
+ </div>
387
+ <div className="space-y-2">
388
+ <label className="text-sm font-medium text-gray-900 dark:text-gray-50">Email</label>
389
+ <input type="email" value={form.email} onChange={(e) => setForm(f => ({ ...f, email: e.target.value }))} required className="w-full rounded-md border border-gray-200 px-3 py-2 text-sm outline-none ring-emerald-500 focus:ring-2 dark:border-gray-700 dark:bg-gray-800" />
390
+ </div>
391
+ <div className="flex justify-end gap-2 pt-2">
392
+ <button type="button" onClick={onClose} className="rounded-md border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800">Cancel</button>
393
+ <button type="submit" disabled={submitting} className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-700 disabled:opacity-50">{submitting ? 'Saving...' : 'Save'}</button>
394
+ </div>
395
+ </form>
396
+ )
397
+ }
398
+
399
+ function DeleteUserModal({ user, onClose, onSuccess }: { user: UserType; onClose: () => void; onSuccess: () => void }) {
400
+ const [error, setError] = useState('')
401
+ const [submitting, setSubmitting] = useState(false)
402
+
403
+ async function handleDelete() {
404
+ setSubmitting(true); setError('')
405
+ const { ok, data } = await del(`/api/users/${user.id}`)
406
+ if (ok) { onClose(); onSuccess() }
407
+ else setError(data?.error ?? 'Request failed')
408
+ setSubmitting(false)
409
+ }
410
+
411
+ return (
412
+ <div className="space-y-4">
413
+ <div>
414
+ <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Are you sure?</h3>
415
+ <p className="text-sm text-gray-500 dark:text-gray-400">
416
+ This will permanently delete <span className="font-medium text-gray-900 dark:text-gray-50">{user.name}</span> ({user.email}). This action cannot be undone.
417
+ </p>
418
+ </div>
419
+ {error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
420
+ <div className="flex justify-end gap-2 pt-2">
421
+ <button type="button" onClick={onClose} className="rounded-md border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800">Cancel</button>
422
+ <button type="button" disabled={submitting} onClick={handleDelete} className="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700 disabled:opacity-50">{submitting ? 'Deleting...' : 'Delete'}</button>
423
+ </div>
424
+ </div>
425
+ )
426
+ }
@@ -0,0 +1,64 @@
1
+ import { AuthenticatedLayout } from '@/components/layout/authenticated-layout'
2
+ import { Header } from '@/components/layout/header'
3
+ import { Main } from '@/components/layout/main'
4
+ import { cn } from '@/lib/utils'
5
+
6
+ interface AccountLayoutProps {
7
+ children: React.ReactNode
8
+ appName?: string
9
+ currentUser?: any
10
+ navigate: (href: string) => void
11
+ activePath: string
12
+ }
13
+
14
+ const sidebarNav = [
15
+ { title: 'Profile', href: '/account/profile', iconPath: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8z' },
16
+ { title: 'Security', href: '/account/security', iconPath: 'M19 11H5a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2zM7 11V7a5 5 0 0 1 10 0v4' },
17
+ { title: 'Preferences', href: '/account/preferences', iconPath: 'M12 2a10 10 0 0 0 0 20 2 2 0 0 0 2-2v-.09a1.65 1.65 0 0 1 3 0v.09a2 2 0 0 0 2 2h.44A10 10 0 0 0 12 2z' },
18
+ ]
19
+
20
+ export function AccountLayout({ children, appName, currentUser, navigate, activePath }: AccountLayoutProps) {
21
+ return (
22
+ <AuthenticatedLayout currentUser={currentUser} appName={appName} navigate={navigate} activePath={activePath}>
23
+ <Header fixed navigate={navigate}>
24
+ <h1 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Settings</h1>
25
+ </Header>
26
+ <Main>
27
+ <div className="space-y-0.5">
28
+ <h2 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-gray-50">Settings</h2>
29
+ <p className="text-gray-500 dark:text-gray-400">
30
+ Manage your account settings and preferences.
31
+ </p>
32
+ </div>
33
+ <div className="my-6 h-px bg-gray-200 dark:bg-gray-800" />
34
+ <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
35
+ <aside className="lg:w-48">
36
+ <nav className="flex space-x-2 overflow-x-auto lg:flex-col lg:space-x-0 lg:space-y-1">
37
+ {sidebarNav.map((item) => (
38
+ <a
39
+ key={item.href}
40
+ href={item.href}
41
+ onClick={(e) => { e.preventDefault(); navigate(item.href) }}
42
+ className={cn(
43
+ 'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
44
+ activePath === item.href
45
+ ? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-50'
46
+ : 'text-gray-500 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-50',
47
+ )}
48
+ >
49
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
50
+ {item.iconPath.split(' M').map((d, i) => (
51
+ <path key={i} d={i === 0 ? d : `M${d}`} />
52
+ ))}
53
+ </svg>
54
+ {item.title}
55
+ </a>
56
+ ))}
57
+ </nav>
58
+ </aside>
59
+ <div className="flex-1 lg:max-w-2xl">{children}</div>
60
+ </div>
61
+ </Main>
62
+ </AuthenticatedLayout>
63
+ )
64
+ }
@@ -0,0 +1,80 @@
1
+ import { useState } from 'react'
2
+ import { AccountLayout } from './layout.tsx'
3
+
4
+ interface PreferencesProps {
5
+ appName?: string
6
+ currentUser?: { id: number; name: string; email: string } | null
7
+ navigate: (href: string) => void
8
+ [key: string]: any
9
+ }
10
+
11
+ export default function Preferences({ appName, currentUser, navigate }: PreferencesProps) {
12
+ const [theme, setTheme] = useState<'light' | 'dark' | 'system'>(() => {
13
+ if (typeof localStorage === 'undefined') return 'system'
14
+ return (localStorage.getItem('theme') as any) ?? 'system'
15
+ })
16
+
17
+ const applyTheme = (t: 'light' | 'dark' | 'system') => {
18
+ setTheme(t)
19
+ if (t === 'system') {
20
+ localStorage.removeItem('theme')
21
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
22
+ document.documentElement.classList.toggle('dark', prefersDark)
23
+ } else {
24
+ localStorage.setItem('theme', t)
25
+ document.documentElement.classList.toggle('dark', t === 'dark')
26
+ }
27
+ }
28
+
29
+ const themes = [
30
+ { value: 'light' as const, label: 'Light', description: 'A clean, bright appearance.' },
31
+ { value: 'dark' as const, label: 'Dark', description: 'Easy on the eyes in low light.' },
32
+ { value: 'system' as const, label: 'System', description: 'Follows your operating system setting.' },
33
+ ]
34
+
35
+ return (
36
+ <AccountLayout appName={appName} currentUser={currentUser} navigate={navigate} activePath="/account/preferences">
37
+ <div className="space-y-8">
38
+ <div>
39
+ <h3 className="text-lg font-medium text-gray-900 dark:text-gray-50">Preferences</h3>
40
+ <p className="text-sm text-gray-500 dark:text-gray-400">
41
+ Customize the appearance and behavior of the app.
42
+ </p>
43
+ </div>
44
+
45
+ {/* Theme */}
46
+ <div className="space-y-3">
47
+ <label className="text-sm font-medium text-gray-900 dark:text-gray-50">Theme</label>
48
+ <p className="text-sm text-gray-500 dark:text-gray-400">Select your preferred theme.</p>
49
+ <div className="grid grid-cols-3 gap-3">
50
+ {themes.map(t => (
51
+ <button
52
+ key={t.value}
53
+ type="button"
54
+ onClick={() => applyTheme(t.value)}
55
+ className={`rounded-lg border p-4 text-left text-sm transition-colors hover:bg-gray-50 dark:hover:bg-gray-800 ${
56
+ theme === t.value
57
+ ? 'border-gray-900 dark:border-gray-50'
58
+ : 'border-gray-200 dark:border-gray-700'
59
+ }`}
60
+ >
61
+ <div className="font-medium text-gray-900 dark:text-gray-50">{t.label}</div>
62
+ <div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{t.description}</div>
63
+ </button>
64
+ ))}
65
+ </div>
66
+ </div>
67
+
68
+ {/* Language */}
69
+ <div className="space-y-3">
70
+ <label className="text-sm font-medium text-gray-900 dark:text-gray-50">Language</label>
71
+ <p className="text-sm text-gray-500 dark:text-gray-400">Select the language for the interface.</p>
72
+ <button type="button" disabled className="inline-flex w-48 items-center justify-between rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-500 opacity-50 dark:border-gray-700 dark:text-gray-400">
73
+ English
74
+ <span className="text-xs">Default</span>
75
+ </button>
76
+ </div>
77
+ </div>
78
+ </AccountLayout>
79
+ )
80
+ }
@@ -0,0 +1,67 @@
1
+ import { useState } from 'react'
2
+ import { AccountLayout } from './layout.tsx'
3
+
4
+ interface ProfileProps {
5
+ appName?: string
6
+ currentUser?: { id: number; name: string; email: string } | null
7
+ navigate: (href: string) => void
8
+ [key: string]: any
9
+ }
10
+
11
+ export default function Profile({ appName, currentUser, navigate }: ProfileProps) {
12
+ const [name, setName] = useState(currentUser?.name ?? '')
13
+ const [email, setEmail] = useState(currentUser?.email ?? '')
14
+ const [saving, setSaving] = useState(false)
15
+ const [saved, setSaved] = useState(false)
16
+
17
+ const handleSave = async (e: React.FormEvent) => {
18
+ e.preventDefault()
19
+ setSaving(true)
20
+ await new Promise(r => setTimeout(r, 500))
21
+ setSaving(false)
22
+ setSaved(true)
23
+ setTimeout(() => setSaved(false), 3000)
24
+ }
25
+
26
+ return (
27
+ <AccountLayout appName={appName} currentUser={currentUser} navigate={navigate} activePath="/account/profile">
28
+ <div className="space-y-6">
29
+ <div>
30
+ <h3 className="text-lg font-medium text-gray-900 dark:text-gray-50">Profile</h3>
31
+ <p className="text-sm text-gray-500 dark:text-gray-400">
32
+ This is how others will see you on the site.
33
+ </p>
34
+ </div>
35
+ <form onSubmit={handleSave} className="space-y-6">
36
+ <div className="space-y-2">
37
+ <label htmlFor="name" className="text-sm font-medium text-gray-900 dark:text-gray-50">Name</label>
38
+ <input
39
+ id="name"
40
+ value={name}
41
+ onChange={e => setName(e.target.value)}
42
+ className="w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-sm outline-none ring-emerald-500 transition focus:border-emerald-500 focus:ring-2 dark:border-gray-700 dark:bg-gray-900"
43
+ />
44
+ <p className="text-xs text-gray-500 dark:text-gray-400">This is your public display name.</p>
45
+ </div>
46
+ <div className="space-y-2">
47
+ <label htmlFor="email" className="text-sm font-medium text-gray-900 dark:text-gray-50">Email</label>
48
+ <input
49
+ id="email"
50
+ type="email"
51
+ value={email}
52
+ onChange={e => setEmail(e.target.value)}
53
+ className="w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-sm outline-none ring-emerald-500 transition focus:border-emerald-500 focus:ring-2 dark:border-gray-700 dark:bg-gray-900"
54
+ />
55
+ <p className="text-xs text-gray-500 dark:text-gray-400">Your email address is used for notifications.</p>
56
+ </div>
57
+ <div className="flex items-center gap-3">
58
+ <button type="submit" disabled={saving} className="rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-700 disabled:opacity-50">
59
+ {saving ? 'Saving...' : 'Update profile'}
60
+ </button>
61
+ {saved && <span className="text-sm text-gray-500 dark:text-gray-400">Saved.</span>}
62
+ </div>
63
+ </form>
64
+ </div>
65
+ </AccountLayout>
66
+ )
67
+ }