@strav/spring 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +61 -0
  2. package/package.json +31 -0
  3. package/src/index.ts +176 -0
  4. package/src/prompts.ts +135 -0
  5. package/src/scaffold.ts +54 -0
  6. package/src/templates/api/app/controllers/user_controller.ts +69 -0
  7. package/src/templates/api/config/http.ts +10 -0
  8. package/src/templates/api/index.ts +33 -0
  9. package/src/templates/api/routes/routes.ts +24 -0
  10. package/src/templates/shared/.env +14 -0
  11. package/src/templates/shared/app/controllers/controller.ts +15 -0
  12. package/src/templates/shared/app/models/user.ts +30 -0
  13. package/src/templates/shared/config/app.ts +10 -0
  14. package/src/templates/shared/config/database.ts +9 -0
  15. package/src/templates/shared/config/encryption.ts +5 -0
  16. package/src/templates/shared/database/factories/user_factory.ts +11 -0
  17. package/src/templates/shared/database/schemas/public/user.ts +13 -0
  18. package/src/templates/shared/database/seeders/database_seeder.ts +8 -0
  19. package/src/templates/shared/database/seeders/user_seeder.ts +15 -0
  20. package/src/templates/shared/package.json +24 -0
  21. package/src/templates/shared/routes/routes.ts +13 -0
  22. package/src/templates/shared/storage/cache/.gitkeep +1 -0
  23. package/src/templates/shared/storage/logs/.gitkeep +1 -0
  24. package/src/templates/shared/storage/uploads/.gitkeep +1 -0
  25. package/src/templates/shared/strav.ts +20 -0
  26. package/src/templates/shared/tests/example.test.ts +11 -0
  27. package/src/templates/shared/tsconfig.json +20 -0
  28. package/src/templates/web/app/controllers/home_controller.ts +24 -0
  29. package/src/templates/web/config/session.ts +10 -0
  30. package/src/templates/web/config/view.ts +7 -0
  31. package/src/templates/web/index.ts +48 -0
  32. package/src/templates/web/package.json +26 -0
  33. package/src/templates/web/resources/css/app.css +176 -0
  34. package/src/templates/web/resources/ts/islands/counter.vue +42 -0
  35. package/src/templates/web/resources/ts/islands/user_manager.vue +127 -0
  36. package/src/templates/web/resources/ts/islands/user_search.vue +71 -0
  37. package/src/templates/web/resources/views/layouts/app.strav +32 -0
  38. package/src/templates/web/resources/views/pages/home.strav +52 -0
  39. package/src/templates/web/resources/views/pages/users.strav +63 -0
  40. package/src/templates/web/routes/routes.ts +22 -0
