This commit is contained in:
17
app/app.config.ts
Normal file
17
app/app.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
colors: {
|
||||
primary: 'amber',
|
||||
neutral: 'zinc'
|
||||
},
|
||||
container: {
|
||||
base: 'max-w-2xl'
|
||||
},
|
||||
card: {
|
||||
slots: {
|
||||
header: 'flex flex-wrap items-center justify-between'
|
||||
},
|
||||
body: 'space-y-4'
|
||||
}
|
||||
}
|
||||
})
|
||||
144
app/app.vue
Normal file
144
app/app.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import type { DropdownMenuItem } from '#ui/types'
|
||||
|
||||
const { loggedIn, user, clear } = useUserSession()
|
||||
const colorMode = useColorMode()
|
||||
|
||||
watch(loggedIn, () => {
|
||||
if (!loggedIn.value) {
|
||||
navigateTo('/')
|
||||
}
|
||||
})
|
||||
|
||||
const isDarkMode = computed({
|
||||
get: () => colorMode.preference === 'dark',
|
||||
set: () =>
|
||||
(colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark')
|
||||
})
|
||||
|
||||
useHead({
|
||||
htmlAttrs: { lang: 'en' },
|
||||
link: [{ rel: 'icon', href: '/icon.png' }]
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
viewport: 'width=device-width, initial-scale=1, maximum-scale=1',
|
||||
title: 'Atidone',
|
||||
description:
|
||||
'A Nuxt demo hosted with edge-side rendering, authentication and queyring a Cloudflare D1 database',
|
||||
ogImage: '/social-image.png',
|
||||
twitterImage: '/social-image.png',
|
||||
twitterCard: 'summary_large_image'
|
||||
})
|
||||
|
||||
const items = [
|
||||
[
|
||||
{
|
||||
label: 'Logout',
|
||||
icon: 'i-lucide-log-out',
|
||||
onSelect: clear
|
||||
}
|
||||
]
|
||||
] satisfies DropdownMenuItem[][]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UApp>
|
||||
<UContainer class="min-h-screen flex flex-col my-4">
|
||||
<div class="mb-2 text-right">
|
||||
<UButton
|
||||
square
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
:icon="
|
||||
$colorMode.preference === 'dark' || $colorMode.preference === 'system'
|
||||
? 'i-lucide-moon'
|
||||
: 'i-lucide-sun'
|
||||
"
|
||||
@click="isDarkMode = !isDarkMode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UCard variant="subtle">
|
||||
<template #header>
|
||||
<h3 class="text-lg font-semibold leading-6">
|
||||
<NuxtLink to="/">
|
||||
Atidone
|
||||
</NuxtLink>
|
||||
</h3>
|
||||
<UButton
|
||||
v-if="!loggedIn"
|
||||
to="/api/auth/github"
|
||||
icon="i-simple-icons-github"
|
||||
label="Login with GitHub"
|
||||
color="neutral"
|
||||
size="xs"
|
||||
external
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-wrap -mx-2 sm:mx-0"
|
||||
>
|
||||
<UButton
|
||||
to="/todos"
|
||||
icon="i-lucide-list"
|
||||
label="Todos"
|
||||
:color="$route.path === '/todos' ? 'primary' : 'neutral'"
|
||||
variant="ghost"
|
||||
/>
|
||||
<UButton
|
||||
to="/optimistic-todos"
|
||||
icon="i-lucide-sparkles"
|
||||
label="Optimistic Todos"
|
||||
:color="$route.path === '/optimistic-todos' ? 'primary' : 'neutral'"
|
||||
variant="ghost"
|
||||
/>
|
||||
<UDropdownMenu
|
||||
v-if="user"
|
||||
:items="items"
|
||||
>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
trailing-icon="i-lucide-chevron-down"
|
||||
>
|
||||
<UAvatar
|
||||
:src="`https://github.com/${user.login}.png`"
|
||||
:alt="user.login"
|
||||
size="3xs"
|
||||
/>
|
||||
{{ user.login }}
|
||||
</UButton>
|
||||
</UDropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
<NuxtPage />
|
||||
</UCard>
|
||||
|
||||
<footer class="flex items-center justify-center gap-2 mt-4">
|
||||
<UButton
|
||||
href="https://github.com/atinux/atidone"
|
||||
target="_blank"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="i-simple-icons-github"
|
||||
/>
|
||||
<UButton
|
||||
href="https://x.com/atinux"
|
||||
target="_blank"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="i-simple-icons-x"
|
||||
/>
|
||||
</footer>
|
||||
</UContainer>
|
||||
</UApp>
|
||||
</template>
|
||||
|
||||
<style lang="postcss">
|
||||
body {
|
||||
@apply font-sans text-neutral-950 bg-neutral-50 dark:bg-neutral-950 dark:text-neutral-50;
|
||||
}
|
||||
</style>
|
||||
6
app/assets/main.css
Normal file
6
app/assets/main.css
Normal file
@@ -0,0 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "@nuxt/ui";
|
||||
|
||||
@theme static {
|
||||
--font-sans: 'Public Sans', sans-serif;
|
||||
}
|
||||
7
app/middleware/auth.ts
Normal file
7
app/middleware/auth.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default defineNuxtRouteMiddleware(() => {
|
||||
const { loggedIn } = useUserSession()
|
||||
|
||||
if (!loggedIn.value) {
|
||||
return navigateTo('/')
|
||||
}
|
||||
})
|
||||
43
app/pages/index.vue
Normal file
43
app/pages/index.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="font-medium">
|
||||
Welcome to Atidone.
|
||||
</p>
|
||||
<p>
|
||||
A <a
|
||||
href="https://nuxt.com"
|
||||
target="_blank"
|
||||
class="text-primary"
|
||||
rel="noopener"
|
||||
>Nuxt</a> demo hosted on <a
|
||||
href="https://vercel.com"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-primary"
|
||||
>Vercel</a> with server-side rendering on the edge and using <NuxtLink
|
||||
href="https://turso.tech"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-primary"
|
||||
>
|
||||
Turso database
|
||||
</NuxtLink>.
|
||||
</p>
|
||||
<p>
|
||||
It is made using <a
|
||||
href="https://hub.nuxt.com/docs/features/database"
|
||||
class="text-primary"
|
||||
>NuxtHub Database</a> and <a
|
||||
href="https://github.com/atinux/nuxt-auth-utils"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-primary"
|
||||
>nuxt-auth-utils</a> for an almost zero-config development & deployment experience.
|
||||
</p>
|
||||
<USeparator />
|
||||
<p class="text-sm text-neutral-500 italic">
|
||||
No personal information regarding your GitHub account are stored in database.<br>
|
||||
We store only the todos created linked with your GitHub ID.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
228
app/pages/optimistic-todos.vue
Normal file
228
app/pages/optimistic-todos.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
import { todosQuery } from '~/queries/todos'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth'
|
||||
})
|
||||
const newTodo = ref('')
|
||||
|
||||
const toast = useToast()
|
||||
const { user } = useUserSession()
|
||||
const queryCache = useQueryCache()
|
||||
|
||||
const { data: todos } = useQuery(todosQuery)
|
||||
|
||||
const { mutate: addTodo } = useMutation({
|
||||
mutation: (title: string) => {
|
||||
if (!title.trim()) throw new Error('Title is required')
|
||||
|
||||
return $fetch('/api/todos', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
title,
|
||||
completed: 0
|
||||
}
|
||||
}) as Promise<Todo>
|
||||
},
|
||||
|
||||
onMutate(title) {
|
||||
// let the user enter new todos right away!
|
||||
newTodo.value = ''
|
||||
const oldTodos = queryCache.getQueryData(todosQuery.key) || []
|
||||
const newTodoItem = {
|
||||
title,
|
||||
completed: 0,
|
||||
// a negative id to differentiate them from the server ones
|
||||
id: -Date.now(),
|
||||
createdAt: new Date(),
|
||||
userId: user.value!.id
|
||||
} satisfies Todo
|
||||
// we use newTodos to check for the cache consistency
|
||||
// a better way would be to save the entry time
|
||||
// const when = queryCache.getEntries({ key: ['todos'], exact: true }).at(0)?.when
|
||||
const newTodos = [...oldTodos, newTodoItem]
|
||||
queryCache.setQueryData(todosQuery.key, newTodos)
|
||||
|
||||
queryCache.cancelQueries({ key: todosQuery.key, exact: true })
|
||||
|
||||
return { oldTodos, newTodos, newTodoItem }
|
||||
},
|
||||
|
||||
onSuccess(todo, _, { newTodoItem }) {
|
||||
// update the todo with the information from the server
|
||||
// since we are invalidating queries, this allows us to progressively
|
||||
// update the todo list even if the user is adding a lot very quickly
|
||||
const todoList = queryCache.getQueryData(todosQuery.key) || []
|
||||
const todoIndex = todoList.findIndex(t => t.id === newTodoItem.id)
|
||||
if (todoIndex >= 0) {
|
||||
queryCache.setQueryData(
|
||||
todosQuery.key,
|
||||
todoList.toSpliced(todoIndex, 1, todo)
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
onSettled() {
|
||||
// always refetch the todos after a mutation
|
||||
queryCache.invalidateQueries({ key: todosQuery.key })
|
||||
},
|
||||
|
||||
onError(err, _title, { oldTodos, newTodos }) {
|
||||
// oldTodos can be undefined if onMutate errors
|
||||
// we also want to check if the oldTodos are still in the cache
|
||||
// because the cache could have been updated by another query
|
||||
if (
|
||||
newTodos != null
|
||||
&& newTodos === queryCache.getQueryData(todosQuery.key)
|
||||
) {
|
||||
queryCache.setQueryData(todosQuery.key, oldTodos)
|
||||
}
|
||||
|
||||
if (isNuxtZodError(err)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const title = (err as any).data.data.issues
|
||||
.map((issue: { message: string }) => issue.message)
|
||||
.join('\n')
|
||||
toast.add({ title, color: 'error' })
|
||||
}
|
||||
else {
|
||||
console.error(err)
|
||||
toast.add({ title: 'Unexpected Error', color: 'error' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const { mutate: toggleTodo } = useMutation({
|
||||
mutation: (todo: Todo) =>
|
||||
$fetch(`/api/todos/${todo.id}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
completed: !todo.completed
|
||||
}
|
||||
}),
|
||||
|
||||
onMutate(todo) {
|
||||
const oldTodos = queryCache.getQueryData(todosQuery.key) || []
|
||||
const todoIndex = oldTodos.findIndex(t => t.id === todo.id)
|
||||
let newTodos = oldTodos
|
||||
if (todoIndex >= 0) {
|
||||
newTodos = oldTodos.toSpliced(todoIndex, 1, {
|
||||
...todo,
|
||||
completed: todo.completed ? 0 : 1
|
||||
})
|
||||
queryCache.setQueryData(todosQuery.key, newTodos)
|
||||
}
|
||||
|
||||
queryCache.cancelQueries({ key: todosQuery.key, exact: true })
|
||||
|
||||
return { oldTodos, newTodos }
|
||||
},
|
||||
|
||||
onSettled() {
|
||||
// always refetch the todos after a mutation
|
||||
queryCache.invalidateQueries({ key: todosQuery.key, exact: true })
|
||||
},
|
||||
|
||||
onError(err, todo, { oldTodos, newTodos }) {
|
||||
// oldTodos can be undefined if onMutate errors
|
||||
if (
|
||||
newTodos != null
|
||||
&& newTodos === queryCache.getQueryData(todosQuery.key)
|
||||
) {
|
||||
queryCache.setQueryData(todosQuery.key, oldTodos)
|
||||
}
|
||||
|
||||
console.error(err)
|
||||
toast.add({ title: 'Unexpected Error', color: 'error' })
|
||||
}
|
||||
})
|
||||
|
||||
const { mutate: deleteTodo } = useMutation({
|
||||
mutation: (todo: Todo) => $fetch(`/api/todos/${todo.id}`, { method: 'DELETE' }),
|
||||
|
||||
onMutate(todo) {
|
||||
const oldTodos = queryCache.getQueryData(todosQuery.key) || []
|
||||
const todoIndex = oldTodos.findIndex(t => t.id === todo.id)
|
||||
let newTodos = oldTodos
|
||||
if (todoIndex >= 0) {
|
||||
newTodos = oldTodos.toSpliced(todoIndex, 1)
|
||||
queryCache.setQueryData(todosQuery.key, newTodos)
|
||||
}
|
||||
|
||||
queryCache.cancelQueries({ key: todosQuery.key, exact: true })
|
||||
|
||||
return { oldTodos, newTodos }
|
||||
},
|
||||
|
||||
onSettled() {
|
||||
// always refetch the todos after a mutation
|
||||
queryCache.invalidateQueries({ key: todosQuery.key, exact: true })
|
||||
},
|
||||
|
||||
onError(err, todo, { oldTodos, newTodos }) {
|
||||
// oldTodos can be undefined if onMutate errors
|
||||
if (newTodos != null && newTodos === queryCache.getQueryData(todosQuery.key)) {
|
||||
queryCache.setQueryData(todosQuery.key, oldTodos)
|
||||
}
|
||||
|
||||
console.error(err)
|
||||
toast.add({ title: 'Unexpected Error', color: 'error' })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="flex flex-col gap-4"
|
||||
@submit.prevent="addTodo(newTodo)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<UInput
|
||||
v-model="newTodo"
|
||||
name="todo"
|
||||
class="flex-1"
|
||||
placeholder="Make a Nuxt demo"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
:ui="{ base: 'flex-1' }"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
type="submit"
|
||||
icon="i-lucide-plus"
|
||||
:disabled="newTodo.trim().length === 0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<li
|
||||
v-for="todo of todos"
|
||||
:key="todo.id"
|
||||
class="flex items-center gap-4 py-2"
|
||||
>
|
||||
<span
|
||||
class="flex-1 font-medium"
|
||||
:class="{
|
||||
'text-gray-500': todo.completed || todo.id < 0,
|
||||
'line-through': todo.completed
|
||||
}"
|
||||
>{{ todo.title }}</span>
|
||||
|
||||
<USwitch
|
||||
:model-value="Boolean(todo.completed)"
|
||||
:disabled="todo.id < 0"
|
||||
@update:model-value="toggleTodo(todo)"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
color="error"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
icon="i-lucide-x"
|
||||
:disabled="todo.id < 0"
|
||||
@click="deleteTodo(todo)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</form>
|
||||
</template>
|
||||
138
app/pages/todos.vue
Normal file
138
app/pages/todos.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
import { todosQuery } from '~/queries/todos'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth'
|
||||
})
|
||||
const newTodo = ref('')
|
||||
const newTodoInput = useTemplateRef('new-todo')
|
||||
|
||||
const toast = useToast()
|
||||
const queryCache = useQueryCache()
|
||||
|
||||
const { data: todos } = useQuery(todosQuery)
|
||||
|
||||
const { mutate: addTodo, isLoading: loading } = useMutation({
|
||||
mutation: (title: string) => {
|
||||
if (!title.trim()) throw new Error('Title is required')
|
||||
|
||||
return $fetch('/api/todos', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
title,
|
||||
completed: 0
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async onSuccess(todo) {
|
||||
await queryCache.invalidateQueries(todosQuery)
|
||||
toast.add({ title: `Todo "${todo!.title}" created.` })
|
||||
},
|
||||
|
||||
onSettled() {
|
||||
newTodo.value = ''
|
||||
// the first nextTick allows loading to become false and re enable the input
|
||||
// the second nextTick allows the input to be rendered again so it can be focused
|
||||
// a better solution would be to use a custom `v-focus` directive or a more elaborated focus management solution
|
||||
nextTick()
|
||||
.then(() => nextTick())
|
||||
.then(() => {
|
||||
newTodoInput.value?.inputRef?.focus()
|
||||
})
|
||||
},
|
||||
|
||||
onError(err) {
|
||||
if (isNuxtZodError(err)) {
|
||||
const title = err.data?.data.issues
|
||||
.map(issue => issue.message)
|
||||
.join('\n')
|
||||
if (title) {
|
||||
toast.add({ title, color: 'error' })
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.error(err)
|
||||
toast.add({ title: 'Unexpected Error', color: 'error' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const { mutate: toggleTodo } = useMutation({
|
||||
mutation: (todo: Todo) =>
|
||||
$fetch(`/api/todos/${todo.id}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
completed: !todo.completed
|
||||
}
|
||||
}),
|
||||
|
||||
async onSuccess() {
|
||||
await queryCache.invalidateQueries(todosQuery)
|
||||
}
|
||||
})
|
||||
|
||||
const { mutate: deleteTodo } = useMutation({
|
||||
mutation: (todo: Todo) =>
|
||||
$fetch(`/api/todos/${todo.id}`, { method: 'DELETE' }),
|
||||
|
||||
async onSuccess(_result, todo) {
|
||||
await queryCache.invalidateQueries(todosQuery)
|
||||
toast.add({ title: `Todo "${todo.title}" deleted.` })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="flex flex-col gap-4"
|
||||
@submit.prevent="addTodo(newTodo)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<UInput
|
||||
ref="new-todo"
|
||||
v-model="newTodo"
|
||||
name="todo"
|
||||
:disabled="loading"
|
||||
class="flex-1"
|
||||
placeholder="Make a Nuxt demo"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
:ui="{ base: 'flex-1' }"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
type="submit"
|
||||
icon="i-lucide-plus"
|
||||
:loading="loading"
|
||||
:disabled="newTodo.trim().length === 0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<li
|
||||
v-for="todo of todos"
|
||||
:key="todo.id"
|
||||
class="flex items-center gap-4 py-2"
|
||||
>
|
||||
<span
|
||||
class="flex-1 font-medium"
|
||||
:class="[todo.completed ? 'line-through text-gray-500' : '']"
|
||||
>{{ todo.title }}</span>
|
||||
|
||||
<USwitch
|
||||
:model-value="Boolean(todo.completed)"
|
||||
@update:model-value="toggleTodo(todo)"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
color="error"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
icon="i-lucide-x"
|
||||
@click="deleteTodo(todo)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</form>
|
||||
</template>
|
||||
10
app/queries/todos.ts
Normal file
10
app/queries/todos.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineQueryOptions } from '@pinia/colada'
|
||||
|
||||
export const todosQuery = defineQueryOptions({
|
||||
key: ['todos'],
|
||||
// NOTE: the cast sometimes avoids an "Excessive depth check" TS error
|
||||
// using $fetch directly doesn't avoid the round trip to the server
|
||||
// when doing SSR
|
||||
// https://github.com/nuxt/nuxt/issues/24813
|
||||
query: () => useRequestFetch()('/api/todos') as Promise<Todo[]>
|
||||
})
|
||||
9
app/utils/errors.ts
Normal file
9
app/utils/errors.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ZodError } from 'zod'
|
||||
import type { NuxtError } from '#app'
|
||||
|
||||
export function isNuxtZodError(err: unknown): err is NuxtError<{ data: ZodError }> {
|
||||
return (
|
||||
isNuxtError(err)
|
||||
&& (err.data as { data?: unknown })?.data instanceof ZodError
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user