Skip to content

Commit 883b4d8

Browse files
committed
feat(kv): add VK composable & route + notes demo page
1 parent f934cee commit 883b4d8

File tree

7 files changed

+287
-7
lines changed

7 files changed

+287
-7
lines changed
+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { createH3StorageHandler } from 'unstorage/server'
2+
3+
export default eventHandler(async (event) => {
4+
const storage = useKV()
5+
return createH3StorageHandler(storage, {
6+
resolvePath(event) {
7+
return event.context.params!.path || ''
8+
}
9+
})(event)
10+
})

_nuxthub/server/utils/kv.ts

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { Storage } from 'unstorage'
2+
import { createStorage, prefixStorage } from 'unstorage'
3+
import fsDriver from 'unstorage/drivers/fs'
4+
import httpDriver from 'unstorage/drivers/http'
5+
import cloudflareKVBindingDriver from 'unstorage/drivers/cloudflare-kv-binding'
6+
import { join } from 'pathe'
7+
import { joinURL } from 'ufo'
8+
9+
let _kv: Storage
10+
11+
export const useKV = (prefix?: string) => {
12+
if (!_kv) {
13+
const isDev = process.env.NODE_ENV === 'development'
14+
if (process.env.KV) {
15+
// kv in production
16+
_kv = createStorage({
17+
driver: cloudflareKVBindingDriver({
18+
binding: process.env.KV
19+
})
20+
})
21+
} else if (isDev && process.env.NUXT_HUB_URL) {
22+
console.log('Using remote KV...')
23+
// Use https://unstorage.unjs.io/drivers/http
24+
_kv = createStorage({
25+
driver: httpDriver({
26+
base: joinURL(process.env.NUXT_HUB_URL, '/api/_hub/kv/'),
27+
headers: {
28+
Authorization: `Bearer ${process.env.NUXT_HUB_SECRET_KEY}`
29+
}
30+
})
31+
})
32+
} else if (isDev) {
33+
// local kv in development
34+
console.log('Using local KV...')
35+
_kv = createStorage({
36+
driver: fsDriver({ base: join(process.cwd(), './.hub/kv') })
37+
})
38+
} else {
39+
throw new Error('No KV configured for production')
40+
}
41+
}
42+
43+
if (prefix) {
44+
return prefixStorage(_kv, prefix)
45+
}
46+
47+
return _kv
48+
}

pages/index.vue

+14-7
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,20 @@ const { loggedIn } = useUserSession()
2828
color="black"
2929
external
3030
/>
31-
<UButton
32-
v-else
33-
to="/todos"
34-
icon="i-heroicons-list-bullet"
35-
label="Go to Todos"
36-
color="black"
37-
/>
31+
<div v-else class="space-x-2">
32+
<UButton
33+
to="/todos"
34+
icon="i-heroicons-list-bullet"
35+
label="Go to Todos"
36+
color="black"
37+
/>
38+
<UButton
39+
to="/notes"
40+
icon="i-heroicons-pencil-square"
41+
label="Go to Notes"
42+
color="black"
43+
/>
44+
</div>
3845
</template>
3946
<p class="font-medium">
4047
Welcome to Nuxt Todos Edge.

pages/notes.vue

