@htlkg/astro 0.0.1
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 +265 -0
- package/dist/chunk-33R4URZV.js +59 -0
- package/dist/chunk-33R4URZV.js.map +1 -0
- package/dist/chunk-64USRLVP.js +85 -0
- package/dist/chunk-64USRLVP.js.map +1 -0
- package/dist/chunk-WLOFOVCL.js +210 -0
- package/dist/chunk-WLOFOVCL.js.map +1 -0
- package/dist/chunk-WNMPTDCR.js +73 -0
- package/dist/chunk-WNMPTDCR.js.map +1 -0
- package/dist/chunk-Z2ZAL7KX.js +9 -0
- package/dist/chunk-Z2ZAL7KX.js.map +1 -0
- package/dist/chunk-ZQ4XMJH7.js +1 -0
- package/dist/chunk-ZQ4XMJH7.js.map +1 -0
- package/dist/htlkg/config.js +7 -0
- package/dist/htlkg/config.js.map +1 -0
- package/dist/htlkg/index.js +7 -0
- package/dist/htlkg/index.js.map +1 -0
- package/dist/index.js +64 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/index.js +168 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/utils/hydration.js +21 -0
- package/dist/utils/hydration.js.map +1 -0
- package/dist/utils/index.js +56 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/ssr.js +21 -0
- package/dist/utils/ssr.js.map +1 -0
- package/dist/utils/static.js +19 -0
- package/dist/utils/static.js.map +1 -0
- package/package.json +53 -0
- package/src/__mocks__/astro-middleware.ts +19 -0
- package/src/__mocks__/virtual-htlkg-config.ts +14 -0
- package/src/auth/LoginForm.vue +482 -0
- package/src/auth/LoginPage.astro +70 -0
- package/src/components/PageHeader.astro +145 -0
- package/src/components/Sidebar.astro +157 -0
- package/src/components/Topbar.astro +167 -0
- package/src/components/index.ts +9 -0
- package/src/htlkg/config.test.ts +165 -0
- package/src/htlkg/config.ts +242 -0
- package/src/htlkg/index.ts +245 -0
- package/src/htlkg/virtual-modules.test.ts +158 -0
- package/src/htlkg/virtual-modules.ts +81 -0
- package/src/index.ts +37 -0
- package/src/layouts/AdminLayout.astro +184 -0
- package/src/layouts/AuthLayout.astro +164 -0
- package/src/layouts/BrandLayout.astro +309 -0
- package/src/layouts/DefaultLayout.astro +25 -0
- package/src/layouts/PublicLayout.astro +153 -0
- package/src/layouts/index.ts +10 -0
- package/src/middleware/auth.ts +53 -0
- package/src/middleware/index.ts +31 -0
- package/src/middleware/route-guards.test.ts +182 -0
- package/src/middleware/route-guards.ts +218 -0
- package/src/patterns/admin/DetailPage.astro +195 -0
- package/src/patterns/admin/FormPage.astro +203 -0
- package/src/patterns/admin/ListPage.astro +178 -0
- package/src/patterns/admin/index.ts +9 -0
- package/src/patterns/brand/ConfigPage.astro +128 -0
- package/src/patterns/brand/PortalPage.astro +161 -0
- package/src/patterns/brand/index.ts +8 -0
- package/src/patterns/index.ts +8 -0
- package/src/utils/hydration.test.ts +154 -0
- package/src/utils/hydration.ts +151 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/ssr.test.ts +235 -0
- package/src/utils/ssr.ts +139 -0
- package/src/utils/static.test.ts +144 -0
- package/src/utils/static.ts +144 -0
- package/src/vue-app-setup.ts +88 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for route guard middleware
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
6
|
+
import { requireAuth, requireAdminAccess, requireBrandAccess } from './route-guards.js';
|
|
7
|
+
|
|
8
|
+
describe('Route Guard Helper Functions', () => {
|
|
9
|
+
describe('requireAuth', () => {
|
|
10
|
+
it('should return user when authenticated', async () => {
|
|
11
|
+
const mockUser = {
|
|
12
|
+
username: 'testuser',
|
|
13
|
+
email: 'test@example.com',
|
|
14
|
+
brandIds: [1, 2],
|
|
15
|
+
accountIds: [1],
|
|
16
|
+
isAdmin: false,
|
|
17
|
+
roles: ['user'],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const context = {
|
|
21
|
+
locals: { user: mockUser },
|
|
22
|
+
url: { pathname: '/dashboard', search: '' },
|
|
23
|
+
redirect: vi.fn(),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const result = await requireAuth(context);
|
|
27
|
+
expect(result).toEqual(mockUser);
|
|
28
|
+
expect(context.redirect).not.toHaveBeenCalled();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should redirect to login when not authenticated', async () => {
|
|
32
|
+
const context = {
|
|
33
|
+
locals: { user: null },
|
|
34
|
+
url: { pathname: '/dashboard', search: '?tab=settings' },
|
|
35
|
+
redirect: vi.fn((url) => ({ type: 'redirect', url })),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const result = await requireAuth(context);
|
|
39
|
+
expect(context.redirect).toHaveBeenCalledWith('/login?redirect=%2Fdashboard%3Ftab%3Dsettings');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should use custom login URL', async () => {
|
|
43
|
+
const context = {
|
|
44
|
+
locals: { user: null },
|
|
45
|
+
url: { pathname: '/dashboard', search: '' },
|
|
46
|
+
redirect: vi.fn((url) => ({ type: 'redirect', url })),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
await requireAuth(context, '/custom-login');
|
|
50
|
+
expect(context.redirect).toHaveBeenCalledWith('/custom-login?redirect=%2Fdashboard');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('requireAdminAccess', () => {
|
|
55
|
+
it('should return user when user is admin', async () => {
|
|
56
|
+
const mockUser = {
|
|
57
|
+
username: 'admin',
|
|
58
|
+
email: 'admin@example.com',
|
|
59
|
+
brandIds: [],
|
|
60
|
+
accountIds: [1],
|
|
61
|
+
isAdmin: true,
|
|
62
|
+
roles: ['admin'],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const context = {
|
|
66
|
+
locals: { user: mockUser },
|
|
67
|
+
url: { pathname: '/admin', search: '' },
|
|
68
|
+
redirect: vi.fn(),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const result = await requireAdminAccess(context);
|
|
72
|
+
expect(result).toEqual(mockUser);
|
|
73
|
+
expect(context.redirect).not.toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should redirect when user is not authenticated', async () => {
|
|
77
|
+
const context = {
|
|
78
|
+
locals: { user: null },
|
|
79
|
+
url: { pathname: '/admin', search: '' },
|
|
80
|
+
redirect: vi.fn((url) => ({ type: 'redirect', url })),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
await requireAdminAccess(context);
|
|
84
|
+
expect(context.redirect).toHaveBeenCalledWith('/login?error=not_authenticated');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should redirect when user is not admin', async () => {
|
|
88
|
+
const mockUser = {
|
|
89
|
+
username: 'user',
|
|
90
|
+
email: 'user@example.com',
|
|
91
|
+
brandIds: [1],
|
|
92
|
+
accountIds: [1],
|
|
93
|
+
isAdmin: false,
|
|
94
|
+
roles: ['user'],
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const context = {
|
|
98
|
+
locals: { user: mockUser },
|
|
99
|
+
url: { pathname: '/admin', search: '' },
|
|
100
|
+
redirect: vi.fn((url) => ({ type: 'redirect', url })),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
await requireAdminAccess(context);
|
|
104
|
+
expect(context.redirect).toHaveBeenCalledWith('/login?error=admin_required');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('requireBrandAccess', () => {
|
|
109
|
+
it('should return user when user has brand access', async () => {
|
|
110
|
+
const mockUser = {
|
|
111
|
+
username: 'user',
|
|
112
|
+
email: 'user@example.com',
|
|
113
|
+
brandIds: [1, 2, 3],
|
|
114
|
+
accountIds: [1],
|
|
115
|
+
isAdmin: false,
|
|
116
|
+
roles: ['user'],
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const context = {
|
|
120
|
+
locals: { user: mockUser },
|
|
121
|
+
url: { pathname: '/brands/2', search: '' },
|
|
122
|
+
redirect: vi.fn(),
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const result = await requireBrandAccess(context, 2);
|
|
126
|
+
expect(result).toEqual(mockUser);
|
|
127
|
+
expect(context.redirect).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should return user when user is admin (even without brand access)', async () => {
|
|
131
|
+
const mockUser = {
|
|
132
|
+
username: 'admin',
|
|
133
|
+
email: 'admin@example.com',
|
|
134
|
+
brandIds: [],
|
|
135
|
+
accountIds: [1],
|
|
136
|
+
isAdmin: true,
|
|
137
|
+
roles: ['admin'],
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const context = {
|
|
141
|
+
locals: { user: mockUser },
|
|
142
|
+
url: { pathname: '/brands/5', search: '' },
|
|
143
|
+
redirect: vi.fn(),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const result = await requireBrandAccess(context, 5);
|
|
147
|
+
expect(result).toEqual(mockUser);
|
|
148
|
+
expect(context.redirect).not.toHaveBeenCalled();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should redirect when user is not authenticated', async () => {
|
|
152
|
+
const context = {
|
|
153
|
+
locals: { user: null },
|
|
154
|
+
url: { pathname: '/brands/1', search: '' },
|
|
155
|
+
redirect: vi.fn((url) => ({ type: 'redirect', url })),
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
await requireBrandAccess(context, 1);
|
|
159
|
+
expect(context.redirect).toHaveBeenCalledWith('/login?error=not_authenticated');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should redirect when user does not have brand access', async () => {
|
|
163
|
+
const mockUser = {
|
|
164
|
+
username: 'user',
|
|
165
|
+
email: 'user@example.com',
|
|
166
|
+
brandIds: [1, 2],
|
|
167
|
+
accountIds: [1],
|
|
168
|
+
isAdmin: false,
|
|
169
|
+
roles: ['user'],
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const context = {
|
|
173
|
+
locals: { user: mockUser },
|
|
174
|
+
url: { pathname: '/brands/5', search: '' },
|
|
175
|
+
redirect: vi.fn((url) => ({ type: 'redirect', url })),
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
await requireBrandAccess(context, 5);
|
|
179
|
+
expect(context.redirect).toHaveBeenCalledWith('/login?error=access_denied');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route guard middleware for htlkg integration
|
|
3
|
+
*
|
|
4
|
+
* This middleware enforces access control based on route configuration:
|
|
5
|
+
* - Public routes: accessible to everyone
|
|
6
|
+
* - Authenticated routes: require any logged-in user
|
|
7
|
+
* - Admin routes: require admin privileges
|
|
8
|
+
* - Brand routes: require brand-specific access or admin privileges
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { MiddlewareHandler } from 'astro';
|
|
12
|
+
import type { RoutePattern, RouteGuardConfig } from '../htlkg/config.js';
|
|
13
|
+
|
|
14
|
+
// Import configuration from virtual module
|
|
15
|
+
import { routeGuardConfig } from 'virtual:htlkg-config';
|
|
16
|
+
|
|
17
|
+
// Type assertion for the imported config
|
|
18
|
+
const config = routeGuardConfig as RouteGuardConfig;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Helper: Check if pathname matches any of the provided patterns
|
|
22
|
+
*/
|
|
23
|
+
function matchesPattern(pathname: string, patterns: RoutePattern[]): boolean {
|
|
24
|
+
return patterns.some((pattern) => {
|
|
25
|
+
try {
|
|
26
|
+
if (typeof pattern === 'string') {
|
|
27
|
+
// Special case: "/" only matches exactly
|
|
28
|
+
if (pattern === '/') {
|
|
29
|
+
return pathname === '/';
|
|
30
|
+
}
|
|
31
|
+
// String pattern: exact match or starts with (for sub-routes)
|
|
32
|
+
return pathname === pattern || pathname.startsWith(pattern + '/');
|
|
33
|
+
}
|
|
34
|
+
// RegExp pattern: test against pathname
|
|
35
|
+
return pattern.test(pathname);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
// Log pattern matching errors but don't block the request
|
|
38
|
+
console.error(
|
|
39
|
+
'[htlkg Route Guard] Error matching pattern:',
|
|
40
|
+
error instanceof Error ? error.message : 'Unknown error',
|
|
41
|
+
);
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Route guard middleware - protects routes based on configuration
|
|
49
|
+
*
|
|
50
|
+
* This middleware runs after authMiddleware and enforces access control
|
|
51
|
+
* based on the route configuration provided to the htlkg integration.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* // In astro.config.mjs
|
|
55
|
+
* htlkg({
|
|
56
|
+
* auth: {
|
|
57
|
+
* publicRoutes: ['/login', '/'],
|
|
58
|
+
* adminRoutes: [/^\/admin/],
|
|
59
|
+
* brandRoutes: [
|
|
60
|
+
* { pattern: /^\/brands\/(\d+)/, brandIdParam: 1 }
|
|
61
|
+
* ],
|
|
62
|
+
* loginUrl: '/login'
|
|
63
|
+
* }
|
|
64
|
+
* })
|
|
65
|
+
*/
|
|
66
|
+
export const routeGuard: MiddlewareHandler = async (context, next) => {
|
|
67
|
+
const { locals, url, redirect } = context;
|
|
68
|
+
const pathname = url.pathname;
|
|
69
|
+
|
|
70
|
+
const {
|
|
71
|
+
publicRoutes = [],
|
|
72
|
+
authenticatedRoutes = [],
|
|
73
|
+
adminRoutes = [],
|
|
74
|
+
brandRoutes = [],
|
|
75
|
+
loginUrl = '/login',
|
|
76
|
+
} = config;
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
// Public routes - no auth required
|
|
80
|
+
if (matchesPattern(pathname, publicRoutes)) {
|
|
81
|
+
console.log(`[htlkg Route Guard] Public route: ${pathname}`);
|
|
82
|
+
return next();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const user = locals.user;
|
|
86
|
+
|
|
87
|
+
// Admin routes - require admin role
|
|
88
|
+
if (matchesPattern(pathname, adminRoutes)) {
|
|
89
|
+
if (!user || !user.isAdmin) {
|
|
90
|
+
console.log(
|
|
91
|
+
`[htlkg Route Guard] Admin access denied for ${pathname} - User: ${user ? 'authenticated (non-admin)' : 'not authenticated'}`,
|
|
92
|
+
);
|
|
93
|
+
return redirect(`${loginUrl}?error=admin_required`);
|
|
94
|
+
}
|
|
95
|
+
console.log(
|
|
96
|
+
`[htlkg Route Guard] Admin access granted for ${pathname}`,
|
|
97
|
+
);
|
|
98
|
+
return next();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Brand routes - require brand access or admin
|
|
102
|
+
for (const brandRoute of brandRoutes) {
|
|
103
|
+
try {
|
|
104
|
+
const match = pathname.match(brandRoute.pattern);
|
|
105
|
+
if (match) {
|
|
106
|
+
const brandId = Number.parseInt(match[brandRoute.brandIdParam], 10);
|
|
107
|
+
|
|
108
|
+
if (Number.isNaN(brandId)) {
|
|
109
|
+
console.warn(
|
|
110
|
+
`[htlkg Route Guard] Invalid brandId extracted from ${pathname}`,
|
|
111
|
+
);
|
|
112
|
+
return redirect(`${loginUrl}?error=invalid_brand`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!user || (!user.isAdmin && !user.brandIds.includes(brandId))) {
|
|
116
|
+
console.log(
|
|
117
|
+
`[htlkg Route Guard] Brand access denied for ${pathname} (brandId: ${brandId}) - User: ${user ? `authenticated (brandIds: ${user.brandIds.join(',')})` : 'not authenticated'}`,
|
|
118
|
+
);
|
|
119
|
+
return redirect(`${loginUrl}?error=access_denied`);
|
|
120
|
+
}
|
|
121
|
+
console.log(
|
|
122
|
+
`[htlkg Route Guard] Brand access granted for ${pathname} (brandId: ${brandId})`,
|
|
123
|
+
);
|
|
124
|
+
return next();
|
|
125
|
+
}
|
|
126
|
+
} catch (error) {
|
|
127
|
+
console.error(
|
|
128
|
+
`[htlkg Route Guard] Error processing brand route for ${pathname}:`,
|
|
129
|
+
error instanceof Error ? error.message : 'Unknown error',
|
|
130
|
+
);
|
|
131
|
+
// Fail-safe: deny access on error
|
|
132
|
+
return redirect(`${loginUrl}?error=access_denied`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Authenticated routes - require any logged-in user
|
|
137
|
+
if (matchesPattern(pathname, authenticatedRoutes)) {
|
|
138
|
+
if (!user) {
|
|
139
|
+
const returnUrl = encodeURIComponent(pathname + url.search);
|
|
140
|
+
console.log(
|
|
141
|
+
`[htlkg Route Guard] Authentication required for ${pathname}, redirecting to login`,
|
|
142
|
+
);
|
|
143
|
+
return redirect(`${loginUrl}?redirect=${returnUrl}`);
|
|
144
|
+
}
|
|
145
|
+
console.log(
|
|
146
|
+
`[htlkg Route Guard] Authenticated access granted for ${pathname}`,
|
|
147
|
+
);
|
|
148
|
+
return next();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Default: allow access
|
|
152
|
+
console.log(
|
|
153
|
+
`[htlkg Route Guard] Default access granted for ${pathname}`,
|
|
154
|
+
);
|
|
155
|
+
return next();
|
|
156
|
+
} catch (error) {
|
|
157
|
+
// Catch-all error handler for route guard
|
|
158
|
+
console.error(
|
|
159
|
+
'[htlkg Route Guard] Unexpected error in route guard:',
|
|
160
|
+
error instanceof Error ? error.message : 'Unknown error',
|
|
161
|
+
);
|
|
162
|
+
// Fail-safe: deny access and redirect to login
|
|
163
|
+
return redirect(`${loginUrl}?error=server_error`);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Helper functions for programmatic route protection in pages
|
|
169
|
+
* These can be used in Astro pages for more fine-grained control
|
|
170
|
+
*/
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Require authentication for a page
|
|
174
|
+
* Redirects to login if user is not authenticated
|
|
175
|
+
*/
|
|
176
|
+
export async function requireAuth(context: any, loginUrl = '/login') {
|
|
177
|
+
const user = context.locals.user;
|
|
178
|
+
if (!user) {
|
|
179
|
+
const currentUrl = context.url.pathname + context.url.search;
|
|
180
|
+
const encodedReturnUrl = encodeURIComponent(currentUrl);
|
|
181
|
+
return context.redirect(`${loginUrl}?redirect=${encodedReturnUrl}`);
|
|
182
|
+
}
|
|
183
|
+
return user;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Require admin access for a page
|
|
188
|
+
* Redirects to login if user is not an admin
|
|
189
|
+
*/
|
|
190
|
+
export async function requireAdminAccess(context: any, loginUrl = '/login') {
|
|
191
|
+
const user = context.locals.user;
|
|
192
|
+
if (!user) {
|
|
193
|
+
return context.redirect(`${loginUrl}?error=not_authenticated`);
|
|
194
|
+
}
|
|
195
|
+
if (!user.isAdmin) {
|
|
196
|
+
return context.redirect(`${loginUrl}?error=admin_required`);
|
|
197
|
+
}
|
|
198
|
+
return user;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Require brand access for a page
|
|
203
|
+
* Redirects to login if user doesn't have access to the specified brand
|
|
204
|
+
*/
|
|
205
|
+
export async function requireBrandAccess(
|
|
206
|
+
context: any,
|
|
207
|
+
brandId: number,
|
|
208
|
+
loginUrl = '/login'
|
|
209
|
+
) {
|
|
210
|
+
const user = context.locals.user;
|
|
211
|
+
if (!user) {
|
|
212
|
+
return context.redirect(`${loginUrl}?error=not_authenticated`);
|
|
213
|
+
}
|
|
214
|
+
if (!user.isAdmin && !user.brandIds.includes(brandId)) {
|
|
215
|
+
return context.redirect(`${loginUrl}?error=access_denied`);
|
|
216
|
+
}
|
|
217
|
+
return user;
|
|
218
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Admin Detail Page Pattern
|
|
4
|
+
*
|
|
5
|
+
* Standard pattern for admin detail pages with tabs and content sections.
|
|
6
|
+
* Provides consistent structure for entity detail pages.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```astro
|
|
10
|
+
* <DetailPage
|
|
11
|
+
* title="User Details"
|
|
12
|
+
* description="View and edit user information"
|
|
13
|
+
* user={user}
|
|
14
|
+
* tabs={tabs}
|
|
15
|
+
* activeTab="overview"
|
|
16
|
+
* >
|
|
17
|
+
* <div slot="actions">
|
|
18
|
+
* <button>Edit</button>
|
|
19
|
+
* </div>
|
|
20
|
+
* <div slot="content">
|
|
21
|
+
* <UserOverview />
|
|
22
|
+
* </div>
|
|
23
|
+
* </DetailPage>
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import AdminLayout from "../../layouts/AdminLayout.astro";
|
|
28
|
+
import PageHeader from "../../components/PageHeader.astro";
|
|
29
|
+
import type { AuthUser } from "@htlkg/core/types";
|
|
30
|
+
|
|
31
|
+
interface BreadcrumbItem {
|
|
32
|
+
label: string;
|
|
33
|
+
href?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface Tab {
|
|
37
|
+
id: string;
|
|
38
|
+
label: string;
|
|
39
|
+
href: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Props {
|
|
43
|
+
title: string;
|
|
44
|
+
description?: string;
|
|
45
|
+
user: AuthUser;
|
|
46
|
+
currentPage?: string;
|
|
47
|
+
breadcrumbs?: BreadcrumbItem[];
|
|
48
|
+
tabs?: Tab[];
|
|
49
|
+
activeTab?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const {
|
|
53
|
+
title,
|
|
54
|
+
description,
|
|
55
|
+
user,
|
|
56
|
+
currentPage,
|
|
57
|
+
breadcrumbs = [],
|
|
58
|
+
tabs = [],
|
|
59
|
+
activeTab,
|
|
60
|
+
} = Astro.props;
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
<AdminLayout
|
|
64
|
+
title={title}
|
|
65
|
+
description={description}
|
|
66
|
+
user={user}
|
|
67
|
+
currentPage={currentPage}
|
|
68
|
+
>
|
|
69
|
+
<div slot="actions">
|
|
70
|
+
<slot name="actions" />
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div class="detail-page">
|
|
74
|
+
<PageHeader
|
|
75
|
+
title={title}
|
|
76
|
+
description={description}
|
|
77
|
+
breadcrumbs={breadcrumbs}
|
|
78
|
+
/>
|
|
79
|
+
|
|
80
|
+
{
|
|
81
|
+
tabs.length > 0 && (
|
|
82
|
+
<nav class="detail-tabs">
|
|
83
|
+
{tabs.map((tab) => (
|
|
84
|
+
<a
|
|
85
|
+
href={tab.href}
|
|
86
|
+
class:list={["tab-item", { active: activeTab === tab.id }]}
|
|
87
|
+
>
|
|
88
|
+
{tab.label}
|
|
89
|
+
</a>
|
|
90
|
+
))}
|
|
91
|
+
</nav>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
<div class="detail-content">
|
|
96
|
+
<slot name="content" />
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<div class="detail-sidebar">
|
|
100
|
+
<slot name="sidebar" />
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</AdminLayout>
|
|
104
|
+
|
|
105
|
+
<style>
|
|
106
|
+
.detail-page {
|
|
107
|
+
display: grid;
|
|
108
|
+
grid-template-columns: 1fr;
|
|
109
|
+
gap: 1.5rem;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.detail-tabs {
|
|
113
|
+
display: flex;
|
|
114
|
+
gap: 2rem;
|
|
115
|
+
border-bottom: 1px solid #e5e7eb;
|
|
116
|
+
background: white;
|
|
117
|
+
padding: 0 1.5rem;
|
|
118
|
+
border-radius: 0.5rem 0.5rem 0 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.tab-item {
|
|
122
|
+
padding: 1rem 0;
|
|
123
|
+
color: #6b7280;
|
|
124
|
+
text-decoration: none;
|
|
125
|
+
font-weight: 500;
|
|
126
|
+
border-bottom: 2px solid transparent;
|
|
127
|
+
transition: all 0.2s;
|
|
128
|
+
position: relative;
|
|
129
|
+
top: 1px;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.tab-item:hover {
|
|
133
|
+
color: #111827;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.tab-item.active {
|
|
137
|
+
color: #2563eb;
|
|
138
|
+
border-bottom-color: #2563eb;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.detail-content {
|
|
142
|
+
background: white;
|
|
143
|
+
border-radius: 0.5rem;
|
|
144
|
+
border: 1px solid #e5e7eb;
|
|
145
|
+
padding: 1.5rem;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.detail-sidebar {
|
|
149
|
+
background: white;
|
|
150
|
+
border-radius: 0.5rem;
|
|
151
|
+
border: 1px solid #e5e7eb;
|
|
152
|
+
padding: 1.5rem;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* Two column layout when sidebar has content */
|
|
156
|
+
.detail-page:has(.detail-sidebar:not(:empty)) {
|
|
157
|
+
grid-template-columns: 1fr 20rem;
|
|
158
|
+
grid-template-areas:
|
|
159
|
+
"header header"
|
|
160
|
+
"tabs tabs"
|
|
161
|
+
"content sidebar";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.detail-page:has(.detail-sidebar:not(:empty)) .detail-content {
|
|
165
|
+
grid-area: content;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.detail-page:has(.detail-sidebar:not(:empty)) .detail-sidebar {
|
|
169
|
+
grid-area: sidebar;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* Responsive */
|
|
173
|
+
@media (max-width: 1024px) {
|
|
174
|
+
.detail-page:has(.detail-sidebar:not(:empty)) {
|
|
175
|
+
grid-template-columns: 1fr;
|
|
176
|
+
grid-template-areas:
|
|
177
|
+
"header"
|
|
178
|
+
"tabs"
|
|
179
|
+
"content"
|
|
180
|
+
"sidebar";
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
@media (max-width: 768px) {
|
|
185
|
+
.detail-tabs {
|
|
186
|
+
overflow-x: auto;
|
|
187
|
+
gap: 1rem;
|
|
188
|
+
padding: 0 1rem;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.tab-item {
|
|
192
|
+
white-space: nowrap;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
</style>
|