@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.
- package/README.md +61 -0
- package/package.json +31 -0
- package/src/index.ts +176 -0
- package/src/prompts.ts +135 -0
- package/src/scaffold.ts +54 -0
- package/src/templates/api/app/controllers/user_controller.ts +69 -0
- package/src/templates/api/config/http.ts +10 -0
- package/src/templates/api/index.ts +33 -0
- package/src/templates/api/routes/routes.ts +24 -0
- package/src/templates/shared/.env +14 -0
- package/src/templates/shared/app/controllers/controller.ts +15 -0
- package/src/templates/shared/app/models/user.ts +30 -0
- package/src/templates/shared/config/app.ts +10 -0
- package/src/templates/shared/config/database.ts +9 -0
- package/src/templates/shared/config/encryption.ts +5 -0
- package/src/templates/shared/database/factories/user_factory.ts +11 -0
- package/src/templates/shared/database/schemas/public/user.ts +13 -0
- package/src/templates/shared/database/seeders/database_seeder.ts +8 -0
- package/src/templates/shared/database/seeders/user_seeder.ts +15 -0
- package/src/templates/shared/package.json +24 -0
- package/src/templates/shared/routes/routes.ts +13 -0
- package/src/templates/shared/storage/cache/.gitkeep +1 -0
- package/src/templates/shared/storage/logs/.gitkeep +1 -0
- package/src/templates/shared/storage/uploads/.gitkeep +1 -0
- package/src/templates/shared/strav.ts +20 -0
- package/src/templates/shared/tests/example.test.ts +11 -0
- package/src/templates/shared/tsconfig.json +20 -0
- package/src/templates/web/app/controllers/home_controller.ts +24 -0
- package/src/templates/web/config/session.ts +10 -0
- package/src/templates/web/config/view.ts +7 -0
- package/src/templates/web/index.ts +48 -0
- package/src/templates/web/package.json +26 -0
- package/src/templates/web/resources/css/app.css +176 -0
- package/src/templates/web/resources/ts/islands/counter.vue +42 -0
- package/src/templates/web/resources/ts/islands/user_manager.vue +127 -0
- package/src/templates/web/resources/ts/islands/user_search.vue +71 -0
- package/src/templates/web/resources/views/layouts/app.strav +32 -0
- package/src/templates/web/resources/views/pages/home.strav +52 -0
- package/src/templates/web/resources/views/pages/users.strav +63 -0
- 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,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,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>
|