@autumnsgrove/groveengine 0.6.4 → 0.7.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/dist/auth/index.d.ts +1 -2
- package/dist/auth/index.js +8 -4
- package/dist/auth/session.d.ts +14 -33
- package/dist/auth/session.js +5 -103
- package/dist/components/admin/FloatingToolbar.svelte +373 -0
- package/dist/components/admin/FloatingToolbar.svelte.d.ts +17 -0
- package/dist/components/admin/MarkdownEditor.svelte +26 -347
- package/dist/components/admin/MarkdownEditor.svelte.d.ts +1 -1
- package/dist/components/admin/composables/index.d.ts +0 -2
- package/dist/components/admin/composables/index.js +0 -2
- package/dist/components/custom/ContentWithGutter.svelte +22 -25
- package/dist/components/custom/MobileTOC.svelte +20 -13
- package/dist/components/quota/UpgradePrompt.svelte +1 -1
- package/dist/server/services/database.d.ts +138 -0
- package/dist/server/services/database.js +234 -0
- package/dist/server/services/index.d.ts +5 -1
- package/dist/server/services/index.js +24 -2
- package/dist/server/services/turnstile.d.ts +66 -0
- package/dist/server/services/turnstile.js +131 -0
- package/dist/server/services/users.d.ts +104 -0
- package/dist/server/services/users.js +158 -0
- package/dist/styles/README.md +50 -0
- package/dist/styles/vine-pattern.css +24 -0
- package/dist/types/turnstile.d.ts +42 -0
- package/dist/ui/components/forms/TurnstileWidget.svelte +111 -0
- package/dist/ui/components/forms/TurnstileWidget.svelte.d.ts +14 -0
- package/dist/ui/components/primitives/dialog/dialog-overlay.svelte +1 -1
- package/dist/ui/components/primitives/sheet/sheet-overlay.svelte +1 -1
- package/dist/ui/components/ui/Glass.svelte +158 -0
- package/dist/ui/components/ui/Glass.svelte.d.ts +52 -0
- package/dist/ui/components/ui/GlassButton.svelte +157 -0
- package/dist/ui/components/ui/GlassButton.svelte.d.ts +39 -0
- package/dist/ui/components/ui/GlassCard.svelte +160 -0
- package/dist/ui/components/ui/GlassCard.svelte.d.ts +39 -0
- package/dist/ui/components/ui/GlassConfirmDialog.svelte +208 -0
- package/dist/ui/components/ui/GlassConfirmDialog.svelte.d.ts +52 -0
- package/dist/ui/components/ui/GlassOverlay.svelte +93 -0
- package/dist/ui/components/ui/GlassOverlay.svelte.d.ts +33 -0
- package/dist/ui/components/ui/Logo.svelte +161 -23
- package/dist/ui/components/ui/Logo.svelte.d.ts +4 -10
- package/dist/ui/components/ui/index.d.ts +5 -0
- package/dist/ui/components/ui/index.js +6 -0
- package/dist/ui/styles/grove.css +136 -0
- package/dist/ui/tokens/fonts.d.ts +69 -0
- package/dist/ui/tokens/fonts.js +341 -0
- package/dist/ui/tokens/index.d.ts +6 -5
- package/dist/ui/tokens/index.js +7 -6
- package/dist/utils/gutter.d.ts +2 -8
- package/dist/utils/markdown.d.ts +1 -0
- package/dist/utils/markdown.js +32 -11
- package/package.json +1 -1
- package/static/robots.txt +520 -0
- package/dist/auth/jwt.d.ts +0 -20
- package/dist/auth/jwt.js +0 -123
- package/dist/components/admin/composables/useCommandPalette.svelte.d.ts +0 -87
- package/dist/components/admin/composables/useCommandPalette.svelte.js +0 -158
- package/dist/components/admin/composables/useSlashCommands.svelte.d.ts +0 -104
- package/dist/components/admin/composables/useSlashCommands.svelte.js +0 -215
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turnstile Server-Side Verification (Shade)
|
|
3
|
+
*
|
|
4
|
+
* Validates Turnstile tokens with Cloudflare's siteverify endpoint.
|
|
5
|
+
* Part of Grove's Shade AI protection system.
|
|
6
|
+
*/
|
|
7
|
+
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
|
8
|
+
/**
|
|
9
|
+
* Verify a Turnstile token server-side
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* const result = await verifyTurnstileToken({
|
|
13
|
+
* token: formData.get('cf-turnstile-response'),
|
|
14
|
+
* secretKey: platform.env.TURNSTILE_SECRET_KEY,
|
|
15
|
+
* remoteip: request.headers.get('CF-Connecting-IP')
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* if (!result.success) {
|
|
19
|
+
* throw error(403, 'Human verification failed');
|
|
20
|
+
* }
|
|
21
|
+
*/
|
|
22
|
+
export async function verifyTurnstileToken(options) {
|
|
23
|
+
const { token, secretKey, remoteip } = options;
|
|
24
|
+
if (!token) {
|
|
25
|
+
return {
|
|
26
|
+
success: false,
|
|
27
|
+
'error-codes': ['missing-input-response']
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (!secretKey) {
|
|
31
|
+
console.error('Turnstile: Missing secret key');
|
|
32
|
+
return {
|
|
33
|
+
success: false,
|
|
34
|
+
'error-codes': ['missing-input-secret']
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const formData = new FormData();
|
|
38
|
+
formData.append('secret', secretKey);
|
|
39
|
+
formData.append('response', token);
|
|
40
|
+
if (remoteip) {
|
|
41
|
+
formData.append('remoteip', remoteip);
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const response = await fetch(TURNSTILE_VERIFY_URL, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
body: formData
|
|
47
|
+
});
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
console.error('Turnstile: Verification request failed', response.status);
|
|
50
|
+
return {
|
|
51
|
+
success: false,
|
|
52
|
+
'error-codes': ['request-failed']
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const result = await response.json();
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
console.error('Turnstile: Verification error', err);
|
|
60
|
+
return {
|
|
61
|
+
success: false,
|
|
62
|
+
'error-codes': ['network-error']
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Cookie name for tracking Turnstile verification status
|
|
68
|
+
*/
|
|
69
|
+
export const TURNSTILE_COOKIE_NAME = 'grove_verified';
|
|
70
|
+
/**
|
|
71
|
+
* Cookie max age (7 days in seconds)
|
|
72
|
+
*/
|
|
73
|
+
export const TURNSTILE_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
|
74
|
+
/**
|
|
75
|
+
* Create the verification cookie value (signed timestamp)
|
|
76
|
+
* Simple format: timestamp:hash
|
|
77
|
+
*/
|
|
78
|
+
export function createVerificationCookie(secretKey) {
|
|
79
|
+
const timestamp = Date.now().toString();
|
|
80
|
+
// Simple hash using the timestamp and a portion of the secret
|
|
81
|
+
const hash = simpleHash(timestamp + secretKey.slice(0, 16));
|
|
82
|
+
return `${timestamp}:${hash}`;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Validate a verification cookie
|
|
86
|
+
* Returns true if valid and not expired
|
|
87
|
+
*/
|
|
88
|
+
export function validateVerificationCookie(cookie, secretKey, maxAgeMs = TURNSTILE_COOKIE_MAX_AGE * 1000) {
|
|
89
|
+
if (!cookie)
|
|
90
|
+
return false;
|
|
91
|
+
const parts = cookie.split(':');
|
|
92
|
+
if (parts.length !== 2)
|
|
93
|
+
return false;
|
|
94
|
+
const [timestamp, hash] = parts;
|
|
95
|
+
const timestampNum = parseInt(timestamp, 10);
|
|
96
|
+
if (isNaN(timestampNum))
|
|
97
|
+
return false;
|
|
98
|
+
// Check expiration
|
|
99
|
+
if (Date.now() - timestampNum > maxAgeMs)
|
|
100
|
+
return false;
|
|
101
|
+
// Verify hash
|
|
102
|
+
const expectedHash = simpleHash(timestamp + secretKey.slice(0, 16));
|
|
103
|
+
return hash === expectedHash;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Simple hash function for cookie signing
|
|
107
|
+
* Not cryptographically secure, but sufficient for cookie validation
|
|
108
|
+
*/
|
|
109
|
+
function simpleHash(str) {
|
|
110
|
+
let hash = 0;
|
|
111
|
+
for (let i = 0; i < str.length; i++) {
|
|
112
|
+
const char = str.charCodeAt(i);
|
|
113
|
+
hash = (hash << 5) - hash + char;
|
|
114
|
+
hash = hash & hash; // Convert to 32bit integer
|
|
115
|
+
}
|
|
116
|
+
return Math.abs(hash).toString(36);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Get cookie options for the verification cookie
|
|
120
|
+
*/
|
|
121
|
+
export function getVerificationCookieOptions(domain) {
|
|
122
|
+
return {
|
|
123
|
+
path: '/',
|
|
124
|
+
httpOnly: true,
|
|
125
|
+
secure: true,
|
|
126
|
+
sameSite: 'lax',
|
|
127
|
+
maxAge: TURNSTILE_COOKIE_MAX_AGE,
|
|
128
|
+
// Set domain to .grove.place to work across subdomains
|
|
129
|
+
...(domain ? { domain } : {})
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User Service - D1 User Operations
|
|
3
|
+
*
|
|
4
|
+
* Provides utilities for managing authenticated users in D1.
|
|
5
|
+
* Users are created/updated during auth callback from GroveAuth.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { getUserByGroveAuthId, getUserFromSession } from '@autumnsgrove/groveengine/services';
|
|
10
|
+
*
|
|
11
|
+
* // Get user by GroveAuth ID (from token)
|
|
12
|
+
* const user = await getUserByGroveAuthId(db, groveAuthId);
|
|
13
|
+
*
|
|
14
|
+
* // Get user from session (validates token first)
|
|
15
|
+
* const user = await getUserFromSession(db, accessToken, authBaseUrl);
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* User record from the users table
|
|
20
|
+
*/
|
|
21
|
+
export interface User {
|
|
22
|
+
id: string;
|
|
23
|
+
groveauth_id: string;
|
|
24
|
+
email: string;
|
|
25
|
+
display_name: string | null;
|
|
26
|
+
avatar_url: string | null;
|
|
27
|
+
tenant_id: string | null;
|
|
28
|
+
last_login_at: number | null;
|
|
29
|
+
login_count: number;
|
|
30
|
+
is_active: number;
|
|
31
|
+
created_at: number;
|
|
32
|
+
updated_at: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Get a user by their GroveAuth ID
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```typescript
|
|
39
|
+
* const user = await getUserByGroveAuthId(db, 'groveauth-user-id');
|
|
40
|
+
* if (user) {
|
|
41
|
+
* console.log('User:', user.email);
|
|
42
|
+
* }
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export declare function getUserByGroveAuthId(db: D1Database, groveAuthId: string): Promise<User | null>;
|
|
46
|
+
/**
|
|
47
|
+
* Get a user by their internal ID
|
|
48
|
+
*/
|
|
49
|
+
export declare function getUserById(db: D1Database, id: string): Promise<User | null>;
|
|
50
|
+
/**
|
|
51
|
+
* Get a user by email address
|
|
52
|
+
*/
|
|
53
|
+
export declare function getUserByEmail(db: D1Database, email: string): Promise<User | null>;
|
|
54
|
+
/**
|
|
55
|
+
* Get user who owns a specific tenant
|
|
56
|
+
*/
|
|
57
|
+
export declare function getUserByTenantId(db: D1Database, tenantId: string): Promise<User | null>;
|
|
58
|
+
/**
|
|
59
|
+
* Get user from an access token by validating with GroveAuth
|
|
60
|
+
*
|
|
61
|
+
* This function:
|
|
62
|
+
* 1. Calls GroveAuth /userinfo to validate the token and get user info
|
|
63
|
+
* 2. Looks up the user in D1 by their groveauth_id
|
|
64
|
+
* 3. Returns the user if found and active
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```typescript
|
|
68
|
+
* const user = await getUserFromSession(db, accessToken, 'https://auth-api.grove.place');
|
|
69
|
+
* if (!user) {
|
|
70
|
+
* return redirect(302, '/auth/login');
|
|
71
|
+
* }
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export declare function getUserFromSession(db: D1Database, accessToken: string, authBaseUrl?: string): Promise<User | null>;
|
|
75
|
+
/**
|
|
76
|
+
* Get user from session without external validation
|
|
77
|
+
*
|
|
78
|
+
* Use this when you've already validated the token (e.g., in hooks.server.ts)
|
|
79
|
+
* and have the GroveAuth user ID (sub claim).
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```typescript
|
|
83
|
+
* // In hooks.server.ts after token validation:
|
|
84
|
+
* const user = await getUserFromValidatedSession(db, tokenInfo.sub);
|
|
85
|
+
* event.locals.user = user;
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
export declare function getUserFromValidatedSession(db: D1Database, groveAuthId: string): Promise<User | null>;
|
|
89
|
+
/**
|
|
90
|
+
* Link a user to a tenant (set ownership)
|
|
91
|
+
*/
|
|
92
|
+
export declare function linkUserToTenant(db: D1Database, userId: string, tenantId: string): Promise<number>;
|
|
93
|
+
/**
|
|
94
|
+
* Update user's display name
|
|
95
|
+
*/
|
|
96
|
+
export declare function updateUserDisplayName(db: D1Database, userId: string, displayName: string): Promise<number>;
|
|
97
|
+
/**
|
|
98
|
+
* Deactivate a user (soft delete)
|
|
99
|
+
*/
|
|
100
|
+
export declare function deactivateUser(db: D1Database, userId: string): Promise<number>;
|
|
101
|
+
/**
|
|
102
|
+
* Reactivate a user
|
|
103
|
+
*/
|
|
104
|
+
export declare function reactivateUser(db: D1Database, userId: string): Promise<number>;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User Service - D1 User Operations
|
|
3
|
+
*
|
|
4
|
+
* Provides utilities for managing authenticated users in D1.
|
|
5
|
+
* Users are created/updated during auth callback from GroveAuth.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { getUserByGroveAuthId, getUserFromSession } from '@autumnsgrove/groveengine/services';
|
|
10
|
+
*
|
|
11
|
+
* // Get user by GroveAuth ID (from token)
|
|
12
|
+
* const user = await getUserByGroveAuthId(db, groveAuthId);
|
|
13
|
+
*
|
|
14
|
+
* // Get user from session (validates token first)
|
|
15
|
+
* const user = await getUserFromSession(db, accessToken, authBaseUrl);
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
import { queryOne, update } from './database.js';
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Query Functions
|
|
21
|
+
// ============================================================================
|
|
22
|
+
/**
|
|
23
|
+
* Get a user by their GroveAuth ID
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```typescript
|
|
27
|
+
* const user = await getUserByGroveAuthId(db, 'groveauth-user-id');
|
|
28
|
+
* if (user) {
|
|
29
|
+
* console.log('User:', user.email);
|
|
30
|
+
* }
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export async function getUserByGroveAuthId(db, groveAuthId) {
|
|
34
|
+
return queryOne(db, 'SELECT * FROM users WHERE groveauth_id = ?', [groveAuthId]);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Get a user by their internal ID
|
|
38
|
+
*/
|
|
39
|
+
export async function getUserById(db, id) {
|
|
40
|
+
return queryOne(db, 'SELECT * FROM users WHERE id = ?', [id]);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Get a user by email address
|
|
44
|
+
*/
|
|
45
|
+
export async function getUserByEmail(db, email) {
|
|
46
|
+
return queryOne(db, 'SELECT * FROM users WHERE email = ?', [email]);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Get user who owns a specific tenant
|
|
50
|
+
*/
|
|
51
|
+
export async function getUserByTenantId(db, tenantId) {
|
|
52
|
+
return queryOne(db, 'SELECT * FROM users WHERE tenant_id = ?', [tenantId]);
|
|
53
|
+
}
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// Session Functions
|
|
56
|
+
// ============================================================================
|
|
57
|
+
/**
|
|
58
|
+
* Get user from an access token by validating with GroveAuth
|
|
59
|
+
*
|
|
60
|
+
* This function:
|
|
61
|
+
* 1. Calls GroveAuth /userinfo to validate the token and get user info
|
|
62
|
+
* 2. Looks up the user in D1 by their groveauth_id
|
|
63
|
+
* 3. Returns the user if found and active
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```typescript
|
|
67
|
+
* const user = await getUserFromSession(db, accessToken, 'https://auth-api.grove.place');
|
|
68
|
+
* if (!user) {
|
|
69
|
+
* return redirect(302, '/auth/login');
|
|
70
|
+
* }
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export async function getUserFromSession(db, accessToken, authBaseUrl = 'https://auth-api.grove.place') {
|
|
74
|
+
if (!accessToken) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
// Validate token with GroveAuth and get user info
|
|
79
|
+
const response = await fetch(`${authBaseUrl}/userinfo`, {
|
|
80
|
+
headers: {
|
|
81
|
+
Authorization: `Bearer ${accessToken}`
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
// Don't log 401s as errors - they're expected for expired tokens
|
|
86
|
+
if (response.status !== 401) {
|
|
87
|
+
console.warn('[User Service] GroveAuth returned non-OK status:', {
|
|
88
|
+
status: response.status,
|
|
89
|
+
authBaseUrl
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
const userInfo = (await response.json());
|
|
95
|
+
// Look up user in D1
|
|
96
|
+
const user = await getUserByGroveAuthId(db, userInfo.sub);
|
|
97
|
+
// Return null if user doesn't exist or is inactive
|
|
98
|
+
if (!user || !user.is_active) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
return user;
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
// Log auth failures for security monitoring and debugging
|
|
105
|
+
console.error('[User Service] Session validation failed:', {
|
|
106
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
107
|
+
authBaseUrl
|
|
108
|
+
});
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Get user from session without external validation
|
|
114
|
+
*
|
|
115
|
+
* Use this when you've already validated the token (e.g., in hooks.server.ts)
|
|
116
|
+
* and have the GroveAuth user ID (sub claim).
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```typescript
|
|
120
|
+
* // In hooks.server.ts after token validation:
|
|
121
|
+
* const user = await getUserFromValidatedSession(db, tokenInfo.sub);
|
|
122
|
+
* event.locals.user = user;
|
|
123
|
+
* ```
|
|
124
|
+
*/
|
|
125
|
+
export async function getUserFromValidatedSession(db, groveAuthId) {
|
|
126
|
+
const user = await getUserByGroveAuthId(db, groveAuthId);
|
|
127
|
+
if (!user || !user.is_active) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
return user;
|
|
131
|
+
}
|
|
132
|
+
// ============================================================================
|
|
133
|
+
// Update Functions
|
|
134
|
+
// ============================================================================
|
|
135
|
+
/**
|
|
136
|
+
* Link a user to a tenant (set ownership)
|
|
137
|
+
*/
|
|
138
|
+
export async function linkUserToTenant(db, userId, tenantId) {
|
|
139
|
+
return update(db, 'users', { tenant_id: tenantId }, 'id = ?', [userId]);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Update user's display name
|
|
143
|
+
*/
|
|
144
|
+
export async function updateUserDisplayName(db, userId, displayName) {
|
|
145
|
+
return update(db, 'users', { display_name: displayName }, 'id = ?', [userId]);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Deactivate a user (soft delete)
|
|
149
|
+
*/
|
|
150
|
+
export async function deactivateUser(db, userId) {
|
|
151
|
+
return update(db, 'users', { is_active: 0 }, 'id = ?', [userId]);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Reactivate a user
|
|
155
|
+
*/
|
|
156
|
+
export async function reactivateUser(db, userId) {
|
|
157
|
+
return update(db, 'users', { is_active: 1 }, 'id = ?', [userId]);
|
|
158
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Shared Styles
|
|
2
|
+
|
|
3
|
+
This directory contains CSS styles that are shared across all Grove sites to maintain visual consistency.
|
|
4
|
+
|
|
5
|
+
## vine-pattern.css
|
|
6
|
+
|
|
7
|
+
The `.leaf-pattern` class provides the signature Grove vine background pattern used across the platform.
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- **450x450px repeating SVG pattern** with:
|
|
12
|
+
- 5 flowing organic vine paths
|
|
13
|
+
- 8 spiral tendrils
|
|
14
|
+
- Multiple leaf varieties (ivy, round, slender, fern)
|
|
15
|
+
- Botanical details (seeds, spores)
|
|
16
|
+
- **Grove green (#22c55e)** from the design system
|
|
17
|
+
- **Automatic dark mode support** with adjusted opacity
|
|
18
|
+
- **Performance-optimized** inline SVG data URI (no HTTP requests)
|
|
19
|
+
|
|
20
|
+
### Usage
|
|
21
|
+
|
|
22
|
+
Import in your site's `app.css`:
|
|
23
|
+
|
|
24
|
+
```css
|
|
25
|
+
@import '@autumnsgrove/groveengine/lib/styles/vine-pattern.css';
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or copy the `.leaf-pattern` class definition directly into your app's CSS.
|
|
29
|
+
|
|
30
|
+
Then apply the class to your layout:
|
|
31
|
+
|
|
32
|
+
```svelte
|
|
33
|
+
<div class="min-h-screen leaf-pattern">
|
|
34
|
+
<!-- Your content -->
|
|
35
|
+
</div>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Current Sites Using This Pattern
|
|
39
|
+
|
|
40
|
+
- **landing** - Applied globally via `routes/+layout.svelte`
|
|
41
|
+
- **plant** - Applied globally via `routes/+layout.svelte`
|
|
42
|
+
|
|
43
|
+
### Customization
|
|
44
|
+
|
|
45
|
+
To adjust the pattern's appearance, you can modify:
|
|
46
|
+
- **Opacity values** in the SVG (currently 0.04-0.14)
|
|
47
|
+
- **Stroke widths** for vines (currently 0.5-1.5)
|
|
48
|
+
- **Color** (currently #22c55e, Grove green)
|
|
49
|
+
|
|
50
|
+
Any changes should be synchronized across all sites for consistency.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/* ═══════════════════════════════════════════════════════════════
|
|
2
|
+
GROVE VINE PATTERN BACKGROUND
|
|
3
|
+
Shared across all Grove sites for visual consistency
|
|
4
|
+
═══════════════════════════════════════════════════════════════ */
|
|
5
|
+
|
|
6
|
+
/* Subtle vine pattern background - organic, forest-like
|
|
7
|
+
|
|
8
|
+
Features:
|
|
9
|
+
- 5 flowing vines with organic S-curves
|
|
10
|
+
- 8 spiral tendrils scattered throughout
|
|
11
|
+
- Multiple types of leaves (ivy, round, slender, fern)
|
|
12
|
+
- Floating seeds/spores for botanical authenticity
|
|
13
|
+
- 450x450px repeating pattern
|
|
14
|
+
- Uses Grove green (#22c55e) from the design system
|
|
15
|
+
- Auto-adjusts opacity for light/dark modes
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
.leaf-pattern {
|
|
19
|
+
background-image: url("data:image/svg+xml,%3Csvg width='450' height='450' viewBox='0 0 450 450' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' stroke='%2322c55e' stroke-linecap='round'%3E%3C!-- Long flowing vine 1 - dramatic S-curve --%3E%3Cpath d='M-30 420 C50 380 30 320 80 260 S150 180 120 120 S180 40 140 -30' stroke-width='1.5' opacity='0.12'/%3E%3C!-- Long flowing vine 2 - opposite sweep --%3E%3Cpath d='M480 380 C400 350 420 280 360 230 S280 160 310 100 S250 30 290 -20' stroke-width='1.4' opacity='0.10'/%3E%3C!-- Diagonal vine crossing --%3E%3Cpath d='M-20 280 C60 250 100 220 160 200 S250 160 320 170 S400 130 470 100' stroke-width='1.2' opacity='0.09'/%3E%3C!-- Vertical accent vine --%3E%3Cpath d='M200 480 C180 420 210 360 190 300 S220 220 200 160 S230 80 210 20' stroke-width='1.1' opacity='0.08'/%3E%3C!-- Wandering vine --%3E%3Cpath d='M350 480 C330 430 360 380 340 320 S380 260 350 200' stroke-width='1' opacity='0.08'/%3E%3C!-- Spiral tendrils scattered --%3E%3Cpath d='M80 260 Q100 250 108 238 Q115 225 105 215 Q95 208 85 215' stroke-width='0.8' opacity='0.10'/%3E%3Cpath d='M120 120 Q140 130 150 120 Q158 108 145 98 Q132 92 125 102' stroke-width='0.7' opacity='0.09'/%3E%3Cpath d='M360 230 Q340 222 330 232 Q322 245 335 255 Q348 262 358 250' stroke-width='0.7' opacity='0.09'/%3E%3Cpath d='M310 100 Q290 92 280 102 Q272 115 288 125' stroke-width='0.6' opacity='0.08'/%3E%3Cpath d='M160 200 Q180 192 188 202 Q195 215 182 225' stroke-width='0.6' opacity='0.08'/%3E%3Cpath d='M190 300 Q210 292 218 305 Q224 320 210 328' stroke-width='0.6' opacity='0.07'/%3E%3Cpath d='M340 320 Q360 328 365 342 Q368 358 352 362' stroke-width='0.6' opacity='0.07'/%3E%3Cpath d='M55 380 Q75 372 82 385 Q88 400 72 408' stroke-width='0.5' opacity='0.06'/%3E%3C/g%3E%3Cg fill='%2322c55e'%3E%3C!-- Pointed ivy leaves --%3E%3Cpath d='M70 300 Q85 280 78 265 Q70 280 55 288 Q70 295 70 300Z' opacity='0.11'/%3E%3Cpath d='M135 145 Q150 128 143 115 Q135 128 120 135 Q135 142 135 145Z' opacity='0.10'/%3E%3Cpath d='M375 255 Q360 240 365 225 Q375 240 390 245 Q375 252 375 255Z' opacity='0.09'/%3E%3Cpath d='M220 180 Q235 165 228 152 Q220 165 205 172 Q220 178 220 180Z' opacity='0.08'/%3E%3C!-- Round soft leaves --%3E%3Cellipse cx='95' cy='210' rx='9' ry='14' transform='rotate(-35 95 210)' opacity='0.10'/%3E%3Cellipse cx='335' cy='145' rx='8' ry='12' transform='rotate(28 335 145)' opacity='0.09'/%3E%3Cellipse cx='175' cy='255' rx='7' ry='11' transform='rotate(-18 175 255)' opacity='0.08'/%3E%3Cellipse cx='280' cy='195' rx='6' ry='10' transform='rotate(40 280 195)' opacity='0.08'/%3E%3Cellipse cx='405' cy='120' rx='7' ry='10' transform='rotate(-25 405 120)' opacity='0.07'/%3E%3C!-- Long slender leaves --%3E%3Cpath d='M145 175 Q152 155 145 135 Q138 155 145 175Z' opacity='0.09'/%3E%3Cpath d='M295 130 Q305 115 298 98 Q288 115 295 130Z' opacity='0.08'/%3E%3Cpath d='M210 275 Q200 258 205 240 Q218 258 210 275Z' opacity='0.08'/%3E%3Cpath d='M365 195 Q375 180 368 162 Q358 180 365 195Z' opacity='0.07'/%3E%3Cpath d='M115 365 Q125 348 118 330 Q108 348 115 365Z' opacity='0.07'/%3E%3C!-- Fern fronds scattered asymmetrically --%3E%3Cg transform='translate(45 365) rotate(-45)' opacity='0.10'%3E%3Cpath d='M0,0 L0,-30' stroke='%2322c55e' stroke-width='0.6' fill='none'/%3E%3Cellipse cx='-5' cy='-7' rx='3.5' ry='6' transform='rotate(-30 -5 -7)'/%3E%3Cellipse cx='5' cy='-12' rx='3.5' ry='6' transform='rotate(30 5 -12)'/%3E%3Cellipse cx='-4' cy='-17' rx='3' ry='5' transform='rotate(-30 -4 -17)'/%3E%3Cellipse cx='4' cy='-22' rx='3' ry='5' transform='rotate(30 4 -22)'/%3E%3Cellipse cx='0' cy='-28' rx='2.5' ry='4'/%3E%3C/g%3E%3Cg transform='translate(390 185) rotate(40)' opacity='0.09'%3E%3Cpath d='M0,0 L0,-28' stroke='%2322c55e' stroke-width='0.6' fill='none'/%3E%3Cellipse cx='-4' cy='-6' rx='3' ry='5' transform='rotate(-25 -4 -6)'/%3E%3Cellipse cx='4' cy='-11' rx='3' ry='5' transform='rotate(25 4 -11)'/%3E%3Cellipse cx='-3' cy='-16' rx='2.5' ry='4' transform='rotate(-25 -3 -16)'/%3E%3Cellipse cx='3' cy='-21' rx='2.5' ry='4' transform='rotate(25 3 -21)'/%3E%3Cellipse cx='0' cy='-26' rx='2' ry='3'/%3E%3C/g%3E%3Cg transform='translate(255 65) rotate(-25)' opacity='0.08'%3E%3Cpath d='M0,0 L0,-25' stroke='%2322c55e' stroke-width='0.5' fill='none'/%3E%3Cellipse cx='-4' cy='-6' rx='2.5' ry='4' transform='rotate(-30 -4 -6)'/%3E%3Cellipse cx='4' cy='-10' rx='2.5' ry='4' transform='rotate(30 4 -10)'/%3E%3Cellipse cx='-3' cy='-15' rx='2' ry='3.5' transform='rotate(-30 -3 -15)'/%3E%3Cellipse cx='3' cy='-20' rx='2' ry='3' transform='rotate(30 3 -20)'/%3E%3C/g%3E%3Cg transform='translate(140 420) rotate(55)' opacity='0.08'%3E%3Cpath d='M0,0 L0,-22' stroke='%2322c55e' stroke-width='0.5' fill='none'/%3E%3Cellipse cx='-3' cy='-5' rx='2' ry='3.5' transform='rotate(-25 -3 -5)'/%3E%3Cellipse cx='3' cy='-9' rx='2' ry='3.5' transform='rotate(25 3 -9)'/%3E%3Cellipse cx='-2' cy='-14' rx='1.5' ry='3' transform='rotate(-25 -2 -14)'/%3E%3Cellipse cx='2' cy='-18' rx='1.5' ry='2.5' transform='rotate(25 2 -18)'/%3E%3C/g%3E%3C!-- Tiny floating seeds/spores --%3E%3Ccircle cx='180' cy='90' r='2' opacity='0.06'/%3E%3Ccircle cx='85' cy='175' r='1.5' opacity='0.05'/%3E%3Ccircle cx='345' cy='290' r='2' opacity='0.06'/%3E%3Ccircle cx='420' cy='200' r='1.5' opacity='0.05'/%3E%3Ccircle cx='260' cy='350' r='2' opacity='0.05'/%3E%3Ccircle cx='40' cy='120' r='1.5' opacity='0.04'/%3E%3Ccircle cx='310' cy='400' r='1.5' opacity='0.04'/%3E%3C/g%3E%3C/svg%3E");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.dark .leaf-pattern {
|
|
23
|
+
background-image: url("data:image/svg+xml,%3Csvg width='450' height='450' viewBox='0 0 450 450' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' stroke='%2322c55e' stroke-linecap='round'%3E%3C!-- Long flowing vine 1 - dramatic S-curve --%3E%3Cpath d='M-30 420 C50 380 30 320 80 260 S150 180 120 120 S180 40 140 -30' stroke-width='1.5' opacity='0.14'/%3E%3C!-- Long flowing vine 2 - opposite sweep --%3E%3Cpath d='M480 380 C400 350 420 280 360 230 S280 160 310 100 S250 30 290 -20' stroke-width='1.4' opacity='0.12'/%3E%3C!-- Diagonal vine crossing --%3E%3Cpath d='M-20 280 C60 250 100 220 160 200 S250 160 320 170 S400 130 470 100' stroke-width='1.2' opacity='0.11'/%3E%3C!-- Vertical accent vine --%3E%3Cpath d='M200 480 C180 420 210 360 190 300 S220 220 200 160 S230 80 210 20' stroke-width='1.1' opacity='0.10'/%3E%3C!-- Wandering vine --%3E%3Cpath d='M350 480 C330 430 360 380 340 320 S380 260 350 200' stroke-width='1' opacity='0.10'/%3E%3C!-- Spiral tendrils scattered --%3E%3Cpath d='M80 260 Q100 250 108 238 Q115 225 105 215 Q95 208 85 215' stroke-width='0.8' opacity='0.12'/%3E%3Cpath d='M120 120 Q140 130 150 120 Q158 108 145 98 Q132 92 125 102' stroke-width='0.7' opacity='0.11'/%3E%3Cpath d='M360 230 Q340 222 330 232 Q322 245 335 255 Q348 262 358 250' stroke-width='0.7' opacity='0.11'/%3E%3Cpath d='M310 100 Q290 92 280 102 Q272 115 288 125' stroke-width='0.6' opacity='0.10'/%3E%3Cpath d='M160 200 Q180 192 188 202 Q195 215 182 225' stroke-width='0.6' opacity='0.10'/%3E%3Cpath d='M190 300 Q210 292 218 305 Q224 320 210 328' stroke-width='0.6' opacity='0.09'/%3E%3Cpath d='M340 320 Q360 328 365 342 Q368 358 352 362' stroke-width='0.6' opacity='0.09'/%3E%3Cpath d='M55 380 Q75 372 82 385 Q88 400 72 408' stroke-width='0.5' opacity='0.08'/%3E%3C/g%3E%3Cg fill='%2322c55e'%3E%3C!-- Pointed ivy leaves --%3E%3Cpath d='M70 300 Q85 280 78 265 Q70 280 55 288 Q70 295 70 300Z' opacity='0.13'/%3E%3Cpath d='M135 145 Q150 128 143 115 Q135 128 120 135 Q135 142 135 145Z' opacity='0.12'/%3E%3Cpath d='M375 255 Q360 240 365 225 Q375 240 390 245 Q375 252 375 255Z' opacity='0.11'/%3E%3Cpath d='M220 180 Q235 165 228 152 Q220 165 205 172 Q220 178 220 180Z' opacity='0.10'/%3E%3C!-- Round soft leaves --%3E%3Cellipse cx='95' cy='210' rx='9' ry='14' transform='rotate(-35 95 210)' opacity='0.12'/%3E%3Cellipse cx='335' cy='145' rx='8' ry='12' transform='rotate(28 335 145)' opacity='0.11'/%3E%3Cellipse cx='175' cy='255' rx='7' ry='11' transform='rotate(-18 175 255)' opacity='0.10'/%3E%3Cellipse cx='280' cy='195' rx='6' ry='10' transform='rotate(40 280 195)' opacity='0.10'/%3E%3Cellipse cx='405' cy='120' rx='7' ry='10' transform='rotate(-25 405 120)' opacity='0.09'/%3E%3C!-- Long slender leaves --%3E%3Cpath d='M145 175 Q152 155 145 135 Q138 155 145 175Z' opacity='0.11'/%3E%3Cpath d='M295 130 Q305 115 298 98 Q288 115 295 130Z' opacity='0.10'/%3E%3Cpath d='M210 275 Q200 258 205 240 Q218 258 210 275Z' opacity='0.10'/%3E%3Cpath d='M365 195 Q375 180 368 162 Q358 180 365 195Z' opacity='0.09'/%3E%3Cpath d='M115 365 Q125 348 118 330 Q108 348 115 365Z' opacity='0.09'/%3E%3C!-- Fern fronds scattered asymmetrically --%3E%3Cg transform='translate(45 365) rotate(-45)' opacity='0.12'%3E%3Cpath d='M0,0 L0,-30' stroke='%2322c55e' stroke-width='0.6' fill='none'/%3E%3Cellipse cx='-5' cy='-7' rx='3.5' ry='6' transform='rotate(-30 -5 -7)'/%3E%3Cellipse cx='5' cy='-12' rx='3.5' ry='6' transform='rotate(30 5 -12)'/%3E%3Cellipse cx='-4' cy='-17' rx='3' ry='5' transform='rotate(-30 -4 -17)'/%3E%3Cellipse cx='4' cy='-22' rx='3' ry='5' transform='rotate(30 4 -22)'/%3E%3Cellipse cx='0' cy='-28' rx='2.5' ry='4'/%3E%3C/g%3E%3Cg transform='translate(390 185) rotate(40)' opacity='0.11'%3E%3Cpath d='M0,0 L0,-28' stroke='%2322c55e' stroke-width='0.6' fill='none'/%3E%3Cellipse cx='-4' cy='-6' rx='3' ry='5' transform='rotate(-25 -4 -6)'/%3E%3Cellipse cx='4' cy='-11' rx='3' ry='5' transform='rotate(25 4 -11)'/%3E%3Cellipse cx='-3' cy='-16' rx='2.5' ry='4' transform='rotate(-25 -3 -16)'/%3E%3Cellipse cx='3' cy='-21' rx='2.5' ry='4' transform='rotate(25 3 -21)'/%3E%3Cellipse cx='0' cy='-26' rx='2' ry='3'/%3E%3C/g%3E%3Cg transform='translate(255 65) rotate(-25)' opacity='0.10'%3E%3Cpath d='M0,0 L0,-25' stroke='%2322c55e' stroke-width='0.5' fill='none'/%3E%3Cellipse cx='-4' cy='-6' rx='2.5' ry='4' transform='rotate(-30 -4 -6)'/%3E%3Cellipse cx='4' cy='-10' rx='2.5' ry='4' transform='rotate(30 4 -10)'/%3E%3Cellipse cx='-3' cy='-15' rx='2' ry='3.5' transform='rotate(-30 -3 -15)'/%3E%3Cellipse cx='3' cy='-20' rx='2' ry='3' transform='rotate(30 3 -20)'/%3E%3C/g%3E%3Cg transform='translate(140 420) rotate(55)' opacity='0.10'%3E%3Cpath d='M0,0 L0,-22' stroke='%2322c55e' stroke-width='0.5' fill='none'/%3E%3Cellipse cx='-3' cy='-5' rx='2' ry='3.5' transform='rotate(-25 -3 -5)'/%3E%3Cellipse cx='3' cy='-9' rx='2' ry='3.5' transform='rotate(25 3 -9)'/%3E%3Cellipse cx='-2' cy='-14' rx='1.5' ry='3' transform='rotate(-25 -2 -14)'/%3E%3Cellipse cx='2' cy='-18' rx='1.5' ry='2.5' transform='rotate(25 2 -18)'/%3E%3C/g%3E%3C!-- Tiny floating seeds/spores --%3E%3Ccircle cx='180' cy='90' r='2' opacity='0.08'/%3E%3Ccircle cx='85' cy='175' r='1.5' opacity='0.07'/%3E%3Ccircle cx='345' cy='290' r='2' opacity='0.08'/%3E%3Ccircle cx='420' cy='200' r='1.5' opacity='0.07'/%3E%3Ccircle cx='260' cy='350' r='2' opacity='0.07'/%3E%3Ccircle cx='40' cy='120' r='1.5' opacity='0.06'/%3E%3Ccircle cx='310' cy='400' r='1.5' opacity='0.06'/%3E%3C/g%3E%3C/svg%3E");
|
|
24
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Turnstile Type Declarations
|
|
3
|
+
*
|
|
4
|
+
* These types define the Turnstile widget API loaded from
|
|
5
|
+
* https://challenges.cloudflare.com/turnstile/v0/api.js
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
interface TurnstileRenderOptions {
|
|
9
|
+
sitekey: string;
|
|
10
|
+
callback?: (token: string) => void;
|
|
11
|
+
'error-callback'?: (error: string) => void;
|
|
12
|
+
'expired-callback'?: () => void;
|
|
13
|
+
theme?: 'light' | 'dark' | 'auto';
|
|
14
|
+
size?: 'normal' | 'compact';
|
|
15
|
+
tabindex?: number;
|
|
16
|
+
action?: string;
|
|
17
|
+
cData?: string;
|
|
18
|
+
'response-field'?: boolean;
|
|
19
|
+
'response-field-name'?: string;
|
|
20
|
+
'refresh-expired'?: 'auto' | 'manual' | 'never';
|
|
21
|
+
language?: string;
|
|
22
|
+
appearance?: 'always' | 'execute' | 'interaction-only';
|
|
23
|
+
retry?: 'auto' | 'never';
|
|
24
|
+
'retry-interval'?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface TurnstileWidget {
|
|
28
|
+
render(container: HTMLElement | string, options: TurnstileRenderOptions): string;
|
|
29
|
+
reset(widgetId?: string): void;
|
|
30
|
+
remove(widgetId: string): void;
|
|
31
|
+
getResponse(widgetId?: string): string | undefined;
|
|
32
|
+
isExpired(widgetId?: string): boolean;
|
|
33
|
+
execute(container?: HTMLElement | string, options?: TurnstileRenderOptions): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
declare global {
|
|
37
|
+
interface Window {
|
|
38
|
+
turnstile?: TurnstileWidget;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* TurnstileWidget - Cloudflare Turnstile human verification (Shade)
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* <TurnstileWidget siteKey={TURNSTILE_SITE_KEY} onverify={(token) => handleToken(token)} />
|
|
7
|
+
*
|
|
8
|
+
* The widget is invisible in "managed" mode - users only see it when suspicious.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { onMount, onDestroy } from 'svelte';
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
siteKey: string;
|
|
15
|
+
theme?: 'light' | 'dark' | 'auto';
|
|
16
|
+
size?: 'normal' | 'compact';
|
|
17
|
+
onverify?: (token: string) => void;
|
|
18
|
+
onerror?: (error: string) => void;
|
|
19
|
+
onexpire?: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let {
|
|
23
|
+
siteKey,
|
|
24
|
+
theme = 'auto',
|
|
25
|
+
size = 'normal',
|
|
26
|
+
onverify,
|
|
27
|
+
onerror,
|
|
28
|
+
onexpire
|
|
29
|
+
}: Props = $props();
|
|
30
|
+
|
|
31
|
+
let container: HTMLDivElement;
|
|
32
|
+
let widgetId: string | null = null;
|
|
33
|
+
|
|
34
|
+
// Load the Turnstile script if not already loaded
|
|
35
|
+
function loadScript(): Promise<void> {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
if (window.turnstile) {
|
|
38
|
+
resolve();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const script = document.createElement('script');
|
|
43
|
+
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
|
|
44
|
+
script.async = true;
|
|
45
|
+
script.defer = true;
|
|
46
|
+
script.onload = () => resolve();
|
|
47
|
+
script.onerror = () => reject(new Error('Failed to load Turnstile script'));
|
|
48
|
+
document.head.appendChild(script);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function renderWidget() {
|
|
53
|
+
if (!window.turnstile || !container) return;
|
|
54
|
+
|
|
55
|
+
widgetId = window.turnstile.render(container, {
|
|
56
|
+
sitekey: siteKey,
|
|
57
|
+
theme,
|
|
58
|
+
size,
|
|
59
|
+
callback: (token: string) => {
|
|
60
|
+
onverify?.(token);
|
|
61
|
+
},
|
|
62
|
+
'error-callback': (error: string) => {
|
|
63
|
+
onerror?.(error);
|
|
64
|
+
},
|
|
65
|
+
'expired-callback': () => {
|
|
66
|
+
onexpire?.();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
onMount(async () => {
|
|
72
|
+
try {
|
|
73
|
+
await loadScript();
|
|
74
|
+
renderWidget();
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error('Turnstile load error:', err);
|
|
77
|
+
onerror?.(err instanceof Error ? err.message : 'Failed to load Turnstile');
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
onDestroy(() => {
|
|
82
|
+
if (widgetId && window.turnstile) {
|
|
83
|
+
window.turnstile.remove(widgetId);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Reset the widget (useful after form submission)
|
|
88
|
+
export function reset() {
|
|
89
|
+
if (widgetId && window.turnstile) {
|
|
90
|
+
window.turnstile.reset(widgetId);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Get the current token
|
|
95
|
+
export function getToken(): string | undefined {
|
|
96
|
+
if (widgetId && window.turnstile) {
|
|
97
|
+
return window.turnstile.getResponse(widgetId);
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
</script>
|
|
102
|
+
|
|
103
|
+
<div bind:this={container} class="turnstile-container"></div>
|
|
104
|
+
|
|
105
|
+
<style>
|
|
106
|
+
.turnstile-container {
|
|
107
|
+
display: flex;
|
|
108
|
+
justify-content: center;
|
|
109
|
+
min-height: 65px;
|
|
110
|
+
}
|
|
111
|
+
</style>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
siteKey: string;
|
|
3
|
+
theme?: 'light' | 'dark' | 'auto';
|
|
4
|
+
size?: 'normal' | 'compact';
|
|
5
|
+
onverify?: (token: string) => void;
|
|
6
|
+
onerror?: (error: string) => void;
|
|
7
|
+
onexpire?: () => void;
|
|
8
|
+
}
|
|
9
|
+
declare const TurnstileWidget: import("svelte").Component<Props, {
|
|
10
|
+
reset: () => void;
|
|
11
|
+
getToken: () => string | undefined;
|
|
12
|
+
}, "">;
|
|
13
|
+
type TurnstileWidget = ReturnType<typeof TurnstileWidget>;
|
|
14
|
+
export default TurnstileWidget;
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
<DialogPrimitive.Overlay
|
|
13
13
|
bind:ref
|
|
14
14
|
class={cn(
|
|
15
|
-
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
|
|
15
|
+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 backdrop-blur-sm",
|
|
16
16
|
className
|
|
17
17
|
)}
|
|
18
18
|
{...restProps}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
<SheetPrimitive.Overlay
|
|
15
15
|
bind:ref
|
|
16
16
|
class={cn(
|
|
17
|
-
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
|
|
17
|
+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 backdrop-blur-sm",
|
|
18
18
|
className
|
|
19
19
|
)}
|
|
20
20
|
{...restProps}
|