+170
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
<script setup>
2+
definePageMeta({
3+
middleware: 'auth'
4+
})
5+
const loading = ref(false)
6+
const newEntryKey = ref('')
7+
const newEntryValue = ref('')
8+
const newEntryKeyInput = ref(null)
9+
const editedEntryKey = ref(null)
10+
const editedEntryValue = ref(null)
11+
12+
const toast = useToast()
13+
const { user, clear } = useUserSession()
14+
const { data: entries } = await useFetch('/api/entries')
15+
16+
async function addEntry () {
17+
const key = newEntryKey.value.trim().replace(/\s/g, '-')
18+
const value = newEntryValue.value.trim()
19+
if (!key || !value) { return }
20+
21+
loading.value = true
22+
23+
try {
24+
const entry = await $fetch('/api/entries', {
25+
method: 'PUT',
26+
body: {
27+
key,
28+
value
29+
}
30+
})
31+
const entryIndex = entries.value.findIndex(e => e.key === entry.key)
32+
if (entryIndex !== -1) {
33+
entries.value.splice(entryIndex, 1, entry)
34+
} else {
35+
entries.value.push(entry)
36+
}
37+
toast.add({ title: `Entry "${entry.key}" created.` })
38+
newEntryKey.value = ''
39+
newEntryValue.value = ''
40+
nextTick(() => {
41+
newEntryKeyInput.value?.input?.focus()
42+
})
43+
} catch (err) {
44+
if (err.data?.data?.issues) {
45+
const title = err.data.data.issues.map(issue => issue.message).join('\n')
46+
toast.add({ title, color: 'red' })
47+
}
48+
}
49+
loading.value = false
50+
}
51+
52+
function editEntry (entry) {
53+
editedEntryValue.value = entry.value
54+
editedEntryKey.value = entry.key
55+
}
56+
57+
async function updateEntry () {
58+
const entry = await $fetch('/api/entries', {
59+
method: 'PUT',
60+
body: {
61+
key: editedEntryKey.value,
62+
value: editedEntryValue.value.trim()
63+
}
64+
})
65+
const entryIndex = entries.value.findIndex(e => e.key === entry.key)
66+
if (entryIndex !== -1) {
67+
entries.value.splice(entryIndex, 1, entry)
68+
} else {
69+
entries.value.push(entry)
70+
}
71+
editedEntryKey.value = null
72+
editedEntryValue.value = null
73+
}
74+
75+
async function deleteEntry (entry) {
76+
await $fetch(`/api/entries/${entry.key}`, { method: 'DELETE' })
77+
entries.value = entries.value.filter(t => t.key !== entry.key)
78+
toast.add({ title: `Entry "${entry.key}" deleted.` })
79+
}
80+
81+
const items = [[{
82+
label: 'Logout',
83+
icon: 'i-heroicons-arrow-left-on-rectangle',
84+
click: clear
85+
}]]
86+
</script>
87+
88+
<template>
89+
<UCard @submit.prevent="addEntry">
90+
<template #header>
91+
<h3 class="text-lg font-semibold leading-6">
92+
<NuxtLink to="/">
93+
Todo List
94+
</NuxtLink>
95+
</h3>
96+
97+
<UDropdown v-if="user" :items="items">
98+
<UButton color="white" trailing-icon="i-heroicons-chevron-down-20-solid">
99+
<UAvatar :src="`https://github.com/${user.login}.png`" :alt="user.login" size="3xs" />
100+
{{ user.login }}
101+
</UButton>
102+
</UDropdown>
103+
</template>
104+
105+
<div class="flex items-center gap-2">
106+
<UInput
107+
ref="newEntryKeyInput"
108+
v-model="newEntryKey"
109+
name="entryKey"
110+
:disabled="loading"
111+
class="flex-1"
112+
placeholder="key"
113+
autocomplete="off"
114+
autofocus
115+
:ui="{ wrapper: 'flex-1' }"
116+
/>
117+
118+
<UInput
119+
v-model="newEntryValue"
120+
name="entryValue"
121+
:disabled="loading"
122+
class="flex-1"
123+
placeholder="value"
124+
autocomplete="off"
125+
:ui="{ wrapper: 'flex-1' }"
126+
/>
127+
128+
<UButton type="submit" icon="i-heroicons-plus-20-solid" :loading="loading" :disabled="newEntryKey.trim().length === 0" />
129+
</div>
130+
131+
<ul class="divide-y divide-gray-200 dark:divide-gray-800">
132+
<li
133+
v-for="entry of entries"
134+
:key="entry.key"
135+
class="flex items-center gap-4 py-2"
136+
>
137+
<span class="flex-1 font-medium">{{ entry.key }}</span>
138+
139+
<span v-if="editedEntryKey !== entry.key" class="flex-1 font-medium">{{ entry.value }}</span>
140+
<UInput v-else v-model="editedEntryValue" name="editedValue" variant="none" size="xl" autofocus :padded="false" class="flex-1" @keypress.enter="updateEntry(entry)" />
141+
142+
<UButton
143+
v-if="editedEntryKey !== entry.key"
144+
variant="ghost"
145+
size="2xs"
146+
icon="i-heroicons-pencil"
147+
class="flex-shrink-0"
148+
@click="editEntry(entry)"
149+
/>
150+
<UButton
151+
v-else
152+
variant="ghost"
153+
size="2xs"
154+
icon="i-heroicons-x-mark"
155+
class="flex-shrink-0"
156+
@click="editedEntryKey = null"
157+
/>
158+
159+
<UButton
160+
color="red"
161+
variant="soft"
162+
size="2xs"
163+
icon="i-heroicons-x-mark-20-solid"
164+
class="flex-shrink-0"
165+
@click="deleteEntry(entry)"
166+
/>
167+
</li>
168+
</ul>
169+
</UCard>
170+
</template>

server/api/entries/[key].delete.ts

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { useValidatedParams, z } from 'h3-zod'
2+
3+
export default eventHandler(async (event) => {
4+
const { key } = await useValidatedParams(event, {
5+
key: z.string().min(1).max(100)
6+
})
7+
const session = await requireUserSession(event)
8+
9+
// Delete entry for the current user
10+
const storage = await useKV(String(session.user!.id))
11+
12+
await storage.removeItem(key)
13+
14+
return { key }
15+
})

server/api/entries/index.get.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export default eventHandler(async (event) => {
2+
const session = await requireUserSession(event)
3+
4+
// List entries for the current user
5+
const storage = await useKV(String(session.user!.id))
6+
7+
const keys = await storage.getKeys()
8+
// const items = await storage.getItems(keys)
9+
const items = await Promise.all(keys.map(async (key) => {
10+
const value = await storage.getItem(key)
11+
return { key, value }
12+
}))
13+
return items
14+
})

server/api/entries/index.put.ts

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { z } from 'zod'
2+
3+
export default eventHandler(async (event) => {
4+
const { key, value } = await readValidatedBody(event, z.object({
5+
key: z.string().min(1).max(100),
6+
value: z.any()
7+
}).parse)
8+
const session = await requireUserSession(event)
9+
10+
// Set entry for the current user
11+
const storage = await useKV(String(session.user!.id))
12+
13+
await storage.setItem(key, value)
14+
15+
return { key, value }
16+
})

0 commit comments

Comments
 (0)