@htlkg/astro 0.0.2 → 0.0.4

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.
@@ -0,0 +1,87 @@
1
+ # Layouts Module
2
+
3
+ Pre-built page layouts for common page types.
4
+
5
+ ## Installation
6
+
7
+ ```typescript
8
+ import { AdminLayout } from '@htlkg/astro/layouts';
9
+ import { BrandLayout } from '@htlkg/astro/layouts';
10
+ import { AuthLayout } from '@htlkg/astro/layouts';
11
+ import { PublicLayout } from '@htlkg/astro/layouts';
12
+ ```
13
+
14
+ ## AdminLayout
15
+
16
+ Admin portal pages with sidebar navigation.
17
+
18
+ ```astro
19
+ ---
20
+ import { AdminLayout } from '@htlkg/astro/layouts';
21
+
22
+ const user = Astro.locals.user;
23
+ ---
24
+
25
+ <AdminLayout user={user} title="Dashboard">
26
+ <DashboardContent />
27
+ </AdminLayout>
28
+ ```
29
+
30
+ ## BrandLayout
31
+
32
+ Brand-specific pages with brand context.
33
+
34
+ ```astro
35
+ ---
36
+ import { BrandLayout } from '@htlkg/astro/layouts';
37
+
38
+ const { brandId } = Astro.params;
39
+ const brand = await getBrand(brandId);
40
+ ---
41
+
42
+ <BrandLayout brand={brand} title="Brand Settings">
43
+ <BrandSettings />
44
+ </BrandLayout>
45
+ ```
46
+
47
+ ## AuthLayout
48
+
49
+ Authentication pages (login, signup).
50
+
51
+ ```astro
52
+ ---
53
+ import { AuthLayout } from '@htlkg/astro/layouts';
54
+ ---
55
+
56
+ <AuthLayout title="Login">
57
+ <LoginForm />
58
+ </AuthLayout>
59
+ ```
60
+
61
+ ## PublicLayout
62
+
63
+ Public-facing pages without authentication.
64
+
65
+ ```astro
66
+ ---
67
+ import { PublicLayout } from '@htlkg/astro/layouts';
68
+ ---
69
+
70
+ <PublicLayout title="Welcome">
71
+ <LandingPage />
72
+ </PublicLayout>
73
+ ```
74
+
75
+ ## DefaultLayout
76
+
77
+ Base layout with minimal styling.
78
+
79
+ ```astro
80
+ ---
81
+ import { DefaultLayout } from '@htlkg/astro/layouts';
82
+ ---
83
+
84
+ <DefaultLayout title="Page Title">
85
+ <slot />
86
+ </DefaultLayout>
87
+ ```
@@ -0,0 +1,82 @@
1
+ # Middleware Module
2
+
3
+ Authentication middleware and route guards for Astro.
4
+
5
+ ## Installation
6
+
7
+ ```typescript
8
+ import {
9
+ requireAuth,
10
+ requireAdminAccess,
11
+ requireBrandAccess,
12
+ } from '@htlkg/astro/middleware';
13
+ ```
14
+
15
+ ## Route Guards
16
+
17
+ ### requireAuth
18
+
19
+ Require authenticated user. Redirects to login if not authenticated.
20
+
21
+ ```astro
22
+ ---
23
+ import { requireAuth } from '@htlkg/astro/middleware';
24
+
25
+ export const prerender = false;
26
+
27
+ const { user } = await requireAuth(Astro);
28
+ ---
29
+
30
+ <h1>Welcome, {user.username}</h1>
31
+ ```
32
+
33
+ ### requireAdminAccess
34
+
35
+ Require admin role.
36
+
37
+ ```astro
38
+ ---
39
+ import { requireAdminAccess } from '@htlkg/astro/middleware';
40
+
41
+ export const prerender = false;
42
+
43
+ const { user } = await requireAdminAccess(Astro);
44
+ ---
45
+
46
+ <AdminDashboard user={user} />
47
+ ```
48
+
49
+ ### requireBrandAccess
50
+
51
+ Require access to a specific brand.
52
+
53
+ ```astro
54
+ ---
55
+ import { requireBrandAccess } from '@htlkg/astro/middleware';
56
+
57
+ export const prerender = false;
58
+
59
+ const { brandId } = Astro.params;
60
+ const { user, brand } = await requireBrandAccess(Astro, brandId);
61
+ ---
62
+
63
+ <BrandDashboard brand={brand} user={user} />
64
+ ```
65
+
66
+ ## Custom Login URL
67
+
68
+ ```typescript
69
+ await requireAuth(Astro, '/custom-login');
70
+ await requireAdminAccess(Astro, '/admin/login');
71
+ ```
72
+
73
+ ## Route Guard Config
74
+
75
+ ```typescript
76
+ interface RouteGuardConfig {
77
+ publicRoutes: RoutePattern[];
78
+ protectedRoutes: RoutePattern[];
79
+ loginPage: string;
80
+ brandRoutePattern?: RegExp;
81
+ }
82
+ ```
@@ -0,0 +1,104 @@
1
+ # Patterns Module
2
+
3
+ Reusable page patterns for common admin and brand pages.
4
+
5
+ ## Installation
6
+
7
+ ```typescript
8
+ // Admin patterns
9
+ import { ListPage } from '@htlkg/astro/patterns/admin';
10
+ import { DetailPage } from '@htlkg/astro/patterns/admin';
11
+ import { FormPage } from '@htlkg/astro/patterns/admin';
12
+
13
+ // Brand patterns
14
+ import { ConfigPage } from '@htlkg/astro/patterns/brand';
15
+ import { PortalPage } from '@htlkg/astro/patterns/brand';
16
+ ```
17
+
18
+ ## Admin Patterns
19
+
20
+ ### ListPage
21
+
22
+ List/table view with filters and pagination.
23
+
24
+ ```astro
25
+ ---
26
+ import { ListPage } from '@htlkg/astro/patterns/admin';
27
+ ---
28
+
29
+ <ListPage
30
+ title="Brands"
31
+ items={brands}
32
+ columns={columns}
33
+ filters={filters}
34
+ onRowClick={(brand) => `/admin/brands/${brand.id}`}
35
+ />
36
+ ```
37
+
38
+ ### DetailPage
39
+
40
+ Detail view with tabs and actions.
41
+
42
+ ```astro
43
+ ---
44
+ import { DetailPage } from '@htlkg/astro/patterns/admin';
45
+ ---
46
+
47
+ <DetailPage
48
+ title={brand.name}
49
+ tabs={['Overview', 'Settings', 'Users']}
50
+ actions={['Edit', 'Delete']}
51
+ >
52
+ <BrandOverview slot="Overview" brand={brand} />
53
+ <BrandSettings slot="Settings" brand={brand} />
54
+ <BrandUsers slot="Users" brand={brand} />
55
+ </DetailPage>
56
+ ```
57
+
58
+ ### FormPage
59
+
60
+ Form view with validation.
61
+
62
+ ```astro
63
+ ---
64
+ import { FormPage } from '@htlkg/astro/patterns/admin';
65
+ ---
66
+
67
+ <FormPage
68
+ title="Create Brand"
69
+ schema={brandSchema}
70
+ onSubmit={handleSubmit}
71
+ />
72
+ ```
73
+
74
+ ## Brand Patterns
75
+
76
+ ### ConfigPage
77
+
78
+ Configuration page for brand settings.
79
+
80
+ ```astro
81
+ ---
82
+ import { ConfigPage } from '@htlkg/astro/patterns/brand';
83
+ ---
84
+
85
+ <ConfigPage
86
+ brand={brand}
87
+ product={product}
88
+ config={productConfig}
89
+ />
90
+ ```
91
+
92
+ ### PortalPage
93
+
94
+ Public portal page for brand.
95
+
96
+ ```astro
97
+ ---
98
+ import { PortalPage } from '@htlkg/astro/patterns/brand';
99
+ ---
100
+
101
+ <PortalPage brand={brand}>
102
+ <WifiPortal />
103
+ </PortalPage>
104
+ ```
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Filter Building Utilities
3
+ *
4
+ * Utilities for building GraphQL filters from parsed URL parameters,
5
+ * and for applying client-side filters to data.
6
+ */
7
+
8
+ import type { FilterFieldConfig, ListParams } from "./params";
9
+
10
+ /**
11
+ * GraphQL filter operators
12
+ */
13
+ export type GraphQLOperator = "eq" | "ne" | "le" | "lt" | "ge" | "gt" | "contains" | "notContains" | "beginsWith" | "between";
14
+
15
+ /**
16
+ * GraphQL filter condition
17
+ */
18
+ export interface GraphQLFilterCondition {
19
+ [key: string]: {
20
+ [operator in GraphQLOperator]?: any;
21
+ };
22
+ }
23
+
24
+ /**
25
+ * GraphQL combined filter
26
+ */
27
+ export interface GraphQLFilter {
28
+ and?: GraphQLFilterCondition[];
29
+ or?: GraphQLFilterCondition[];
30
+ not?: GraphQLFilter;
31
+ [key: string]: any;
32
+ }
33
+
34
+ /**
35
+ * Build GraphQL filter from parsed URL parameters
36
+ *
37
+ * Only includes fields marked with `graphql: true` in the field config.
38
+ *
39
+ * @example
40
+ * ```typescript
41
+ * const filter = buildGraphQLFilter(
42
+ * { status: 'active', name: 'test' },
43
+ * [
44
+ * { key: 'status', type: 'select', graphql: true },
45
+ * { key: 'name', type: 'text', graphql: true },
46
+ * ]
47
+ * );
48
+ * // Returns: { and: [{ status: { eq: 'active' } }, { name: { contains: 'test' } }] }
49
+ * ```
50
+ */
51
+ export function buildGraphQLFilter(
52
+ filters: Record<string, any>,
53
+ filterableFields: FilterFieldConfig[],
54
+ options: { searchFields?: string[]; search?: string } = {}
55
+ ): GraphQLFilter | undefined {
56
+ const conditions: GraphQLFilterCondition[] = [];
57
+
58
+ // Build field filters
59
+ for (const field of filterableFields) {
60
+ if (!field.graphql) continue;
61
+
62
+ const value = filters[field.key];
63
+ if (value === undefined || value === null || value === "") continue;
64
+
65
+ const condition = buildFieldCondition(field, value);
66
+ if (condition) {
67
+ conditions.push(condition);
68
+ }
69
+ }
70
+
71
+ // Build search filter (applies to multiple fields with OR)
72
+ if (options.search && options.searchFields && options.searchFields.length > 0) {
73
+ const searchConditions = options.searchFields.map((fieldKey) => ({
74
+ [fieldKey]: { contains: options.search },
75
+ }));
76
+
77
+ if (searchConditions.length === 1) {
78
+ conditions.push(searchConditions[0]);
79
+ } else if (searchConditions.length > 1) {
80
+ // Multiple search fields use OR
81
+ conditions.push({ or: searchConditions } as unknown as GraphQLFilterCondition);
82
+ }
83
+ }
84
+
85
+ // Return combined filter
86
+ if (conditions.length === 0) {
87
+ return undefined;
88
+ }
89
+
90
+ if (conditions.length === 1) {
91
+ return conditions[0] as unknown as GraphQLFilter;
92
+ }
93
+
94
+ return { and: conditions };
95
+ }
96
+
97
+ /**
98
+ * Build a single field condition
99
+ */
100
+ function buildFieldCondition(field: FilterFieldConfig, value: any): GraphQLFilterCondition | undefined {
101
+ // Use custom operator if provided
102
+ const operator = field.graphqlOperator;
103
+
104
+ if (operator) {
105
+ // Use the custom operator specified in config
106
+ return { [field.key]: { [operator]: value } };
107
+ }
108
+
109
+ // Default behavior based on field type
110
+ switch (field.type) {
111
+ case "text":
112
+ // Text fields use contains by default (case-sensitive in DynamoDB)
113
+ return { [field.key]: { contains: String(value) } };
114
+
115
+ case "number":
116
+ // Number fields use exact match
117
+ return { [field.key]: { eq: Number(value) } };
118
+
119
+ case "boolean":
120
+ // Boolean fields use exact match
121
+ return { [field.key]: { eq: Boolean(value) } };
122
+
123
+ case "date":
124
+ // Date fields - could be exact or range
125
+ if (value instanceof Date) {
126
+ return { [field.key]: { eq: value.toISOString() } };
127
+ }
128
+ return { [field.key]: { eq: value } };
129
+
130
+ case "select":
131
+ // Select fields use exact match
132
+ return { [field.key]: { eq: value } };
133
+
134
+ default:
135
+ return { [field.key]: { eq: value } };
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Apply client-side filters to data array
141
+ *
142
+ * Only applies filters for fields NOT marked with `graphql: true`.
143
+ *
144
+ * @example
145
+ * ```typescript
146
+ * const filtered = applyClientFilters(
147
+ * items,
148
+ * { brandCount: 5 },
149
+ * [{ key: 'brandCount', type: 'number', graphql: false }]
150
+ * );
151
+ * ```
152
+ */
153
+ export function applyClientFilters<T extends Record<string, any>>(
154
+ items: T[],
155
+ filters: Record<string, any>,
156
+ filterableFields: FilterFieldConfig[],
157
+ options: { search?: string; searchFields?: string[] } = {}
158
+ ): T[] {
159
+ let result = items;
160
+
161
+ // Apply field filters (only non-GraphQL fields)
162
+ for (const field of filterableFields) {
163
+ if (field.graphql) continue; // Skip GraphQL fields
164
+
165
+ const value = filters[field.key];
166
+ if (value === undefined || value === null || value === "") continue;
167
+
168
+ result = result.filter((item) => matchesFieldFilter(item, field, value));
169
+ }
170
+
171
+ // Apply search filter (only if not applied via GraphQL)
172
+ if (options.search && options.searchFields) {
173
+ const searchLower = options.search.toLowerCase();
174
+ result = result.filter((item) =>
175
+ options.searchFields!.some((fieldKey) => {
176
+ const fieldValue = item[fieldKey];
177
+ if (fieldValue === null || fieldValue === undefined) return false;
178
+ return String(fieldValue).toLowerCase().includes(searchLower);
179
+ })
180
+ );
181
+ }
182
+
183
+ return result;
184
+ }
185
+
186
+ /**
187
+ * Check if an item matches a field filter
188
+ */
189
+ function matchesFieldFilter<T extends Record<string, any>>(item: T, field: FilterFieldConfig, filterValue: any): boolean {
190
+ const itemValue = item[field.key];
191
+
192
+ switch (field.type) {
193
+ case "text":
194
+ if (itemValue === null || itemValue === undefined) return false;
195
+ return String(itemValue).toLowerCase().includes(String(filterValue).toLowerCase());
196
+
197
+ case "number":
198
+ return Number(itemValue) === Number(filterValue);
199
+
200
+ case "boolean":
201
+ return Boolean(itemValue) === Boolean(filterValue);
202
+
203
+ case "date":
204
+ if (!itemValue) return false;
205
+ const itemDate = itemValue instanceof Date ? itemValue : new Date(itemValue);
206
+ const filterDate = filterValue instanceof Date ? filterValue : new Date(filterValue);
207
+ // Compare dates (ignoring time)
208
+ return itemDate.toDateString() === filterDate.toDateString();
209
+
210
+ case "select":
211
+ return itemValue === filterValue;
212
+
213
+ default:
214
+ return itemValue === filterValue;
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Sort items by a key
220
+ */
221
+ export function sortItems<T extends Record<string, any>>(items: T[], sortKey: string, sortOrder: "asc" | "desc"): T[] {
222
+ if (!sortKey) return items;
223
+
224
+ const multiplier = sortOrder === "asc" ? 1 : -1;
225
+
226
+ return [...items].sort((a, b) => {
227
+ const aVal = a[sortKey];
228
+ const bVal = b[sortKey];
229
+
230
+ // Handle null/undefined
231
+ if (aVal === null || aVal === undefined) return 1;
232
+ if (bVal === null || bVal === undefined) return -1;
233
+ if (aVal === bVal) return 0;
234
+
235
+ // Handle different types
236
+ if (typeof aVal === "string" && typeof bVal === "string") {
237
+ return aVal.localeCompare(bVal) * multiplier;
238
+ }
239
+
240
+ if (typeof aVal === "number" && typeof bVal === "number") {
241
+ return (aVal - bVal) * multiplier;
242
+ }
243
+
244
+ if (aVal instanceof Date && bVal instanceof Date) {
245
+ return (aVal.getTime() - bVal.getTime()) * multiplier;
246
+ }
247
+
248
+ // Fallback to string comparison
249
+ return String(aVal).localeCompare(String(bVal)) * multiplier;
250
+ });
251
+ }
252
+
253
+ /**
254
+ * Paginate items
255
+ */
256
+ export function paginateItems<T>(
257
+ items: T[],
258
+ page: number,
259
+ pageSize: number
260
+ ): {
261
+ paginatedItems: T[];
262
+ totalItems: number;
263
+ totalPages: number;
264
+ currentPage: number;
265
+ pageSize: number;
266
+ } {
267
+ const totalItems = items.length;
268
+ const totalPages = Math.ceil(totalItems / pageSize);
269
+ const currentPage = Math.min(Math.max(1, page), totalPages || 1);
270
+
271
+ const startIndex = (currentPage - 1) * pageSize;
272
+ const endIndex = startIndex + pageSize;
273
+ const paginatedItems = items.slice(startIndex, endIndex);
274
+
275
+ return {
276
+ paginatedItems,
277
+ totalItems,
278
+ totalPages,
279
+ currentPage,
280
+ pageSize,
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Process list data with filters, sorting, and pagination
286
+ *
287
+ * Convenience function that combines all processing steps.
288
+ */
289
+ export function processListData<T extends Record<string, any>>(
290
+ items: T[],
291
+ params: ListParams,
292
+ filterableFields: FilterFieldConfig[],
293
+ options: { searchFields?: string[] } = {}
294
+ ): {
295
+ items: T[];
296
+ totalItems: number;
297
+ totalPages: number;
298
+ currentPage: number;
299
+ pageSize: number;
300
+ } {
301
+ // Apply client-side filters
302
+ let result = applyClientFilters(items, params.filters, filterableFields, {
303
+ search: params.search,
304
+ searchFields: options.searchFields,
305
+ });
306
+
307
+ // Sort
308
+ result = sortItems(result, params.sortKey, params.sortOrder);
309
+
310
+ // Paginate
311
+ const paginated = paginateItems(result, params.page, params.pageSize);
312
+
313
+ return {
314
+ items: paginated.paginatedItems,
315
+ totalItems: paginated.totalItems,
316
+ totalPages: paginated.totalPages,
317
+ currentPage: paginated.currentPage,
318
+ pageSize: paginated.pageSize,
319
+ };
320
+ }
@@ -1,9 +1,15 @@
1
1
  /**
2
- * @htlkg/pages - Utilities
3
- *
2
+ * @htlkg/astro - Utilities
3
+ *
4
4
  * Helper functions for Astro pages.
5
5
  */
6
6
 
7
7
  export * from './ssr';
8
8
  export * from './static';
9
9
  export * from './hydration';
10
+
11
+ // URL parameter parsing and building
12
+ export * from './params';
13
+
14
+ // Filter building and processing
15
+ export * from './filters';