@edgedev/create-edge-site 1.0.15 → 1.0.16

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/app/app.vue CHANGED
@@ -1,12 +1,11 @@
1
+ <script setup>
2
+ </script>
3
+
1
4
  <template class="font-sans">
2
- <edge-navbar />
3
- <NuxtPage />
4
- <edge-footer class="h-[200px]" />
5
+ <NuxtLayout>
6
+ <NuxtPage />
7
+ </NuxtLayout>
5
8
  </template>
6
9
 
7
10
  <style>
8
- @import 'swiper/css';
9
- @import 'swiper/css/navigation';
10
- @import 'swiper/css/pagination';
11
- @import '@fancyapps/ui/dist/fancybox/fancybox.css';
12
11
  </style>
@@ -39,6 +39,14 @@ const props = defineProps({
39
39
  required: false,
40
40
  default: 'text-red-500 mt-2',
41
41
  },
42
+ gtagEvent: {
43
+ type: String,
44
+ required: false,
45
+ },
46
+ gtagEventParams: {
47
+ type: Object,
48
+ required: false,
49
+ },
42
50
  })
43
51
 
44
52
  const state = reactive({
@@ -118,6 +126,10 @@ const onSubmit = async (values, { resetForm }) => {
118
126
  state.submitting = false
119
127
  }
120
128
  if (state.submitResponse.success) {
129
+ if (props.gtagEvent && typeof window !== 'undefined' && typeof window.gtag === 'function') {
130
+ const hasParams = props.gtagEventParams && Object.keys(props.gtagEventParams).length > 0
131
+ window.gtag('event', props.gtagEvent, hasParams ? props.gtagEventParams : undefined)
132
+ }
121
133
  resetForm()
122
134
  }
123
135
  }
@@ -128,7 +140,7 @@ const onSubmit = async (values, { resetForm }) => {
128
140
  :validation-schema="props.validationSchema"
129
141
  @submit="onSubmit"
130
142
  >
131
- <slot :submitting="state.submitting" :submit-response="state.submitResponse"></slot>
143
+ <slot :submitting="state.submitting" :submit-response="state.submitResponse" />
132
144
  <VueTurnstile
133
145
  v-if="props.turnstileSiteSecret"
134
146
  v-model="state.turnstileToken"
@@ -42,11 +42,11 @@ defineOptions({ inheritAttrs: false })
42
42
  type="file"
43
43
  v-bind="{ ...field, ...$attrs }"
44
44
  @change="handleChange($event)"
45
- />
45
+ >
46
46
  <input
47
47
  v-else
48
48
  v-bind="{ ...field, ...$attrs }"
49
- />
49
+ >
50
50
  </Field>
51
51
  <ErrorMessage :class="props.errorClass" :name="props.name" />
52
52
  </template>
package/app/error.vue ADDED
@@ -0,0 +1,59 @@
1
+ <script setup>
2
+ const props = defineProps({
3
+ error: {
4
+ type: Object,
5
+ default: () => ({}),
6
+ },
7
+ })
8
+
9
+ const statusCode = computed(() => props.error?.statusCode || 500)
10
+ const isNotFound = computed(() => statusCode.value === 404)
11
+ </script>
12
+
13
+ <template>
14
+ <Head>
15
+ <Title>Edge Website - An awesome Edge website</Title>
16
+ <Meta name="description" content="This is an Edge website template" />
17
+ <Link rel="canonical" href="https://edgemarketingdesign.com/" />
18
+ </Head>
19
+
20
+ <template v-if="isNotFound">
21
+ <titleSection
22
+ page="404"
23
+ headline="404 - Page Not Found"
24
+ />
25
+ <div class="min-h-[calc(100vh_-_586px)] w-full items-center justify-center flex flex-col">
26
+ <h1 class="text-6xl font-bold mb-4">
27
+ 404
28
+ </h1>
29
+ <p class="text-xl mb-2">
30
+ Page Not Found
31
+ </p>
32
+ <p class="text-md text-gray-400 mb-6">
33
+ Looks like this page took an early return and never came back.<br>
34
+ Maybe it hit a <code class="bg-gray-800 px-1 py-0.5 rounded">null pointer</code>... or just rage-quit the DOM.
35
+ </p>
36
+ <a href="/" class="px-6 py-2 mt-2 transition-colors bg-lblue text-dblue hover:bg-opacity-80">
37
+ Go Home & Debug Later
38
+ </a>
39
+ </div>
40
+ </template>
41
+ <div v-else class="min-h-[calc(100vh_-_586px)] w-full items-center justify-center flex flex-col">
42
+ <titleSection
43
+ page="Error"
44
+ headline="Something went wrong"
45
+ />
46
+ <h1 class="text-6xl font-bold mb-4">
47
+ {{ statusCode }}
48
+ </h1>
49
+ <p class="text-xl mb-2">
50
+ Unexpected Error
51
+ </p>
52
+ <p class="text-md text-gray-400 mb-6">
53
+ Please try again or head back home.
54
+ </p>
55
+ <a href="/" class="px-6 py-2 mt-2 transition-colors bg-lblue text-dblue hover:bg-opacity-80">
56
+ Go Home & Debug Later
57
+ </a>
58
+ </div>
59
+ </template>
@@ -0,0 +1,6 @@
1
+ <template>
2
+ <slot />
3
+ </template>
4
+
5
+ <style>
6
+ </style>
@@ -0,0 +1,12 @@
1
+ <template>
2
+ <edge-navbar />
3
+ <slot />
4
+ <edge-footer class="h-[200px]" />
5
+ </template>
6
+
7
+ <style>
8
+ @import 'swiper/css';
9
+ @import 'swiper/css/navigation';
10
+ @import 'swiper/css/pagination';
11
+ @import '@fancyapps/ui/dist/fancybox/fancybox.css';
12
+ </style>
@@ -1,5 +1,6 @@
1
1
  <script setup>
2
2
  import { useAsyncData } from '#imports'
3
+ import { createError } from 'h3'
3
4
  import { useRoute } from 'vue-router'
4
5
 
5
6
  const route = useRoute()
@@ -8,6 +9,13 @@ const { collection, slug } = route.params
8
9
  const { data: project } = await useAsyncData(`${collection}-${slug}`, () => {
9
10
  return queryCollection(collection).path(`/${collection}/${slug}`).first()
10
11
  })
12
+
13
+ if (!project.value) {
14
+ throw createError({
15
+ statusCode: 404,
16
+ statusMessage: 'Page not found',
17
+ })
18
+ }
11
19
  </script>
12
20
 
13
21
  <template>
@@ -1,4 +1,5 @@
1
1
  <script setup>
2
+ import { createError } from 'h3'
2
3
  import { NuxtLink } from '#components'
3
4
  import { useAsyncData } from '#imports'
4
5
  import { useRoute } from 'vue-router'
@@ -9,6 +10,13 @@ const { collection } = route.params
9
10
  const { data: projects } = await useAsyncData(collection, () => {
10
11
  return queryCollection(collection).order('date', 'DESC').all()
11
12
  })
13
+
14
+ if (!projects.value || projects.value.length === 0) {
15
+ throw createError({
16
+ statusCode: 404,
17
+ statusMessage: 'Page not found',
18
+ })
19
+ }
12
20
  </script>
13
21
 
14
22
  <template>
@@ -0,0 +1,7 @@
1
+ <script setup>
2
+
3
+ </script>
4
+
5
+ <template>
6
+ test admin index page
7
+ </template>
@@ -0,0 +1,12 @@
1
+ <script setup>
2
+ import { definePageMeta } from '#imports'
3
+
4
+ definePageMeta({ layout: 'admin' })
5
+ </script>
6
+
7
+ <template class="font-sans">
8
+ <NuxtPage />
9
+ </template>
10
+
11
+ <style>
12
+ </style>
@@ -49,7 +49,7 @@ onMounted(() => {
49
49
  success-class="text-green-500"
50
50
  error-class="text-red-500"
51
51
  >
52
- <Input
52
+ <edge-input
53
53
  type="text"
54
54
  placeholder="Name"
55
55
  name="name"
@@ -59,7 +59,7 @@ onMounted(() => {
59
59
  <!-- Email and Phone -->
60
60
  <div class="max-w-[400px] my-2 grid grid-cols-1 gap-4 md:grid-cols-2">
61
61
  <div>
62
- <Input
62
+ <edge-input
63
63
  type="email"
64
64
  placeholder="Email"
65
65
  name="email"
@@ -68,7 +68,7 @@ onMounted(() => {
68
68
  />
69
69
  </div>
70
70
  <div>
71
- <Input
71
+ <edge-input
72
72
  type="phone"
73
73
  placeholder="Phone"
74
74
  name="phone"
@@ -79,7 +79,7 @@ onMounted(() => {
79
79
  </div>
80
80
  <!-- Message -->
81
81
  <div>
82
- <Textarea
82
+ <edge-textarea
83
83
  name="message"
84
84
  placeholder="Message"
85
85
  class="w-full h-32 px-4 py-2 mt-2 border border-gray-300 resize-none focus:outline-none"
@@ -0,0 +1,21 @@
1
+ export default defineNuxtPlugin((nuxtApp) => {
2
+ let host = ''
3
+
4
+ // Works locally (node SSR)
5
+ const nodeReq = nuxtApp.ssrContext?.event?.node?.req
6
+ if (nodeReq?.headers?.host) {
7
+ host = nodeReq.headers.host
8
+ }
9
+
10
+ // Works in Cloudflare Pages Functions
11
+ const webReq = nuxtApp.ssrContext?.event?.req || nuxtApp.ssrContext?.event?.request
12
+ if (!host && typeof webReq?.headers?.get === 'function') {
13
+ host = webReq.headers.get('host') || ''
14
+ }
15
+
16
+ host = host.replace(/^www\./, '')
17
+ const baseURL = host.startsWith('localhost') || host.startsWith('192.') ? `http://${host}` : `https://${host}`
18
+
19
+ nuxtApp.provide('domain', host)
20
+ nuxtApp.provide('baseURL', baseURL)
21
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgedev/create-edge-site",
3
- "version": "1.0.15",
3
+ "version": "1.0.16",
4
4
  "description": "Create Edge Starter Site",
5
5
  "bin": {
6
6
  "create-edge-site": "./bin/cli.js"
@@ -14,27 +14,28 @@
14
14
  "postinstall": "nuxt prepare"
15
15
  },
16
16
  "dependencies": {
17
- "@fancyapps/ui": "^5.0.36",
18
- "@nuxt/content": "^3.6.3",
17
+ "@fancyapps/ui": "5.0.36",
18
+ "@nuxt/content": "3.6.3",
19
19
  "@nuxtjs/tailwindcss": "6.13.2",
20
- "@vee-validate/nuxt": "^4.15.0",
21
- "@vee-validate/rules": "^4.15.0",
22
- "@vee-validate/zod": "^4.15.0",
23
- "better-sqlite3": "^12.2.0",
24
- "nuxt": "^3.16.1",
25
- "scrollreveal": "^4.0.9",
26
- "swiper": "^11.2.6",
27
- "vue": "^3.5.13",
28
- "vue-imask": "^7.6.1",
29
- "vue-router": "^4.5.0",
30
- "vue-turnstile": "^1.0.11",
31
- "zod": "^3.24.2"
20
+ "@vee-validate/nuxt": "4.15.0",
21
+ "@vee-validate/rules": "4.15.0",
22
+ "@vee-validate/zod": "4.15.0",
23
+ "better-sqlite3": "12.2.0",
24
+ "nuxt": "3.16.1",
25
+ "scrollreveal": "4.0.9",
26
+ "swiper": "11.2.6",
27
+ "vue": "3.5.13",
28
+ "vue-imask": "7.6.1",
29
+ "vue-router": "4.5.0",
30
+ "vue-turnstile": "1.0.11",
31
+ "zod": "3.24.2"
32
32
  },
33
33
  "devDependencies": {
34
- "@antfu/eslint-config": "^4.11.0",
35
- "eslint": "^9",
36
- "eslint-plugin-nuxt": "^4.0.0",
37
- "nitro-cloudflare-dev": "^0.2.2",
38
- "wrangler": "^4.14.4"
34
+ "@antfu/eslint-config": "4.11.0",
35
+ "eslint": "9.23.0",
36
+ "eslint-plugin-nuxt": "4.0.0",
37
+ "nitro-cloudflare-dev": "0.2.2",
38
+ "typescript": "5.9.3",
39
+ "wrangler": "4.14.4"
39
40
  }
40
41
  }
@@ -1,3 +1,5 @@
1
- export default defineEventHandler(() => {
1
+ export default defineEventHandler((event) => {
2
+ const context = event.context
3
+ const config = useRuntimeConfig()
2
4
  return { message: 'Hello from World' }
3
5
  })
@@ -0,0 +1,246 @@
1
+ import { useRuntimeConfig } from '#imports'
2
+ // server/api/images.upload.post.ts
3
+ import { createError, defineEventHandler, readMultipartFormData } from 'h3'
4
+
5
+ function getCfg(cfg = useRuntimeConfig()) {
6
+ const accountId = cfg.cfAccountId ?? process.env.NUXT_CF_ACCOUNT_ID
7
+ const apiToken = cfg.cfApiToken ?? process.env.NUXT_CF_API_TOKEN
8
+ const imagesHash = cfg.cfImagesAccountHash ?? process.env.NUXT_CF_IMAGES_HASH
9
+ let variants: string[]
10
+ = Array.isArray(cfg.cfImagesVariants)
11
+ ? cfg.cfImagesVariants
12
+ : (typeof cfg.cfImagesVariants === 'string'
13
+ ? cfg.cfImagesVariants.split(',').map(s => s.trim()).filter(Boolean)
14
+ : [])
15
+
16
+ if (!accountId)
17
+ throw new Error('Missing cfAccountId / NUXT_CF_ACCOUNT_ID')
18
+ if (!apiToken)
19
+ throw new Error('Missing cfApiToken / NUXT_CF_API_TOKEN')
20
+ if (!imagesHash)
21
+ throw new Error('Missing cfImagesAccountHash / NUXT_CF_IMAGES_HASH')
22
+ if (!variants.length) {
23
+ // sensible default if you have a “public” variant configured
24
+ variants = ['public', 'thumbnail']
25
+ }
26
+
27
+ return { accountId, apiToken, imagesHash, variants }
28
+ }
29
+
30
+ export default defineEventHandler(async (event) => {
31
+ if (event.node.req.method !== 'POST') {
32
+ throw createError({ statusCode: 405, statusMessage: 'Method Not Allowed' })
33
+ }
34
+
35
+ const config = useRuntimeConfig()
36
+ const turnstileSecretKey = config.turnstileSecretKey || process.env.NUXT_TURNSTILE_SECRET_KEY || ''
37
+ if (turnstileSecretKey) {
38
+ const tokenHeader = event.node.req.headers['cf-turnstile-response']
39
+ const token = Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader
40
+ if (typeof token !== 'string' || !token) {
41
+ throw createError({ statusCode: 400, statusMessage: 'Missing Turnstile token' })
42
+ }
43
+
44
+ const verifyResponse = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
45
+ method: 'POST',
46
+ headers: {
47
+ 'Content-Type': 'application/x-www-form-urlencoded',
48
+ },
49
+ body: new URLSearchParams({
50
+ secret: turnstileSecretKey,
51
+ response: token,
52
+ remoteip: Array.isArray(event.node.req.headers['cf-connecting-ip'])
53
+ ? event.node.req.headers['cf-connecting-ip'][0]
54
+ : (event.node.req.headers['cf-connecting-ip'] || ''),
55
+ }),
56
+ })
57
+
58
+ if (!verifyResponse.ok) {
59
+ throw createError({ statusCode: 500, statusMessage: 'Failed to verify Turnstile token' })
60
+ }
61
+
62
+ const verifyData = await verifyResponse.json()
63
+ if (!verifyData.success) {
64
+ throw createError({ statusCode: 400, statusMessage: 'Invalid Turnstile token' })
65
+ }
66
+ }
67
+
68
+ const { accountId, apiToken, imagesHash, variants } = getCfg(config)
69
+
70
+ const parts = await readMultipartFormData(event)
71
+ if (!parts || !parts.length) {
72
+ throw createError({ statusCode: 400, statusMessage: 'No form data' })
73
+ }
74
+
75
+ const filePart = parts.find(p => p.filename && p.data)
76
+ if (!filePart) {
77
+ throw createError({ statusCode: 400, statusMessage: 'Missing file upload (field "file")' })
78
+ }
79
+
80
+ // Convert Buffer -> File for FormData
81
+ const filename = filePart.filename || 'upload.bin'
82
+ console.warn('Uploading file:', filename, 'size:', filePart.data?.length, 'type:', filePart.type)
83
+ const mime = filePart.type || 'application/octet-stream'
84
+ const blob = new Blob([filePart.data], { type: mime })
85
+ // @ts-ignore: File is available in Node 18+ (undici)
86
+ const file = new File([blob], filename, { type: mime })
87
+
88
+ // Optional metadata fields (if you posted them in the same form)
89
+ const metadataPart = parts.find(p => p.name === 'metadata' && !p.filename)
90
+ const requireSignedPart = parts.find(p => p.name === 'requireSignedURLs' && !p.filename)
91
+
92
+ const form = new FormData()
93
+ form.append('file', file)
94
+ if (metadataPart?.data?.length) {
95
+ // accept either JSON or plain string metadata
96
+ const asText = metadataPart.data.toString('utf8')
97
+ try {
98
+ JSON.parse(asText)
99
+ form.append('metadata', asText)
100
+ }
101
+ catch {
102
+ // If not JSON, wrap as a string field
103
+ form.append('metadata', JSON.stringify({ note: asText }))
104
+ }
105
+ }
106
+ if (requireSignedPart) {
107
+ const v = requireSignedPart.data.toString('utf8').trim().toLowerCase()
108
+ if (v === 'true' || v === '1')
109
+ form.append('requireSignedURLs', 'true')
110
+ }
111
+
112
+ // Upload to Cloudflare Images (REST)
113
+ const apiUrl = `https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1`
114
+ const res = await fetch(apiUrl, {
115
+ method: 'POST',
116
+ headers: { Authorization: `Bearer ${apiToken}` },
117
+ body: form,
118
+ })
119
+
120
+ const text = await res.text()
121
+ let json: any
122
+ try { json = JSON.parse(text) }
123
+ catch { /* keep text for error */ }
124
+
125
+ if (!res.ok || !json?.success) {
126
+ const msg
127
+ = (json?.errors && json.errors[0]?.message)
128
+ || json?.messages?.[0]
129
+ || text
130
+ || `Upload failed with status ${res.status}`
131
+ throw createError({ statusCode: 502, statusMessage: `Cloudflare Images upload error: ${msg}` })
132
+ }
133
+
134
+ const id: string = json.result?.id
135
+ if (!id) {
136
+ throw createError({ statusCode: 502, statusMessage: 'Upload succeeded but no image id returned' })
137
+ }
138
+
139
+ // Helper to extract variant name from URL
140
+ function variantNameFromUrl(u: string): string | null {
141
+ try {
142
+ const url = new URL(u)
143
+ const parts = url.pathname.split('/').filter(Boolean) // ['', 'HASH', 'ID', 'variant']
144
+ return parts[3] || null
145
+ }
146
+ catch {
147
+ return null
148
+ }
149
+ }
150
+
151
+ // Prefer the variant URLs returned by the API (they are authoritative)
152
+ const variantsMap: Record<string, string> = {}
153
+ if (Array.isArray(json.result?.variants)) {
154
+ for (const u of json.result.variants) {
155
+ const name = variantNameFromUrl(u)
156
+ if (name)
157
+ variantsMap[name] = u
158
+ }
159
+ }
160
+
161
+ // Ensure configured variants exist in the map (construct if missing)
162
+ for (const v of variants) {
163
+ if (!variantsMap[v]) {
164
+ variantsMap[v] = `https://imagedelivery.net/${imagesHash}/${id}/${v}`
165
+ }
166
+ }
167
+
168
+ // If API returned variant URLs, verify their HASH matches configured imagesHash
169
+ let hashMismatch: { configured: string, observed: string } | null = null
170
+ const anyVariantUrl = Object.values(variantsMap)[0]
171
+ if (anyVariantUrl) {
172
+ try {
173
+ const parts = new URL(anyVariantUrl).pathname.split('/').filter(Boolean) // HASH/ID/variant
174
+ const observedHash = parts[0]
175
+ if (observedHash && observedHash !== imagesHash) {
176
+ hashMismatch = { configured: imagesHash, observed: observedHash }
177
+ }
178
+ }
179
+ catch {}
180
+ }
181
+
182
+ // Sleep helper for simple backoff
183
+ const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
184
+
185
+ // Single HEAD probe
186
+ async function headProbe(url: string): Promise<{ ok: boolean, status: number }> {
187
+ try {
188
+ const r = await fetch(url, { method: 'HEAD' })
189
+ return { ok: r.ok, status: r.status }
190
+ }
191
+ catch {
192
+ return { ok: false, status: 0 }
193
+ }
194
+ }
195
+
196
+ // Probe delivery with small exponential backoff to smooth over manifest lag
197
+ const probe: Record<string, { ok: boolean, status: number, note?: string, attempts: number }> = {}
198
+ await Promise.all(Object.entries(variantsMap).map(async ([name, url]) => {
199
+ const delays = [0, 200, 400, 800] // ms
200
+ let last = { ok: false, status: 0 }
201
+ for (let i = 0; i < delays.length; i++) {
202
+ if (delays[i] > 0)
203
+ await sleep(delays[i])
204
+ last = await headProbe(url)
205
+ // Exit early unless this is a transient 500
206
+ if (last.ok || (last.status !== 500 && last.status !== 0)) {
207
+ probe[name] = {
208
+ ok: last.ok,
209
+ status: last.status,
210
+ note:
211
+ last.status === 403
212
+ ? 'Forbidden (variant may require signed URL)'
213
+ : last.status === 404
214
+ ? 'Not found (check hash/id/variant name)'
215
+ : last.status === 500
216
+ ? 'Image manifest not ready/available yet'
217
+ : last.status === 0
218
+ ? 'HEAD request failed'
219
+ : undefined,
220
+ attempts: i + 1,
221
+ }
222
+ return
223
+ }
224
+ }
225
+ // If we reach here, still failing with 500/0 after retries
226
+ probe[name] = {
227
+ ok: last.ok,
228
+ status: last.status,
229
+ note:
230
+ last.status === 500
231
+ ? 'Persistent 500 after retries (manifest not ready or account/variant mismatch)'
232
+ : last.status === 0
233
+ ? 'HEAD failed after retries'
234
+ : undefined,
235
+ attempts: delays.length,
236
+ }
237
+ }))
238
+
239
+ return {
240
+ success: true,
241
+ id,
242
+ variants: variantsMap,
243
+ probe,
244
+ ...(hashMismatch ? { hashMismatch } : {}),
245
+ }
246
+ })
@@ -0,0 +1,49 @@
1
+ import { useRuntimeConfig } from '#imports'
2
+
3
+ export async function deleteKVValue(key, context) {
4
+ const config = useRuntimeConfig()
5
+ const isDev = import.meta.dev
6
+ const MY_KV = context?.cloudflare?.env?.MY_KV
7
+
8
+ const deleteFromAPI = async () => {
9
+ const { cfAccountId, cfKVNamespaceId, cfApiToken } = config
10
+ const url = `https://api.cloudflare.com/client/v4/accounts/${cfAccountId}/storage/kv/namespaces/${cfKVNamespaceId}/values/${key}`
11
+ const res = await fetch(url, {
12
+ method: 'DELETE',
13
+ headers: {
14
+ Authorization: `Bearer ${cfApiToken}`,
15
+ },
16
+ })
17
+ if (!res.ok) {
18
+ console.warn('❌ Remote delete failed:', await res.text())
19
+ return false
20
+ }
21
+ return true
22
+ }
23
+
24
+ let deletedLocally = false
25
+
26
+ if (typeof MY_KV !== 'undefined') {
27
+ try {
28
+ await MY_KV.delete(key)
29
+ console.log('🗑️ Deleted from local KV')
30
+ deletedLocally = true
31
+ }
32
+ catch (e) {
33
+ console.warn('⚠️ Failed to delete from local KV:', e)
34
+ }
35
+ }
36
+
37
+ if (isDev) {
38
+ console.log('🛠 Dev mode – syncing delete to API')
39
+ const deletedRemotely = await deleteFromAPI()
40
+ return deletedLocally || deletedRemotely
41
+ }
42
+
43
+ if (typeof MY_KV === 'undefined') {
44
+ console.log('🌐 No KV available, deleting via API')
45
+ return await deleteFromAPI()
46
+ }
47
+
48
+ return deletedLocally
49
+ }
@@ -0,0 +1,74 @@
1
+ import { useRuntimeConfig } from '#imports'
2
+
3
+ export async function getKVValuesByPrefix(prefix, context) {
4
+ const config = useRuntimeConfig()
5
+ const isDev = import.meta.dev
6
+ const MY_KV = context?.cloudflare?.env?.MY_KV
7
+
8
+ const fetchKeysFromAPI = async () => {
9
+ const { cfAccountId, cfKVNamespaceId, cfApiToken } = config
10
+ const url = `https://api.cloudflare.com/client/v4/accounts/${cfAccountId}/storage/kv/namespaces/${cfKVNamespaceId}/keys?prefix=${encodeURIComponent(prefix)}`
11
+ const res = await fetch(url, {
12
+ headers: {
13
+ Authorization: `Bearer ${cfApiToken}`,
14
+ },
15
+ })
16
+ if (!res.ok) {
17
+ console.warn('❌ Failed to list keys from API')
18
+ return []
19
+ }
20
+ const data = await res.json()
21
+ return data.result?.map(k => k.name) || []
22
+ }
23
+
24
+ const fetchValuesFromAPI = async (keys) => {
25
+ const { cfAccountId, cfKVNamespaceId, cfApiToken } = config
26
+ const results = []
27
+ for (const key of keys) {
28
+ const res = await fetch(`https://api.cloudflare.com/client/v4/accounts/${cfAccountId}/storage/kv/namespaces/${cfKVNamespaceId}/values/${key}`, {
29
+ headers: {
30
+ Authorization: `Bearer ${cfApiToken}`,
31
+ },
32
+ })
33
+ if (res.ok) {
34
+ const text = await res.text()
35
+ results.push({ key, value: text })
36
+ }
37
+ }
38
+ return results
39
+ }
40
+
41
+ // Local KV available
42
+ if (typeof MY_KV !== 'undefined') {
43
+ const keys = await MY_KV.list({ prefix })
44
+ const values = []
45
+ for (const key of keys.keys || []) {
46
+ const value = await MY_KV.get(key.name)
47
+ values.push({ key: key.name, value })
48
+ }
49
+
50
+ if (values.length > 0) {
51
+ console.warn('✅ Retrieved from local KV')
52
+ return values
53
+ }
54
+
55
+ if (isDev) {
56
+ console.warn('🔍 No local matches, trying API fallback (dev only)')
57
+ const remoteKeys = await fetchKeysFromAPI()
58
+ return await fetchValuesFromAPI(remoteKeys)
59
+ }
60
+
61
+ console.warn('🚫 No results and no API fallback in production')
62
+ return []
63
+ }
64
+
65
+ // No KV available
66
+ if (isDev) {
67
+ console.warn('🌐 No KV available, fetching from API (dev only)')
68
+ const remoteKeys = await fetchKeysFromAPI()
69
+ return await fetchValuesFromAPI(remoteKeys)
70
+ }
71
+
72
+ console.warn('🚫 No KV and no API fallback in production')
73
+ return []
74
+ }
@@ -0,0 +1,63 @@
1
+ import { useRuntimeConfig } from '#imports'
2
+
3
+ export async function putKVValue(key, value, context) {
4
+ const config = useRuntimeConfig()
5
+ const isDev = import.meta.dev
6
+ const MY_KV = context?.cloudflare?.env?.MY_KV
7
+
8
+ // Auto-stringify objects and arrays
9
+ let toStore = value
10
+ if (typeof value === 'object' && value !== null) {
11
+ value.key = key // Ensure the key is part of the stored value
12
+ try {
13
+ toStore = JSON.stringify(value)
14
+ }
15
+ catch (e) {
16
+ console.warn('⚠️ Failed to stringify value:', e)
17
+ return false
18
+ }
19
+ }
20
+
21
+ const writeToAPI = async () => {
22
+ const { cfAccountId, cfKVNamespaceId, cfApiToken } = config
23
+ const url = `https://api.cloudflare.com/client/v4/accounts/${cfAccountId}/storage/kv/namespaces/${cfKVNamespaceId}/values/${key}`
24
+ const res = await fetch(url, {
25
+ method: 'PUT',
26
+ headers: {
27
+ 'Authorization': `Bearer ${cfApiToken}`,
28
+ 'Content-Type': 'text/plain',
29
+ },
30
+ body: toStore,
31
+ })
32
+ if (!res.ok) {
33
+ console.warn('❌ Remote write failed:', await res.text())
34
+ return false
35
+ }
36
+ return true
37
+ }
38
+
39
+ // Always try local KV first if available
40
+ if (typeof MY_KV !== 'undefined') {
41
+ try {
42
+ await MY_KV.put(key, toStore)
43
+ console.log('✅ Wrote to local KV')
44
+ }
45
+ catch (e) {
46
+ console.warn('⚠️ Failed to write to local KV:', e)
47
+ }
48
+ }
49
+
50
+ // Dev mode: also sync up to the Cloudflare API
51
+ if (isDev) {
52
+ console.log('🛠 Dev mode – syncing write to API')
53
+ return await writeToAPI()
54
+ }
55
+
56
+ // Production: only write to API if no KV available
57
+ if (typeof MY_KV === 'undefined') {
58
+ console.log('🌐 No KV available, writing directly to API')
59
+ return await writeToAPI()
60
+ }
61
+
62
+ return true
63
+ }
@@ -0,0 +1,67 @@
1
+ import { useRuntimeConfig } from '#imports'
2
+
3
+ export async function sendEmail(toEmail, fromEmail, replyToEmail, subject, messageText, messageHtml) {
4
+ const config = useRuntimeConfig()
5
+ const apiKey = config.sendgridApiKey || process.env.NUXT_SENDGRID_API_KEY
6
+
7
+ if (!apiKey) {
8
+ throw new Error('SendGrid API key is missing. Set NUXT_SENDGRID_API_KEY in your environment.')
9
+ }
10
+
11
+ if (!toEmail || !fromEmail) {
12
+ throw new Error('Both toEmail and fromEmail are required.')
13
+ }
14
+
15
+ if (!subject) {
16
+ throw new Error('Email subject is required.')
17
+ }
18
+
19
+ if (!messageText && !messageHtml) {
20
+ throw new Error('At least one of messageText or messageHtml must be provided.')
21
+ }
22
+
23
+ const payload = {
24
+ personalizations: [
25
+ {
26
+ to: [{ email: toEmail }],
27
+ },
28
+ ],
29
+ from: { email: fromEmail },
30
+ subject,
31
+ content: [],
32
+ }
33
+
34
+ if (replyToEmail) {
35
+ payload.reply_to = { email: replyToEmail }
36
+ }
37
+
38
+ if (messageText) {
39
+ payload.content.push({
40
+ type: 'text/plain',
41
+ value: messageText,
42
+ })
43
+ }
44
+
45
+ if (messageHtml) {
46
+ payload.content.push({
47
+ type: 'text/html',
48
+ value: messageHtml,
49
+ })
50
+ }
51
+
52
+ const response = await fetch('https://api.sendgrid.com/v3/mail/send', {
53
+ method: 'POST',
54
+ headers: {
55
+ 'Authorization': `Bearer ${apiKey}`,
56
+ 'Content-Type': 'application/json',
57
+ },
58
+ body: JSON.stringify(payload),
59
+ })
60
+
61
+ if (!response.ok) {
62
+ const errorBody = await response.text()
63
+ throw new Error(`SendGrid request failed with status ${response.status}: ${errorBody}`)
64
+ }
65
+
66
+ return true
67
+ }
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env sh
2
+ # POSIX-compatible script.
3
+ # Purpose: Read .env.wrangler and upload **all** keys as Cloudflare Pages **Secrets**.
4
+ # - No wrangler.toml modifications
5
+ # - No REST API usage
6
+ # - Works even if .env.wrangler has no trailing newline
7
+ # - Skips blank lines and comments, trims whitespace, strips surrounding quotes
8
+ #
9
+ # Usage:
10
+ # export CF_PAGES_PROJECT=openhousemap # optional but recommended
11
+ # chmod +x set-wrangler-secrets.sh
12
+ # ./set-wrangler-secrets.sh
13
+
14
+ set -eu
15
+
16
+ ENV_FILE=".env.wrangler"
17
+
18
+ # Optional: pin the Pages project (avoids prompts)
19
+ CF_PAGES_PROJECT="${CF_PAGES_PROJECT-}"
20
+
21
+ wrangler_pages() {
22
+ if [ -n "$CF_PAGES_PROJECT" ]; then
23
+ npx wrangler pages "$@" --project-name "$CF_PAGES_PROJECT"
24
+ else
25
+ npx wrangler pages "$@"
26
+ fi
27
+ }
28
+
29
+ if [ ! -f "$ENV_FILE" ]; then
30
+ echo "❌ File $ENV_FILE not found!" >&2
31
+ exit 1
32
+ fi
33
+
34
+ echo "🔐 Uploading ALL entries in $ENV_FILE as Cloudflare Pages Secrets..."
35
+
36
+ # Read raw lines, process each exactly once
37
+ while IFS= read -r raw || [ -n "$raw" ]; do
38
+ # Trim surrounding whitespace from the whole line
39
+ line=$(printf '%s' "$raw" | awk '{$1=$1};1')
40
+
41
+ # Skip blanks and comments
42
+ [ -z "$line" ] && continue
43
+ first_char=$(printf '%s' "$line" | cut -c1)
44
+ [ "$first_char" = "#" ] && continue
45
+
46
+ # Split only on the first '='
47
+ case "$line" in
48
+ *=*)
49
+ key=${line%%=*}
50
+ value=${line#*=}
51
+ ;;
52
+ *)
53
+ # No '=', skip
54
+ continue
55
+ ;;
56
+ esac
57
+
58
+ # Trim key/value
59
+ key=$(printf '%s' "$key" | awk '{$1=$1};1')
60
+ value=$(printf '%s' "$value" | awk '{$1=$1};1')
61
+
62
+ # Strip surrounding double quotes from value
63
+ case "$value" in
64
+ \"*\") value=$(printf '%s' "$value" | sed 's/^"//;s/"$//') ;;
65
+ esac
66
+
67
+ # Skip empty key names defensively
68
+ [ -z "$key" ] && continue
69
+
70
+ echo "🔑 SECRET → Cloudflare Pages: $key"
71
+ printf '%s' "$value" | wrangler_pages secret put "$key"
72
+
73
+ done < "$ENV_FILE"
74
+
75
+ echo "✅ Done. All keys uploaded as Secrets."
package/wrangler.toml CHANGED
@@ -1,3 +1,7 @@
1
+ name = "edgeproject"
2
+ compatibility_date = "2024-09-23"
3
+ pages_build_output_dir = "dist"
4
+
1
5
  [[kv_namespaces]]
2
6
  binding = "MY_KV"
3
7
  id = "ce7ff0d3eb6f43ae84611b5d9e28b6f1"
@@ -1,33 +0,0 @@
1
- <script setup>
2
- onMounted(() => {
3
- console.log('Hello world.')
4
- })
5
- </script>
6
-
7
- <template>
8
- <Head>
9
- <Title>Edge Website - An awesome Edge website</Title>
10
- <Meta name="description" content="This is an Edge website template" />
11
- <Link rel="canonical" href="https://edgemarketingdesign.com/" />
12
- </Head>
13
-
14
- <titleSection
15
- page="404"
16
- headline="404 - Page Not Found"
17
- />
18
- <div class="min-h-[calc(100vh_-_586px)] w-full items-center justify-center flex flex-col">
19
- <h1 class="text-6xl font-bold mb-4">
20
- 404
21
- </h1>
22
- <p class="text-xl mb-2">
23
- Page Not Found
24
- </p>
25
- <p class="text-md text-gray-400 mb-6">
26
- Looks like this page took an early return and never came back.<br />
27
- Maybe it hit a <code class="bg-gray-800 px-1 py-0.5 rounded">null pointer</code>... or just rage-quit the DOM.
28
- </p>
29
- <a href="/" class="px-6 py-2 mt-2 transition-colors bg-lblue text-dblue hover:bg-opacity-80">
30
- Go Home & Debug Later
31
- </a>
32
- </div>
33
- </template>
File without changes
File without changes