@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 +6 -7
- package/app/components/edgeFormFling.vue +13 -1
- package/app/components/{Input.vue → edgeInput.vue} +2 -2
- package/app/components/edgeNavbar.vue +15 -5
- package/app/components/titleSection.vue +2 -2
- package/app/error.vue +59 -0
- package/app/layouts/admin.vue +6 -0
- package/app/layouts/default.vue +12 -0
- package/app/pages/[collection]/[slug].vue +38 -0
- package/app/pages/[collection]/index.vue +41 -0
- package/app/pages/admin/index.vue +7 -0
- package/app/pages/admin.vue +12 -0
- package/app/pages/contact.vue +4 -4
- package/app/pages/index.vue +1 -1
- package/app/plugins/detect-domain.server.ts +21 -0
- package/content/projects/test.md +6 -0
- package/content/projects/test2.md +6 -0
- package/content.config.ts +14 -0
- package/eslint.config.mjs +32 -12
- package/nuxt.config.ts +1 -1
- package/package.json +21 -17
- package/server/api/hello.ts +3 -1
- package/server/api/images/upload.post.ts +246 -0
- package/server/utils/deleteKV.js +49 -0
- package/server/utils/getPrefix.js +74 -0
- package/server/utils/putKV.js +63 -0
- package/server/utils/sendEmail.js +67 -0
- package/set-wrangler-secrets.sh +75 -0
- package/wrangler.toml +4 -0
- package/app/pages/[...404].vue +0 -33
- /package/app/components/{Select.vue → edge-select.vue} +0 -0
- /package/app/components/{Textarea.vue → edgeTextarea.vue} +0 -0
- /package/{tailwind.config.js → tailwind.config.mjs} +0 -0
package/app/app.vue
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
</script>
|
|
3
|
+
|
|
1
4
|
<template class="font-sans">
|
|
2
|
-
<
|
|
3
|
-
|
|
4
|
-
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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,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>
|
package/app/pages/contact.vue
CHANGED
|
@@ -49,7 +49,7 @@ onMounted(() => {
|
|
|
49
49
|
success-class="text-green-500"
|
|
50
50
|
error-class="text-red-500"
|
|
51
51
|
>
|
|
52
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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"
|
package/app/pages/index.vue
CHANGED
|
@@ -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,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
|
-
|
|
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.
|
|
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": "
|
|
17
|
+
"@fancyapps/ui": "5.0.36",
|
|
18
|
+
"@nuxt/content": "3.6.3",
|
|
18
19
|
"@nuxtjs/tailwindcss": "6.13.2",
|
|
19
|
-
"@vee-validate/nuxt": "
|
|
20
|
-
"@vee-validate/rules": "
|
|
21
|
-
"@vee-validate/zod": "
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"vue
|
|
27
|
-
"vue-
|
|
28
|
-
"vue-
|
|
29
|
-
"
|
|
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": "
|
|
33
|
-
"eslint": "
|
|
34
|
-
"
|
|
35
|
-
"
|
|
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
|
}
|
package/server/api/hello.ts
CHANGED
|
@@ -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
package/app/pages/[...404].vue
DELETED
|
@@ -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
|
|
File without changes
|