@edgedev/create-edge-site 1.0.14 → 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>
@@ -43,7 +43,7 @@ onUnmounted(() => {
43
43
  <div class="container mx-auto flex items-center justify-between h-[128px]">
44
44
  <!-- Logo -->
45
45
  <NuxtLink to="/" class="text-xl font-bold">
46
- <img src="/images/logo.png" alt="MT" class="w-full h-auto" />
46
+ <img src="/images/logo.png" alt="MT" class="w-full h-auto">
47
47
  </NuxtLink>
48
48
 
49
49
  <!-- Mobile Menu Button -->
@@ -68,6 +68,11 @@ onUnmounted(() => {
68
68
  Stuff
69
69
  </NuxtLink>
70
70
  </li>
71
+ <li>
72
+ <NuxtLink to="/projects" class="nav-item">
73
+ Projects
74
+ </NuxtLink>
75
+ </li>
71
76
  <li>
72
77
  <NuxtLink to="/contact" class="nav-item">
73
78
  Contact
@@ -86,18 +91,18 @@ onUnmounted(() => {
86
91
  </button>
87
92
 
88
93
  <!-- Mobile Logo -->
89
- <a href="/"><img src="/images/logo.png" alt="Logo" class="mb-4 w-50" /></a>
94
+ <a href="/"><img src="/images/logo.png" alt="Logo" class="mb-4 w-50"></a>
90
95
 
91
96
  <!-- Social Media Icons -->
92
97
  <div class="flex justify-center my-6 space-x-4">
93
98
  <a href="#" class="flex items-center justify-center w-12 h-12 transition rounded-full bg-lblue text-dblue hover:bg-opacity-80">
94
- <i class="fab fa-facebook-f fa-lg"></i>
99
+ <i class="fab fa-facebook-f fa-lg" />
95
100
  </a>
96
101
  <a href="#" class="flex items-center justify-center w-12 h-12 transition rounded-full bg-lblue text-dblue hover:bg-opacity-80">
97
- <i class="fab fa-instagram fa-lg"></i>
102
+ <i class="fab fa-instagram fa-lg" />
98
103
  </a>
99
104
  <a href="#" class="flex items-center justify-center w-12 h-12 transition rounded-full bg-lblue text-dblue hover:bg-opacity-80">
100
- <i class="fab fa-houzz fa-lg"></i> <!-- Houzz or custom icon -->
105
+ <i class="fab fa-houzz fa-lg" /> <!-- Houzz or custom icon -->
101
106
  </a>
102
107
  </div>
103
108
 
@@ -113,6 +118,11 @@ onUnmounted(() => {
113
118
  Stuff
114
119
  </NuxtLink>
115
120
  </li>
121
+ <li class="border-t border-b border-lblue">
122
+ <NuxtLink to="/projects" class="text-lg tracking-widest uppercase" @click="closeMenu">
123
+ Projects
124
+ </NuxtLink>
125
+ </li>
116
126
  <li class="border-t border-b border-lblue">
117
127
  <NuxtLink to="/contact" class="text-lg tracking-widest uppercase" @click="closeMenu">
118
128
  Contact
@@ -31,13 +31,13 @@ onMounted(() => {
31
31
  :class="isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'"
32
32
  >
33
33
  <h6
34
- class="inline-flex items-center gap-2 mb-4 transition-all duration-700 ease-out super-head"
34
+ class="inline-flex capitalize items-center gap-2 mb-4 transition-all duration-700 ease-out super-head"
35
35
  :class="isVisible ? 'opacity-100 translate-x-0' : 'opacity-0 -translate-x-4'"
36
36
  >
37
37
  {{ page }}
38
38
  </h6>
39
39
  <h1
40
- class="text-3xl font-light !leading-tight font-fancy md:text-5xl md:pr-80 transition-all duration-700 ease-out delay-100"
40
+ class="text-3xl capitalize font-light !leading-tight font-fancy md:text-5xl md:pr-80 transition-all duration-700 ease-out delay-100"
41
41
  :class="isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'"
42
42
  >
43
43
  {{ headline }}
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>
@@ -0,0 +1,38 @@
1
+ <script setup>
2
+ import { useAsyncData } from '#imports'
3
+ import { createError } from 'h3'
4
+ import { useRoute } from 'vue-router'
5
+
6
+ const route = useRoute()
7
+ const { collection, slug } = route.params
8
+
9
+ const { data: project } = await useAsyncData(`${collection}-${slug}`, () => {
10
+ return queryCollection(collection).path(`/${collection}/${slug}`).first()
11
+ })
12
+
13
+ if (!project.value) {
14
+ throw createError({
15
+ statusCode: 404,
16
+ statusMessage: 'Page not found',
17
+ })
18
+ }
19
+ </script>
20
+
21
+ <template>
22
+ <Head>
23
+ <Title>Edge Website - An awesome Edge website</Title>
24
+ <Meta name="description" content="This is an Edge website template" />
25
+ <Link rel="canonical" href="https://edgemarketingdesign.com/contact" />
26
+ </Head>
27
+
28
+ <titleSection
29
+ :page="collection"
30
+ :headline="project.title"
31
+ />
32
+
33
+ <div class="container flex flex-col items-center justify-center w-full max-w-6xl px-6 pt-10 pb-64 mx-auto">
34
+ <article class="max-w-4xl px-4 py-16 mx-auto">
35
+ <ContentRenderer :value="project" />
36
+ </article>
37
+ </div>
38
+ </template>
@@ -0,0 +1,41 @@
1
+ <script setup>
2
+ import { createError } from 'h3'
3
+ import { NuxtLink } from '#components'
4
+ import { useAsyncData } from '#imports'
5
+ import { useRoute } from 'vue-router'
6
+
7
+ const route = useRoute()
8
+ const { collection } = route.params
9
+
10
+ const { data: projects } = await useAsyncData(collection, () => {
11
+ return queryCollection(collection).order('date', 'DESC').all()
12
+ })
13
+
14
+ if (!projects.value || projects.value.length === 0) {
15
+ throw createError({
16
+ statusCode: 404,
17
+ statusMessage: 'Page not found',
18
+ })
19
+ }
20
+ </script>
21
+
22
+ <template>
23
+ <Head>
24
+ <Title>Edge Website - An awesome Edge website</Title>
25
+ <Meta name="description" content="This is an Edge website template" />
26
+ <Link rel="canonical" href="https://edgemarketingdesign.com/contact" />
27
+ </Head>
28
+
29
+ <titleSection
30
+ page="Content"
31
+ :headline="collection"
32
+ />
33
+
34
+ <div class="container flex flex-col items-center justify-center w-full max-w-6xl px-6 pt-10 pb-64 mx-auto">
35
+ <div v-for="project in projects" :key="project._id" class="mb-12">
36
+ <NuxtLink :to="`${project.path}`">
37
+ {{ project.title }}
38
+ </NuxtLink>
39
+ </div>
40
+ </div>
41
+ </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"
@@ -11,6 +11,6 @@ onMounted(() => {
11
11
  <Link rel="canonical" href="https://edgemarketingdesign.com/" />
12
12
  </Head>
13
13
  <div class="min-h-[calc(100vh_-_328px)] w-full items-center justify-center flex flex-col">
14
- <img src="/images/edge_logo.png" class="w-[400px] h-auto" alt="Edge Marketing Design Logo" />
14
+ <img src="/images/edge_logo.png" class="w-[400px] h-auto" alt="Edge Marketing Design Logo">
15
15
  </div>
16
16
  </template>
@@ -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
+ })
@@ -0,0 +1,6 @@
1
+ ---
2
+ title: "Test Project"
3
+ date: "2025-01-01"
4
+ ---
5
+
6
+ This is just a test.
@@ -0,0 +1,6 @@
1
+ ---
2
+ title: "Test Project 2"
3
+ date: "2025-08-25"
4
+ ---
5
+
6
+ This is just another test.
@@ -0,0 +1,14 @@
1
+ import { defineCollection, defineContentConfig, z } from '@nuxt/content'
2
+
3
+ export default defineContentConfig({
4
+ collections: {
5
+ projects: defineCollection({
6
+ type: 'page',
7
+ source: 'projects/*.md',
8
+ schema: z.object({
9
+ title: z.string(),
10
+ date: z.date(),
11
+ }),
12
+ }),
13
+ },
14
+ })
package/eslint.config.mjs CHANGED
@@ -1,7 +1,37 @@
1
1
  import antfu from '@antfu/eslint-config'
2
2
 
3
+ // Handle CJS interop for Nuxt plugin
4
+ const nuxtPluginModule = await import('eslint-plugin-nuxt')
5
+ const nuxtPlugin = nuxtPluginModule.default ?? nuxtPluginModule
6
+
3
7
  export default [
4
- ...(await antfu()),
8
+ // Antfu base config
9
+ ...await antfu(),
10
+
11
+ // Nuxt recommended rules
12
+ {
13
+ name: 'nuxt',
14
+ plugins: {
15
+ nuxt: nuxtPlugin,
16
+ },
17
+ rules: {
18
+ ...(nuxtPlugin.configs?.recommended?.rules ?? {}),
19
+ },
20
+ },
21
+
22
+ // Explicit globals (add any other auto-imported composables here)
23
+ {
24
+ name: 'globals',
25
+ languageOptions: {
26
+ globals: {
27
+ globalState: 'readonly',
28
+ $fetch: 'readonly',
29
+ queryCollection: 'readonly',
30
+ },
31
+ },
32
+ },
33
+
34
+ // Your custom rule overrides
5
35
  {
6
36
  rules: {
7
37
  'vue/no-deprecated-slot-attribute': 'off',
@@ -9,16 +39,6 @@ export default [
9
39
  'no-console': 'off',
10
40
  'antfu/top-level-function': 'off',
11
41
  'style/eol-last': 'off',
12
- // ✅ Enforce correct self-closing behavior in Vue templates
13
- 'vue/html-self-closing': ['error', {
14
- html: {
15
- void: 'always', // <img />, <br />, <input />, etc.
16
- normal: 'never', // <div></div>, <span></span>, etc.
17
- component: 'always', // <MyComponent />
18
- },
19
- svg: 'always',
20
- math: 'always',
21
- }],
22
42
  },
23
43
  },
24
- ]
44
+ ]
package/nuxt.config.ts CHANGED
@@ -32,7 +32,7 @@ export default defineNuxtConfig({
32
32
  css: [
33
33
  '~/assets/css/global.css', // ✅ Keep global styles only
34
34
  ],
35
- modules: ['@nuxtjs/tailwindcss', '@vee-validate/nuxt', 'nitro-cloudflare-dev'],
35
+ modules: ['@nuxtjs/tailwindcss', '@vee-validate/nuxt', 'nitro-cloudflare-dev', '@nuxt/content'],
36
36
  vite: {
37
37
  define: {
38
38
  'process.env.DEBUG': false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgedev/create-edge-site",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "description": "Create Edge Starter Site",
5
5
  "bin": {
6
6
  "create-edge-site": "./bin/cli.js"
@@ -14,24 +14,28 @@
14
14
  "postinstall": "nuxt prepare"
15
15
  },
16
16
  "dependencies": {
17
- "@fancyapps/ui": "^5.0.36",
17
+ "@fancyapps/ui": "5.0.36",
18
+ "@nuxt/content": "3.6.3",
18
19
  "@nuxtjs/tailwindcss": "6.13.2",
19
- "@vee-validate/nuxt": "^4.15.0",
20
- "@vee-validate/rules": "^4.15.0",
21
- "@vee-validate/zod": "^4.15.0",
22
- "nuxt": "^3.16.1",
23
- "scrollreveal": "^4.0.9",
24
- "swiper": "^11.2.6",
25
- "vue": "^3.5.13",
26
- "vue-imask": "^7.6.1",
27
- "vue-router": "^4.5.0",
28
- "vue-turnstile": "^1.0.11",
29
- "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"
30
32
  },
31
33
  "devDependencies": {
32
- "@antfu/eslint-config": "^4.11.0",
33
- "eslint": "^9",
34
- "nitro-cloudflare-dev": "^0.2.2",
35
- "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"
36
40
  }
37
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