@gblikas/querykit 0.0.0 → 0.1.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/.github/workflows/publish.yml +5 -7
- package/README.md +76 -0
- package/dist/security/types.d.ts +48 -0
- package/dist/security/types.js +2 -0
- package/dist/security/validator.d.ts +35 -0
- package/dist/security/validator.js +108 -0
- package/examples/qk-next/app/globals.css +23 -0
- package/examples/qk-next/app/hooks/use-viewport-info.ts +89 -0
- package/examples/qk-next/app/layout.tsx +26 -7
- package/examples/qk-next/app/page.tsx +423 -121
- package/examples/qk-next/lib/utils.ts +74 -0
- package/examples/qk-next/package.json +5 -3
- package/examples/qk-next/pnpm-lock.yaml +112 -47
- package/package.json +5 -1
- package/src/security/types.ts +52 -0
- package/src/security/validator.test.ts +368 -0
- package/src/security/validator.ts +117 -0
|
@@ -18,18 +18,18 @@ jobs:
|
|
|
18
18
|
runs-on: ubuntu-latest
|
|
19
19
|
permissions:
|
|
20
20
|
contents: read
|
|
21
|
+
id-token: write # Required for NPM Trusted Publishers (OIDC)
|
|
21
22
|
steps:
|
|
22
23
|
- name: Checkout code
|
|
23
24
|
uses: actions/checkout@v4
|
|
24
25
|
with:
|
|
25
|
-
ref: ${{ github.event.release.tag_name }}
|
|
26
|
+
ref: ${{ github.event.release.tag_name || inputs.tag }}
|
|
26
27
|
|
|
27
28
|
- name: Setup Node.js
|
|
28
29
|
uses: actions/setup-node@v4
|
|
29
30
|
with:
|
|
30
|
-
node-version: '
|
|
31
|
+
node-version: '24'
|
|
31
32
|
registry-url: 'https://registry.npmjs.org'
|
|
32
|
-
always-auth: true
|
|
33
33
|
|
|
34
34
|
- name: Setup pnpm
|
|
35
35
|
uses: pnpm/action-setup@v3
|
|
@@ -53,9 +53,7 @@ jobs:
|
|
|
53
53
|
TAG: ${{ github.event.release.tag_name || inputs.tag }}
|
|
54
54
|
run: node -e 'const v=require("./package.json").version; const tag=(process.env.TAG||"").replace(/^v/,""); if(v!==tag){console.error("package.json version "+v+" does not match tag "+tag); process.exit(1)} else {console.log("Version matches tag:", v)}'
|
|
55
55
|
|
|
56
|
-
- name: Publish package to npmjs
|
|
57
|
-
|
|
58
|
-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
59
|
-
run: pnpm publish --no-git-checks --access public
|
|
56
|
+
- name: Publish package to npmjs with provenance
|
|
57
|
+
run: pnpm publish --no-git-checks --access public --provenance
|
|
60
58
|
|
|
61
59
|
|
package/README.md
CHANGED
|
@@ -124,6 +124,16 @@ const qk = createQueryKit({
|
|
|
124
124
|
allowedFields: ['name', 'email', 'priority', 'status'], // Only these fields can be queried
|
|
125
125
|
denyFields: ['password', 'secretKey'], // These fields can never be queried
|
|
126
126
|
|
|
127
|
+
// Value restrictions - deny specific values for fields
|
|
128
|
+
denyValues: {
|
|
129
|
+
status: ['deleted', 'banned'], // Block queries for deleted/banned records
|
|
130
|
+
role: ['superadmin', 'system'], // Prevent querying privileged roles
|
|
131
|
+
'user.type': ['internal', 'bot'] // Supports dot-notation for nested fields
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
// Field name restrictions
|
|
135
|
+
allowDotNotation: true, // Set to false to block "table.field" or "json.path" queries
|
|
136
|
+
|
|
127
137
|
// Query complexity limits
|
|
128
138
|
maxQueryDepth: 5, // Maximum nesting level of expressions
|
|
129
139
|
maxClauseCount: 20, // Maximum number of clauses (AND/OR operations)
|
|
@@ -150,6 +160,8 @@ const DEFAULT_SECURITY = {
|
|
|
150
160
|
// Field restrictions - by default, all schema fields are allowed
|
|
151
161
|
allowedFields: [], // Empty means "use schema fields"
|
|
152
162
|
denyFields: [], // Empty means no denied fields
|
|
163
|
+
denyValues: {}, // Empty means no denied values for any field
|
|
164
|
+
allowDotNotation: true, // Allow "table.field" and "json.path" notation
|
|
153
165
|
|
|
154
166
|
// Query complexity limits
|
|
155
167
|
maxQueryDepth: 10, // Maximum nesting level of expressions
|
|
@@ -174,6 +186,10 @@ Security configurations can be stored in a separate file and imported:
|
|
|
174
186
|
// security-config.json
|
|
175
187
|
{
|
|
176
188
|
"allowedFields": ["name", "email", "priority", "status"],
|
|
189
|
+
"denyValues": {
|
|
190
|
+
"status": ["deleted", "banned"],
|
|
191
|
+
"role": ["superadmin", "system"]
|
|
192
|
+
},
|
|
177
193
|
"maxQueryDepth": 5,
|
|
178
194
|
"maxClauseCount": 20,
|
|
179
195
|
"defaultLimit": 100
|
|
@@ -199,6 +215,66 @@ When using QueryKit in production, consider these additional security practices:
|
|
|
199
215
|
4. **Field-Level Access Control**: Use dynamic allowedFields based on user roles/permissions.
|
|
200
216
|
5. **Separate Query Context**: Consider separate QueryKit instances with different security settings for different contexts (admin vs. user).
|
|
201
217
|
|
|
218
|
+
### Controlling Dot Notation in Field Names
|
|
219
|
+
|
|
220
|
+
QueryKit supports dot notation in field names (e.g., `user.name`, `metadata.tags`) which is useful for:
|
|
221
|
+
|
|
222
|
+
- **Table-qualified columns**: When joining tables with overlapping column names (`users.id` vs `orders.id`)
|
|
223
|
+
- **JSON/JSONB fields**: Querying nested data in PostgreSQL JSON columns (`metadata.dimensions.width`)
|
|
224
|
+
- **Related data**: Accessing data through ORM relations (`order.customer.name`)
|
|
225
|
+
|
|
226
|
+
However, you may want to **disable dot notation** for public-facing APIs:
|
|
227
|
+
|
|
228
|
+
```typescript
|
|
229
|
+
const qk = createQueryKit({
|
|
230
|
+
adapter: drizzleAdapter,
|
|
231
|
+
schema: { products },
|
|
232
|
+
security: {
|
|
233
|
+
allowDotNotation: false, // Reject queries like "user.password" or "config.secret"
|
|
234
|
+
allowedFields: ['name', 'price', 'category', 'inStock']
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ✅ Allowed: Simple field names
|
|
239
|
+
qk.query('products').where('name:"Widget" AND price:<100');
|
|
240
|
+
|
|
241
|
+
// ❌ Rejected: Dot notation
|
|
242
|
+
qk.query('products').where('user.password:"secret"');
|
|
243
|
+
// Error: Dot notation is not allowed in field names. Found "user.password" - use a simple field name without dots instead.
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
**When to disable dot notation:**
|
|
247
|
+
|
|
248
|
+
| Scenario | Recommendation |
|
|
249
|
+
|----------|---------------|
|
|
250
|
+
| Public search API | Disable - prevents probing internal table structures |
|
|
251
|
+
| Admin dashboard | Enable - admins may need cross-table queries |
|
|
252
|
+
| Simple flat schema | Disable - simplifies security model |
|
|
253
|
+
| JSON/JSONB columns | Enable - needed for nested data access |
|
|
254
|
+
| Multi-tenant app | Disable - prevents `tenant.secret` style access |
|
|
255
|
+
|
|
256
|
+
**Concrete example - Public e-commerce search:**
|
|
257
|
+
|
|
258
|
+
```typescript
|
|
259
|
+
// For a public product search endpoint, disable dot notation
|
|
260
|
+
// to prevent users from attempting queries like:
|
|
261
|
+
// - "orders.creditCard" (accessing other tables)
|
|
262
|
+
// - "internal.costPrice" (accessing internal JSON fields)
|
|
263
|
+
// - "admin.notes" (accessing admin-only data)
|
|
264
|
+
|
|
265
|
+
const publicSearchKit = createQueryKit({
|
|
266
|
+
adapter: drizzleAdapter,
|
|
267
|
+
schema: { products },
|
|
268
|
+
security: {
|
|
269
|
+
allowDotNotation: false,
|
|
270
|
+
allowedFields: ['name', 'description', 'price', 'category'],
|
|
271
|
+
denyValues: {
|
|
272
|
+
category: ['internal', 'discontinued']
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
```
|
|
277
|
+
|
|
202
278
|
## Roadmap
|
|
203
279
|
|
|
204
280
|
### Core Parsing Engine and DSL
|
package/dist/security/types.d.ts
CHANGED
|
@@ -54,6 +54,54 @@ export interface ISecurityOptions {
|
|
|
54
54
|
* ```
|
|
55
55
|
*/
|
|
56
56
|
denyFields?: string[];
|
|
57
|
+
/**
|
|
58
|
+
* Map of field names to arrays of values that are denied for that field.
|
|
59
|
+
* This provides granular control over what values can be used in queries.
|
|
60
|
+
* Use this to protect against queries targeting specific sensitive values.
|
|
61
|
+
*
|
|
62
|
+
* The keys are field names (can include table prefixes like "user.role")
|
|
63
|
+
* and the values are arrays of denied values for that field.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```typescript
|
|
67
|
+
* // Prevent certain values from being queried
|
|
68
|
+
* denyValues: {
|
|
69
|
+
* 'status': ['deleted', 'banned'],
|
|
70
|
+
* 'role': ['superadmin', 'system'],
|
|
71
|
+
* 'user.type': ['internal', 'bot']
|
|
72
|
+
* }
|
|
73
|
+
*
|
|
74
|
+
* // This would block queries like:
|
|
75
|
+
* // status == "deleted"
|
|
76
|
+
* // role IN ["superadmin", "admin"]
|
|
77
|
+
* // user.type == "internal"
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
denyValues?: Record<string, Array<string | number | boolean | null>>;
|
|
81
|
+
/**
|
|
82
|
+
* Whether to allow dot notation in field names (e.g., "user.name", "metadata.tags").
|
|
83
|
+
* When disabled, queries with dots in field names will be rejected.
|
|
84
|
+
*
|
|
85
|
+
* Use cases for DISABLING dot notation:
|
|
86
|
+
* - Public-facing search APIs where users should only query flat, top-level fields
|
|
87
|
+
* - Preventing access to table-qualified columns in SQL joins (e.g., "users.password")
|
|
88
|
+
* - Simpler security model when your schema doesn't have nested/JSON data
|
|
89
|
+
* - Preventing users from probing internal table structures
|
|
90
|
+
*
|
|
91
|
+
* @default true
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```typescript
|
|
95
|
+
* // Disable dot notation for a public search API
|
|
96
|
+
* allowDotNotation: false
|
|
97
|
+
*
|
|
98
|
+
* // This would block queries like:
|
|
99
|
+
* // user.email == "test@example.com" // Rejected
|
|
100
|
+
* // metadata.tags == "sale" // Rejected
|
|
101
|
+
* // email == "test@example.com" // Allowed
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
allowDotNotation?: boolean;
|
|
57
105
|
/**
|
|
58
106
|
* Maximum nesting depth of query expressions.
|
|
59
107
|
* Prevents deeply nested queries that could impact performance.
|
package/dist/security/types.js
CHANGED
|
@@ -29,6 +29,8 @@ exports.DEFAULT_SECURITY_OPTIONS = {
|
|
|
29
29
|
// Field restrictions - by default, all schema fields are allowed
|
|
30
30
|
allowedFields: [], // Empty means "use schema fields"
|
|
31
31
|
denyFields: [], // Empty means no denied fields
|
|
32
|
+
denyValues: {}, // Empty means no denied values for any field
|
|
33
|
+
allowDotNotation: true, // Allow dot notation by default for backward compatibility
|
|
32
34
|
// Query complexity limits
|
|
33
35
|
maxQueryDepth: 10, // Maximum nesting level of expressions
|
|
34
36
|
maxClauseCount: 50, // Maximum number of clauses (AND/OR operations)
|
|
@@ -141,6 +141,41 @@ export declare class QuerySecurityValidator {
|
|
|
141
141
|
* @param schema - Optional schema definition to validate fields against
|
|
142
142
|
*/
|
|
143
143
|
private validateFields;
|
|
144
|
+
/**
|
|
145
|
+
* Validate that field names do not contain dot notation
|
|
146
|
+
*
|
|
147
|
+
* When allowDotNotation is disabled, this method ensures no field names
|
|
148
|
+
* contain dots, which could be used for:
|
|
149
|
+
* - Table-qualified column access (e.g., "users.password")
|
|
150
|
+
* - Nested JSON/JSONB field access (e.g., "metadata.secret")
|
|
151
|
+
* - Probing internal table structures
|
|
152
|
+
*
|
|
153
|
+
* @private
|
|
154
|
+
* @param expression - The query expression to validate
|
|
155
|
+
* @throws {QuerySecurityError} If a field name contains dot notation
|
|
156
|
+
*/
|
|
157
|
+
private validateNoDotNotation;
|
|
158
|
+
/**
|
|
159
|
+
* Validate that query values are not in the denied values list for their field
|
|
160
|
+
*
|
|
161
|
+
* This method checks each comparison expression to ensure the value being
|
|
162
|
+
* queried is not in the denyValues list for that field. This provides
|
|
163
|
+
* granular control over what values can be queried for specific fields.
|
|
164
|
+
*
|
|
165
|
+
* @private
|
|
166
|
+
* @param expression - The query expression to validate
|
|
167
|
+
* @throws {QuerySecurityError} If a denied value is found in the query
|
|
168
|
+
*/
|
|
169
|
+
private validateDenyValues;
|
|
170
|
+
/**
|
|
171
|
+
* Check if a value is in the denied values list
|
|
172
|
+
*
|
|
173
|
+
* @private
|
|
174
|
+
* @param value - The value to check
|
|
175
|
+
* @param deniedValues - The list of denied values
|
|
176
|
+
* @returns true if the value is denied, false otherwise
|
|
177
|
+
*/
|
|
178
|
+
private isValueDenied;
|
|
144
179
|
/**
|
|
145
180
|
* Validate that query depth does not exceed the maximum
|
|
146
181
|
*
|
|
@@ -143,8 +143,14 @@ class QuerySecurityValidator {
|
|
|
143
143
|
* ```
|
|
144
144
|
*/
|
|
145
145
|
validate(expression, schema) {
|
|
146
|
+
// Check for dot notation if disabled
|
|
147
|
+
if (!this.options.allowDotNotation) {
|
|
148
|
+
this.validateNoDotNotation(expression);
|
|
149
|
+
}
|
|
146
150
|
// Check for field restrictions if specified
|
|
147
151
|
this.validateFields(expression, schema);
|
|
152
|
+
// Check for denied values if specified
|
|
153
|
+
this.validateDenyValues(expression);
|
|
148
154
|
// Check query complexity
|
|
149
155
|
this.validateQueryDepth(expression, 0);
|
|
150
156
|
this.validateClauseCount(expression);
|
|
@@ -192,6 +198,108 @@ class QuerySecurityValidator {
|
|
|
192
198
|
}
|
|
193
199
|
}
|
|
194
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* Validate that field names do not contain dot notation
|
|
203
|
+
*
|
|
204
|
+
* When allowDotNotation is disabled, this method ensures no field names
|
|
205
|
+
* contain dots, which could be used for:
|
|
206
|
+
* - Table-qualified column access (e.g., "users.password")
|
|
207
|
+
* - Nested JSON/JSONB field access (e.g., "metadata.secret")
|
|
208
|
+
* - Probing internal table structures
|
|
209
|
+
*
|
|
210
|
+
* @private
|
|
211
|
+
* @param expression - The query expression to validate
|
|
212
|
+
* @throws {QuerySecurityError} If a field name contains dot notation
|
|
213
|
+
*/
|
|
214
|
+
validateNoDotNotation(expression) {
|
|
215
|
+
if (expression.type === 'comparison') {
|
|
216
|
+
const { field } = expression;
|
|
217
|
+
if (field.includes('.')) {
|
|
218
|
+
throw new QuerySecurityError(`Dot notation is not allowed in field names. ` +
|
|
219
|
+
`Found "${field}" - use a simple field name without dots instead.`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
// Recursively validate logical expressions
|
|
224
|
+
this.validateNoDotNotation(expression.left);
|
|
225
|
+
if (expression.right) {
|
|
226
|
+
this.validateNoDotNotation(expression.right);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Validate that query values are not in the denied values list for their field
|
|
232
|
+
*
|
|
233
|
+
* This method checks each comparison expression to ensure the value being
|
|
234
|
+
* queried is not in the denyValues list for that field. This provides
|
|
235
|
+
* granular control over what values can be queried for specific fields.
|
|
236
|
+
*
|
|
237
|
+
* @private
|
|
238
|
+
* @param expression - The query expression to validate
|
|
239
|
+
* @throws {QuerySecurityError} If a denied value is found in the query
|
|
240
|
+
*/
|
|
241
|
+
validateDenyValues(expression) {
|
|
242
|
+
// Skip if no denyValues configured
|
|
243
|
+
if (Object.keys(this.options.denyValues).length === 0) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (expression.type === 'comparison') {
|
|
247
|
+
const { field, value } = expression;
|
|
248
|
+
const deniedValues = this.options.denyValues[field];
|
|
249
|
+
if (deniedValues && deniedValues.length > 0) {
|
|
250
|
+
// Check if the value is an array (for IN/NOT IN operators)
|
|
251
|
+
if (Array.isArray(value)) {
|
|
252
|
+
for (const item of value) {
|
|
253
|
+
if (this.isValueDenied(item, deniedValues)) {
|
|
254
|
+
throw new QuerySecurityError('Invalid query parameters');
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
// Single value comparison
|
|
260
|
+
if (this.isValueDenied(value, deniedValues)) {
|
|
261
|
+
throw new QuerySecurityError('Invalid query parameters');
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
// Recursively validate logical expressions
|
|
268
|
+
this.validateDenyValues(expression.left);
|
|
269
|
+
if (expression.right) {
|
|
270
|
+
this.validateDenyValues(expression.right);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Check if a value is in the denied values list
|
|
276
|
+
*
|
|
277
|
+
* @private
|
|
278
|
+
* @param value - The value to check
|
|
279
|
+
* @param deniedValues - The list of denied values
|
|
280
|
+
* @returns true if the value is denied, false otherwise
|
|
281
|
+
*/
|
|
282
|
+
isValueDenied(value, deniedValues) {
|
|
283
|
+
// Use strict equality to match values, handling type coercion properly
|
|
284
|
+
return deniedValues.some(deniedValue => {
|
|
285
|
+
// Handle null comparison explicitly
|
|
286
|
+
if (value === null && deniedValue === null) {
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
// Handle same-type comparison with strict equality
|
|
290
|
+
if (typeof value === typeof deniedValue) {
|
|
291
|
+
return value === deniedValue;
|
|
292
|
+
}
|
|
293
|
+
// Handle string/number comparison (common case)
|
|
294
|
+
if (typeof value === 'string' && typeof deniedValue === 'number') {
|
|
295
|
+
return value === String(deniedValue);
|
|
296
|
+
}
|
|
297
|
+
if (typeof value === 'number' && typeof deniedValue === 'string') {
|
|
298
|
+
return String(value) === deniedValue;
|
|
299
|
+
}
|
|
300
|
+
return false;
|
|
301
|
+
});
|
|
302
|
+
}
|
|
195
303
|
/**
|
|
196
304
|
* Validate that query depth does not exceed the maximum
|
|
197
305
|
*
|
|
@@ -116,7 +116,30 @@
|
|
|
116
116
|
* {
|
|
117
117
|
@apply border-border outline-ring/50;
|
|
118
118
|
}
|
|
119
|
+
|
|
119
120
|
body {
|
|
120
121
|
@apply bg-background text-foreground;
|
|
122
|
+
overscroll-behavior: none;
|
|
123
|
+
overflow: hidden;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
@layer components {
|
|
128
|
+
.quick-start-snippet {
|
|
129
|
+
@apply !m-0 !bg-transparent whitespace-pre-wrap break-words px-3 py-3 pr-14 text-xs leading-[1.4] font-mono sm:text-sm;
|
|
121
130
|
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* Prevent mobile zoom on inputs by ensuring font-size >= 16px */
|
|
134
|
+
input,
|
|
135
|
+
textarea,
|
|
136
|
+
select {
|
|
137
|
+
font-size: 16px;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* Ensure the root takes full viewport and no scrollbars appear */
|
|
141
|
+
html,
|
|
142
|
+
body,
|
|
143
|
+
#__next {
|
|
144
|
+
height: 100%;
|
|
122
145
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
export interface IViewportInfo {
|
|
6
|
+
innerWidth: number;
|
|
7
|
+
innerHeight: number;
|
|
8
|
+
shortViewportHeightPx: number;
|
|
9
|
+
isLessThanHeight: (px: number) => boolean;
|
|
10
|
+
shortSidePx: number;
|
|
11
|
+
isShortSideLessThan: (px: number) => boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Returns robust viewport dimensions using small viewport units (svh/svw) fallbacks.
|
|
16
|
+
* Ensures height reflects the visual viewport on mobile, avoiding URL bar issues.
|
|
17
|
+
*/
|
|
18
|
+
export function useViewportInfo(): IViewportInfo {
|
|
19
|
+
const readInnerHeight = (): number => {
|
|
20
|
+
// Prefer visualViewport when available to avoid browser UI chrome affecting measurements
|
|
21
|
+
const visual = window.visualViewport;
|
|
22
|
+
if (visual && typeof visual.height === 'number')
|
|
23
|
+
return Math.round(visual.height);
|
|
24
|
+
// Fallbacks in order of reliability
|
|
25
|
+
return Math.round(
|
|
26
|
+
window.innerHeight || document.documentElement.clientHeight
|
|
27
|
+
);
|
|
28
|
+
};
|
|
29
|
+
const readInnerWidth = (): number => {
|
|
30
|
+
const visual = window.visualViewport;
|
|
31
|
+
if (visual && typeof visual.width === 'number')
|
|
32
|
+
return Math.round(visual.width);
|
|
33
|
+
return Math.round(
|
|
34
|
+
window.innerWidth || document.documentElement.clientWidth
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const [state, setState] = useState<IViewportInfo>((): IViewportInfo => {
|
|
39
|
+
const w =
|
|
40
|
+
typeof window !== 'undefined'
|
|
41
|
+
? typeof window.visualViewport?.width === 'number'
|
|
42
|
+
? Math.round(window.visualViewport.width)
|
|
43
|
+
: window.innerWidth
|
|
44
|
+
: 0;
|
|
45
|
+
const h = typeof window !== 'undefined' ? readInnerHeight() : 0;
|
|
46
|
+
const shortSide = Math.min(w, h);
|
|
47
|
+
return {
|
|
48
|
+
innerWidth: w,
|
|
49
|
+
innerHeight: h,
|
|
50
|
+
shortViewportHeightPx: h,
|
|
51
|
+
isLessThanHeight: (px: number) => h < px,
|
|
52
|
+
shortSidePx: shortSide,
|
|
53
|
+
isShortSideLessThan: (px: number) => shortSide < px
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
let frame = 0;
|
|
59
|
+
const measure = (): void => {
|
|
60
|
+
const w = readInnerWidth();
|
|
61
|
+
const h = readInnerHeight();
|
|
62
|
+
const shortSide = Math.min(w, h);
|
|
63
|
+
setState({
|
|
64
|
+
innerWidth: w,
|
|
65
|
+
innerHeight: h,
|
|
66
|
+
shortViewportHeightPx: h,
|
|
67
|
+
isLessThanHeight: (px: number) => h < px,
|
|
68
|
+
shortSidePx: shortSide,
|
|
69
|
+
isShortSideLessThan: (px: number) => shortSide < px
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
frame = requestAnimationFrame(measure);
|
|
73
|
+
const onResize = (): void => {
|
|
74
|
+
cancelAnimationFrame(frame);
|
|
75
|
+
frame = requestAnimationFrame(measure);
|
|
76
|
+
};
|
|
77
|
+
window.addEventListener('resize', onResize);
|
|
78
|
+
window.visualViewport?.addEventListener('resize', onResize);
|
|
79
|
+
window.visualViewport?.addEventListener('scroll', onResize);
|
|
80
|
+
return (): void => {
|
|
81
|
+
cancelAnimationFrame(frame);
|
|
82
|
+
window.removeEventListener('resize', onResize);
|
|
83
|
+
window.visualViewport?.removeEventListener('resize', onResize);
|
|
84
|
+
window.visualViewport?.removeEventListener('scroll', onResize);
|
|
85
|
+
};
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
return state;
|
|
89
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Metadata } from 'next';
|
|
1
|
+
import type { Metadata, Viewport } from 'next';
|
|
2
2
|
import { Geist, Geist_Mono } from 'next/font/google';
|
|
3
3
|
import './globals.css';
|
|
4
4
|
import { Toaster } from '@/components/ui/sonner';
|
|
@@ -7,6 +7,8 @@ import { ThemeProvider } from '@/components/theme-provider';
|
|
|
7
7
|
import { GitHubStars } from '@/components/github-stars';
|
|
8
8
|
import AuroraBackground from '@/components/aurora-background';
|
|
9
9
|
import { JSX } from 'react';
|
|
10
|
+
import { Analytics } from '@vercel/analytics/next';
|
|
11
|
+
import { SpeedInsights } from '@vercel/speed-insights/next';
|
|
10
12
|
|
|
11
13
|
const geistSans = Geist({
|
|
12
14
|
variable: '--font-geist-sans',
|
|
@@ -19,9 +21,7 @@ const geistMono = Geist_Mono({
|
|
|
19
21
|
});
|
|
20
22
|
|
|
21
23
|
export const metadata: Metadata = {
|
|
22
|
-
metadataBase: new URL(
|
|
23
|
-
process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'
|
|
24
|
-
),
|
|
24
|
+
metadataBase: new URL('https://www.querykit.dev/'),
|
|
25
25
|
title: {
|
|
26
26
|
default: 'QueryKit · Next.js Demo',
|
|
27
27
|
template: '%s · QueryKit'
|
|
@@ -37,7 +37,12 @@ export const metadata: Metadata = {
|
|
|
37
37
|
'drizzle orm',
|
|
38
38
|
'pglite',
|
|
39
39
|
'typescript',
|
|
40
|
-
'next.js'
|
|
40
|
+
'next.js',
|
|
41
|
+
'drizzle',
|
|
42
|
+
'sql',
|
|
43
|
+
'npm',
|
|
44
|
+
'npm-package',
|
|
45
|
+
'nextjs'
|
|
41
46
|
],
|
|
42
47
|
authors: [{ name: 'gblikas', url: 'https://github.com/gblikas' }],
|
|
43
48
|
creator: 'QueryKit',
|
|
@@ -79,15 +84,27 @@ export const metadata: Metadata = {
|
|
|
79
84
|
]
|
|
80
85
|
};
|
|
81
86
|
|
|
87
|
+
export const viewport: Viewport = {
|
|
88
|
+
width: 'device-width',
|
|
89
|
+
initialScale: 1,
|
|
90
|
+
maximumScale: 1,
|
|
91
|
+
userScalable: false,
|
|
92
|
+
viewportFit: 'cover'
|
|
93
|
+
};
|
|
94
|
+
|
|
82
95
|
export default function RootLayout({
|
|
83
96
|
children
|
|
84
97
|
}: {
|
|
85
98
|
children: React.ReactNode;
|
|
86
99
|
}): JSX.Element {
|
|
87
100
|
return (
|
|
88
|
-
<html
|
|
101
|
+
<html
|
|
102
|
+
lang="en"
|
|
103
|
+
suppressHydrationWarning
|
|
104
|
+
className="min-h-screen overflow-hidden"
|
|
105
|
+
>
|
|
89
106
|
<body
|
|
90
|
-
className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen`}
|
|
107
|
+
className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen overflow-hidden`}
|
|
91
108
|
>
|
|
92
109
|
<ThemeProvider
|
|
93
110
|
attribute="class"
|
|
@@ -113,6 +130,8 @@ export default function RootLayout({
|
|
|
113
130
|
</div>
|
|
114
131
|
<Providers>{children}</Providers>
|
|
115
132
|
<Toaster />
|
|
133
|
+
<Analytics />
|
|
134
|
+
<SpeedInsights />
|
|
116
135
|
</div>
|
|
117
136
|
</ThemeProvider>
|
|
118
137
|
</body>
|