@@ -0,0 +1,11 @@
1
+ import { Factory } from '@strav/testing'
2
+ import User from '../../app/models/user.ts'
3
+
4
+ export const UserFactory = Factory.define(User, (seq) => ({
5
+ id: crypto.randomUUID(),
6
+ email: `user-${seq}@example.com`,
7
+ name: `User ${seq}`,
8
+ password_hash: '$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
9
+ email_verified_at: new Date(),
10
+ remember_token: null,
11
+ }))
@@ -0,0 +1,13 @@
1
+ import { defineSchema, t, Archetype } from '@strav/database'
2
+
3
+ export default defineSchema('user', {
4
+ archetype: Archetype.Entity,
5
+ fields: {
6
+ id: t.uuid().primaryKey(),
7
+ email: t.string().email().unique().required(),
8
+ name: t.string().required(),
9
+ password_hash: t.string().required(),
10
+ email_verified_at: t.timestamp().nullable(),
11
+ remember_token: t.string(100).nullable(),
12
+ },
13
+ })
@@ -0,0 +1,8 @@
1
+ import { Seeder } from '@strav/database'
2
+ import UserSeeder from './user_seeder.ts'
3
+
4
+ export default class DatabaseSeeder extends Seeder {
5
+ async run(): Promise<void> {
6
+ await this.call(UserSeeder)
7
+ }
8
+ }
@@ -0,0 +1,15 @@
1
+ import { Seeder } from '@strav/database'
2
+ import { UserFactory } from '../factories/user_factory.ts'
3
+
4
+ export default class UserSeeder extends Seeder {
5
+ async run(): Promise<void> {
6
+ // Create admin user
7
+ await UserFactory.create({
8
+ email: 'admin@example.com',
9
+ name: 'Admin User',
10
+ })
11
+
12
+ // Create test users
13
+ await UserFactory.createMany(10)
14
+ }
15
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "__PROJECT_NAME__",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "private": true,
6
+ "scripts": {
7
+ "dev": "bun --hot index.ts",
8
+ "start": "bun index.ts",
9
+ "test": "bun test tests/",
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "@strav/kernel": "workspace:*",
14
+ "@strav/http": "workspace:*",
15
+ "@strav/database": "workspace:*",
16
+ "@strav/cli": "workspace:*",
17
+ "reflect-metadata": "^0.2.2"
18
+ },
19
+ "devDependencies": {
20
+ "@types/bun": "latest",
21
+ "@strav/testing": "workspace:*",
22
+ "typescript": "^5.9.3"
23
+ }
24
+ }
@@ -0,0 +1,13 @@
1
+ import type { Router } from '@strav/http'
2
+
3
+ export default function (router: Router) {
4
+ // Health check endpoint
5
+ router.get('/health', async (ctx) => {
6
+ return ctx.json({ status: 'ok', timestamp: new Date().toISOString() })
7
+ })
8
+
9
+ // API routes
10
+ router.group('/api', () => {
11
+ // Add your API routes here
12
+ })
13
+ }
@@ -0,0 +1 @@
1
+ # This file keeps the cache directory in git
@@ -0,0 +1 @@
1
+ # This file keeps the logs directory in git
@@ -0,0 +1 @@
1
+ # This file keeps the uploads directory in git
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env bun
2
+ import 'reflect-metadata'
3
+ import { app } from '@strav/kernel'
4
+ import { ConfigProvider, EncryptionProvider } from '@strav/kernel'
5
+ import { DatabaseProvider } from '@strav/database'
6
+ import { CliProvider } from '@strav/cli'
7
+
8
+ // Register service providers
9
+ app
10
+ .use(new ConfigProvider())
11
+ .use(new DatabaseProvider())
12
+ .use(new EncryptionProvider())
13
+ .use(new CliProvider())
14
+
15
+ // Boot services
16
+ await app.start()
17
+
18
+ // Start CLI
19
+ const cli = app.resolve('cli')
20
+ await cli.handle(process.argv.slice(2))
@@ -0,0 +1,11 @@
1
+ import { test, expect } from 'bun:test'
2
+
3
+ test('example test', () => {
4
+ expect(1 + 1).toBe(2)
5
+ })
6
+
7
+ test('health endpoint returns ok', async () => {
8
+ // TODO: Add integration tests for your routes
9
+ // Use @strav/testing for HTTP testing helpers
10
+ expect(true).toBe(true)
11
+ })
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "strict": true,
4
+ "target": "ES2022",
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "allowImportingTsExtensions": true,
8
+ "noEmit": true,
9
+ "composite": false,
10
+ "skipLibCheck": true,
11
+ "allowSyntheticDefaultImports": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "experimentalDecorators": true,
14
+ "emitDecoratorMetadata": true,
15
+ "useDefineForClassFields": false,
16
+ "types": ["bun-types"]
17
+ },
18
+ "include": ["**/*.ts"],
19
+ "exclude": ["node_modules", "dist"]
20
+ }
@@ -0,0 +1,24 @@
1
+ import type { Context } from '@strav/http'
2
+ import { Controller } from './controller.ts'
3
+ import User from '../models/user.ts'
4
+
5
+ export default class HomeController extends Controller {
6
+ async index(ctx: Context) {
7
+ const userCount = await User.count()
8
+
9
+ return ctx.view('pages/home', {
10
+ title: 'Welcome to __PROJECT_NAME__',
11
+ userCount,
12
+ message: 'Welcome to your new Strav application!',
13
+ })
14
+ }
15
+
16
+ async users(ctx: Context) {
17
+ const users = await User.all()
18
+
19
+ return ctx.view('pages/users', {
20
+ title: 'Users',
21
+ users,
22
+ })
23
+ }
24
+ }
@@ -0,0 +1,10 @@
1
+ import { env } from '@strav/kernel'
2
+
3
+ export default {
4
+ secret: env('SESSION_SECRET'),
5
+ cookieName: env('SESSION_COOKIE_NAME', 'session'),
6
+ maxAge: 1000 * 60 * 60 * 24 * 7, // 1 week
7
+ secure: env('APP_ENV') === 'production',
8
+ httpOnly: true,
9
+ sameSite: 'lax' as const,
10
+ }
@@ -0,0 +1,7 @@
1
+ import { env } from '@strav/kernel'
2
+
3
+ export default {
4
+ directory: 'resources/views',
5
+ cache: env.bool('VIEW_CACHE', true),
6
+ assets: ['/css/app.css', '/islands.js'],
7
+ }
@@ -0,0 +1,48 @@
1
+ import 'reflect-metadata'
2
+ import { app } from '@strav/kernel'
3
+ import { router } from '@strav/http'
4
+ import { ConfigProvider, EncryptionProvider } from '@strav/kernel'
5
+ import { DatabaseProvider } from '@strav/database'
6
+ import { SessionProvider } from '@strav/http'
7
+ import { ViewProvider } from '@strav/view'
8
+ import BaseModel from '@strav/database/orm/base_model'
9
+ import Database from '@strav/database/database/database'
10
+ import Server from '@strav/http/server'
11
+ import { ExceptionHandler } from '@strav/kernel'
12
+ import { IslandBuilder } from '@strav/view'
13
+
14
+ // Register service providers
15
+ app
16
+ .use(new ConfigProvider())
17
+ .use(new DatabaseProvider())
18
+ .use(new EncryptionProvider())
19
+ .use(new SessionProvider())
20
+ .use(new ViewProvider())
21
+
22
+ // Boot services (loads config, connects database, derives encryption keys, starts sessions)
23
+ await app.start()
24
+
25
+ // Initialize ORM
26
+ new BaseModel(app.resolve(Database))
27
+
28
+ // Build Vue islands for development
29
+ if (process.env.NODE_ENV !== 'production') {
30
+ const islands = new IslandBuilder({
31
+ islandsDir: './resources/ts/islands',
32
+ outDir: './public',
33
+ outFile: 'islands.js',
34
+ })
35
+ await islands.build()
36
+ islands.watch() // Auto-rebuild on changes
37
+ }
38
+
39
+ // Configure router
40
+ router.useExceptionHandler(new ExceptionHandler(true))
41
+
42
+ // Load routes
43
+ await import('./routes/routes')
44
+
45
+ // Start HTTP server
46
+ app.singleton(Server)
47
+ const server = app.resolve(Server)
48
+ server.start(router)
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "__PROJECT_NAME__",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "private": true,
6
+ "scripts": {
7
+ "dev": "bun --hot index.ts",
8
+ "start": "bun index.ts",
9
+ "test": "bun test tests/",
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "@strav/kernel": "workspace:*",
14
+ "@strav/http": "workspace:*",
15
+ "@strav/view": "workspace:*",
16
+ "@strav/database": "workspace:*",
17
+ "@strav/cli": "workspace:*",
18
+ "vue": "^3.5.28",
19
+ "reflect-metadata": "^0.2.2"
20
+ },
21
+ "devDependencies": {
22
+ "@types/bun": "latest",
23
+ "@strav/testing": "workspace:*",
24
+ "typescript": "^5.9.3"
25
+ }
26
+ }
@@ -0,0 +1,176 @@
1
+ /* Basic Tailwind CSS-inspired styles */
2
+ /* In a real project, you'd use Tailwind CSS or your preferred CSS framework */
3
+
4
+ * {
5
+ margin: 0;
6
+ padding: 0;
7
+ box-sizing: border-box;
8
+ }
9
+
10
+ body {
11
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
12
+ 'Ubuntu', 'Cantarell', 'Open Sans', 'Helvetica Neue', sans-serif;
13
+ line-height: 1.6;
14
+ color: #374151;
15
+ }
16
+
17
+ /* Basic utility classes */
18
+ .bg-gray-50 { background-color: #f9fafb; }
19
+ .bg-white { background-color: #ffffff; }
20
+ .bg-blue-600 { background-color: #2563eb; }
21
+ .bg-blue-700 { background-color: #1d4ed8; }
22
+ .bg-red-500 { background-color: #ef4444; }
23
+ .bg-red-600 { background-color: #dc2626; }
24
+ .bg-green-500 { background-color: #10b981; }
25
+ .bg-green-600 { background-color: #059669; }
26
+ .bg-gray-100 { background-color: #f3f4f6; }
27
+ .bg-yellow-50 { background-color: #fffbeb; }
28
+
29
+ .text-white { color: #ffffff; }
30
+ .text-gray-900 { color: #111827; }
31
+ .text-gray-600 { color: #4b5563; }
32
+ .text-gray-500 { color: #6b7280; }
33
+ .text-blue-600 { color: #2563eb; }
34
+ .text-green-600 { color: #059669; }
35
+
36
+ .text-xs { font-size: 0.75rem; }
37
+ .text-sm { font-size: 0.875rem; }
38
+ .text-base { font-size: 1rem; }
39
+ .text-lg { font-size: 1.125rem; }
40
+ .text-xl { font-size: 1.25rem; }
41
+ .text-2xl { font-size: 1.5rem; }
42
+ .text-4xl { font-size: 2.25rem; }
43
+
44
+ .font-bold { font-weight: 700; }
45
+ .font-semibold { font-weight: 600; }
46
+ .font-medium { font-weight: 500; }
47
+
48
+ .max-w-7xl { max-width: 80rem; }
49
+ .max-w-4xl { max-width: 56rem; }
50
+ .max-w-md { max-width: 28rem; }
51
+ .mx-auto { margin-left: auto; margin-right: auto; }
52
+
53
+ .px-4 { padding-left: 1rem; padding-right: 1rem; }
54
+ .py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; }
55
+ .p-6 { padding: 1.5rem; }
56
+ .px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
57
+ .py-4 { padding-top: 1rem; padding-bottom: 1rem; }
58
+ .px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
59
+ .py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
60
+ .py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
61
+ .py-12 { padding-top: 3rem; padding-bottom: 3rem; }
62
+
63
+ .mb-4 { margin-bottom: 1rem; }
64
+ .mb-8 { margin-bottom: 2rem; }
65
+ .mt-8 { margin-top: 2rem; }
66
+ .mt-2 { margin-top: 0.5rem; }
67
+
68
+ .flex { display: flex; }
69
+ .grid { display: grid; }
70
+ .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
71
+ .grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
72
+ .gap-6 { gap: 1.5rem; }
73
+ .space-x-4 > * + * { margin-left: 1rem; }
74
+ .space-y-6 > * + * { margin-top: 1.5rem; }
75
+ .items-center { align-items: center; }
76
+ .justify-between { justify-content: space-between; }
77
+
78
+ .rounded { border-radius: 0.25rem; }
79
+ .rounded-lg { border-radius: 0.5rem; }
80
+ .shadow { box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); }
81
+
82
+ .border { border-width: 1px; }
83
+ .border-4 { border-width: 4px; }
84
+ .border-dashed { border-style: dashed; }
85
+ .border-gray-200 { border-color: #e5e7eb; }
86
+
87
+ .text-center { text-align: center; }
88
+
89
+ .hover\:bg-blue-700:hover { background-color: #1d4ed8; }
90
+ .hover\:bg-red-600:hover { background-color: #dc2626; }
91
+ .hover\:bg-green-600:hover { background-color: #059669; }
92
+ .hover\:text-gray-900:hover { color: #111827; }
93
+
94
+ .transition-colors {
95
+ transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
96
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
97
+ transition-duration: 150ms;
98
+ }
99
+
100
+ /* Navigation styles */
101
+ nav {
102
+ border-bottom: 1px solid #e5e7eb;
103
+ }
104
+
105
+ nav a {
106
+ text-decoration: none;
107
+ transition: color 0.15s ease-in-out;
108
+ }
109
+
110
+ /* Table styles */
111
+ table {
112
+ border-collapse: collapse;
113
+ width: 100%;
114
+ }
115
+
116
+ th, td {
117
+ text-align: left;
118
+ border-bottom: 1px solid #e5e7eb;
119
+ }
120
+
121
+ th {
122
+ background-color: #f9fafb;
123
+ font-weight: 500;
124
+ text-transform: uppercase;
125
+ letter-spacing: 0.025em;
126
+ }
127
+
128
+ /* Form styles */
129
+ input[type="text"],
130
+ input[type="email"],
131
+ textarea {
132
+ border: 1px solid #d1d5db;
133
+ border-radius: 0.375rem;
134
+ padding: 0.5rem 0.75rem;
135
+ width: 100%;
136
+ transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
137
+ }
138
+
139
+ input:focus,
140
+ textarea:focus {
141
+ outline: none;
142
+ border-color: #3b82f6;
143
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
144
+ }
145
+
146
+ button {
147
+ cursor: pointer;
148
+ border: none;
149
+ border-radius: 0.375rem;
150
+ font-weight: 500;
151
+ text-align: center;
152
+ transition: background-color 0.15s ease-in-out;
153
+ }
154
+
155
+ button:disabled {
156
+ opacity: 0.5;
157
+ cursor: not-allowed;
158
+ }
159
+
160
+ code {
161
+ background-color: #f3f4f6;
162
+ padding: 0.125rem 0.5rem;
163
+ border-radius: 0.25rem;
164
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
165
+ }
166
+
167
+ /* Media queries for responsiveness */
168
+ @media (min-width: 768px) {
169
+ .md\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
170
+ .md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
171
+ }
172
+
173
+ @media (min-width: 640px) {
174
+ .sm\:px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
175
+ .sm\:px-0 { padding-left: 0; padding-right: 0; }
176
+ }
@@ -0,0 +1,42 @@
1
+ <template>
2
+ <div class="flex items-center space-x-4">
3
+ <button
4
+ @click="decrement"
5
+ class="px-3 py-1 bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
6
+ >
7
+ -
8
+ </button>
9
+
10
+ <span class="text-xl font-bold text-gray-900 min-w-[3rem] text-center">
11
+ {{ count }}
12
+ </span>
13
+
14
+ <button
15
+ @click="increment"
16
+ class="px-3 py-1 bg-green-500 text-white rounded hover:bg-green-600 transition-colors"
17
+ >
18
+ +
19
+ </button>
20
+
21
+ <span class="text-sm text-gray-600 ml-4">{{ label }}</span>
22
+ </div>
23
+ </template>
24
+
25
+ <script setup>
26
+ import { ref } from 'vue'
27
+
28
+ const props = defineProps({
29
+ initial: { type: Number, default: 0 },
30
+ label: { type: String, default: 'Counter' }
31
+ })
32
+
33
+ const count = ref(props.initial)
34
+
35
+ function increment() {
36
+ count.value++
37
+ }
38
+
39
+ function decrement() {
40
+ count.value--
41
+ }
42
+ </script>
@@ -0,0 +1,127 @@
1
+ <template>
2
+ <div class="bg-white p-6 rounded-lg shadow">
3
+ <h3 class="text-lg font-semibold text-gray-900 mb-4">Interactive User Management</h3>
4
+
5
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
6
+ <!-- Add User Form -->
7
+ <div>
8
+ <h4 class="font-medium text-gray-900 mb-3">Add New User</h4>
9
+ <form @submit.prevent="addUser" class="space-y-3">
10
+ <input
11
+ v-model="newUser.name"
12
+ type="text"
13
+ placeholder="Full Name"
14
+ required
15
+ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
16
+ />
17
+ <input
18
+ v-model="newUser.email"
19
+ type="email"
20
+ placeholder="Email Address"
21
+ required
22
+ class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
23
+ />
24
+ <button
25
+ type="submit"
26
+ :disabled="isLoading"
27
+ class="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
28
+ >
29
+ {{ isLoading ? 'Adding...' : 'Add User' }}
30
+ </button>
31
+ </form>
32
+ </div>
33
+
34
+ <!-- User Stats -->
35
+ <div>
36
+ <h4 class="font-medium text-gray-900 mb-3">Statistics</h4>
37
+ <div class="space-y-2">
38
+ <div class="flex justify-between">
39
+ <span class="text-gray-600">Total Users:</span>
40
+ <span class="font-semibold">{{ users.length }}</span>
41
+ </div>
42
+ <div class="flex justify-between">
43
+ <span class="text-gray-600">Users Added:</span>
44
+ <span class="font-semibold text-green-600">+{{ addedCount }}</span>
45
+ </div>
46
+ <div class="flex justify-between">
47
+ <span class="text-gray-600">Last Added:</span>
48
+ <span class="text-sm text-gray-500">
49
+ {{ lastAdded || 'None yet' }}
50
+ </span>
51
+ </div>
52
+ </div>
53
+ </div>
54
+ </div>
55
+
56
+ <!-- Recent Users List -->
57
+ <div class="mt-6">
58
+ <h4 class="font-medium text-gray-900 mb-3">Recent Users</h4>
59
+ <div v-if="users.length === 0" class="text-center text-gray-500 py-8">
60
+ No users yet. Add one above!
61
+ </div>
62
+ <div v-else class="space-y-2 max-h-48 overflow-y-auto">
63
+ <div
64
+ v-for="(user, index) in users.slice(-5).reverse()"
65
+ :key="user.id || index"
66
+ class="flex items-center justify-between p-3 bg-gray-50 rounded-md"
67
+ >
68
+ <div>
69
+ <div class="font-medium text-gray-900">{{ user.name }}</div>
70
+ <div class="text-sm text-gray-500">{{ user.email }}</div>
71
+ </div>
72
+ <span v-if="index === 0" class="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">
73
+ Latest
74
+ </span>
75
+ </div>
76
+ </div>
77
+ </div>
78
+ </div>
79
+ </template>
80
+
81
+ <script setup>
82
+ import { ref, reactive, computed } from 'vue'
83
+
84
+ const props = defineProps({
85
+ initialUsers: { type: String, default: '[]' }
86
+ })
87
+
88
+ // Parse initial users from JSON string
89
+ const users = ref(JSON.parse(props.initialUsers))
90
+ const addedCount = ref(0)
91
+ const isLoading = ref(false)
92
+
93
+ const newUser = reactive({
94
+ name: '',
95
+ email: ''
96
+ })
97
+
98
+ const lastAdded = computed(() => {
99
+ if (addedCount.value === 0) return null
100
+ const latest = users.value[users.value.length - 1]
101
+ return latest ? `${latest.name} (${latest.email})` : null
102
+ })
103
+
104
+ async function addUser() {
105
+ if (!newUser.name || !newUser.email) return
106
+
107
+ isLoading.value = true
108
+
109
+ // Simulate API call
110
+ await new Promise(resolve => setTimeout(resolve, 500))
111
+
112
+ // Add the new user
113
+ users.value.push({
114
+ id: crypto.randomUUID(),
115
+ name: newUser.name,
116
+ email: newUser.email,
117
+ created_at: new Date()
118
+ })
119
+
120
+ addedCount.value++
121
+
122
+ // Reset form
123
+ newUser.name = ''
124
+ newUser.email = ''
125
+ isLoading.value = false
126
+ }
127
+ </script>