This commit is contained in:
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
NUXT_OAUTH_GITHUB_CLIENT_ID=
|
||||||
|
NUXT_OAUTH_GITHUB_CLIENT_SECRET=
|
||||||
|
NUXT_SESSION_PASSWORD=
|
||||||
34
.github/workflows/ci.yml
vendored
Normal file
34
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
name: ci
|
||||||
|
|
||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest]
|
||||||
|
node: [22]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Install node
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node }}
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm run lint
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: pnpm run typecheck
|
||||||
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
node_modules
|
||||||
|
*.log
|
||||||
|
.nuxt
|
||||||
|
nuxt.d.ts
|
||||||
|
.output
|
||||||
|
.env
|
||||||
|
.history
|
||||||
|
db.sqlite
|
||||||
|
dist
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
db.sqlite-*
|
||||||
|
.data
|
||||||
|
.wrangler
|
||||||
|
.env*.local
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023-2024 Sébastien Chopin
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
85
README.md
Normal file
85
README.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Manage your Todos with Atidone ☑️
|
||||||
|
|
||||||
|
A demonstration using [Nuxt](https://nuxt.com) with server-side rendering, authentication and database querying using [Turso](https://turso.tech) with [Drizzle ORM](https://orm.drizzle.team/).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fatinux%2Fatidone%2Ftree%2Fnuxthub-v1&env=NUXT_OAUTH_GITHUB_CLIENT_ID,NUXT_OAUTH_GITHUB_CLIENT_SECRET,NUXT_SESSION_PASSWORD&envDescription=GitHub%20OAuth%20App%20client%20ID%20and%20secret.%20Generate%20a%20random%20session%20password%20min%2032%20chars%20using%20%60openssl%20rand%20-hex%2032%60.&project-name=todos&repository-name=todos&demo-title=Atidone&demo-description=A%20demonstration%20using%20Nuxt%20with%20server-side%20rendering%2C%20authentication%20and%20database%20querying%20using%20Turso%20with%20Drizzle%20ORM&demo-url=https%3A%2F%2Ftodos.nuxt.dev%2F&demo-image=https%3A%2F%2Ftodos.nuxt.dev%2Fsocial-image.png&products=%255B%257B%2522type%2522%253A%2522integration%2522%252C%2522protocol%2522%253A%2522storage%2522%252C%2522productSlug%2522%253A%2522database%2522%252C%2522integrationSlug%2522%253A%2522tursocloud%2522%257D%255D)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Authentication backed-in using [nuxt-auth-utils](https://github.com/atinux/nuxt-auth-utils)
|
||||||
|
- Leverage [Turso](https://turso.tech) as database with [drizzle ORM](https://orm.drizzle.team/) using [NuxtHub DB](https://hub.nuxt.com/docs/storage/database)
|
||||||
|
- [Automatic database migrations](https://hub.nuxt.com/docs/features/database#database-migrations) in development & when deploying
|
||||||
|
- User interface made with [Nuxt UI](https://ui.nuxt.com)
|
||||||
|
- Embed [Drizzle Studio](https://orm.drizzle.team/drizzle-studio/overview/) in the [Nuxt DevTools](https://devtools.nuxt.com)
|
||||||
|
- Cache invalidation and Optimistic UI with [Pinia Colada](https://pinia-colada.esm.dev)
|
||||||
|
|
||||||
|
## Live demo
|
||||||
|
|
||||||
|
https://todos.nuxt.dev
|
||||||
|
|
||||||
|
https://github.com/atinux/atidone/assets/904724/5f3bee55-dbae-4329-8057-7d0e16e92f81
|
||||||
|
|
||||||
|
To see an example using Passkeys (WebAuthn) for authentication, checkout [todo-passkeys](https://github.com/atinux/todo-passkeys).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Make sure to install the dependencies using [pnpm](https://pnpm.io/):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm i
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a [GitHub Oauth Application](https://github.com/settings/applications/new) with:
|
||||||
|
- Homepage url: `http://localhost:3000`
|
||||||
|
- Callback url: `http://localhost:3000/api/auth/github`
|
||||||
|
|
||||||
|
Add the variables in the `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NUXT_OAUTH_GITHUB_CLIENT_ID="my-github-oauth-app-id"
|
||||||
|
NUXT_OAUTH_GITHUB_CLIENT_SECRET="my-github-oauth-app-secret"
|
||||||
|
```
|
||||||
|
|
||||||
|
To create sealed sessions, you also need to add `NUXT_SESSION_PASSWORD` in the `.env` with at least 32 characters:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NUXT_SESSION_PASSWORD="your-super-long-secret-for-session-encryption"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Start the development server on http://localhost:3000
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
In the Nuxt DevTools, you can see your tables by clicking on the Hub Database tab:
|
||||||
|
|
||||||
|
https://github.com/atinux/atidone/assets/904724/7ece3f10-aa6f-43d8-a941-7ca549bc208b
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
You can deploy this project on your Cloudflare account for free and with zero configuration using [NuxtHub](https://hub.nuxt.com).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx nuxthub deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
It's also possible to leverage Cloudflare Pages CI for deploying, learn more about the different options on https://hub.nuxt.com/docs/getting-started/deploy
|
||||||
|
|
||||||
|
## Remote Storage
|
||||||
|
|
||||||
|
Once you deployed your project, you can connect to your remote database locally running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev --remote
|
||||||
|
```
|
||||||
|
|
||||||
|
Learn more about remote storage on https://hub.nuxt.com/docs/getting-started/remote-storage
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT License](./LICENSE)
|
||||||
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
|
||||||
|
)
|
||||||
|
}
|
||||||
6
eslint.config.mjs
Normal file
6
eslint.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// @ts-check
|
||||||
|
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||||
|
|
||||||
|
export default withNuxt(
|
||||||
|
// Your custom configs here
|
||||||
|
)
|
||||||
28
nuxt.config.ts
Normal file
28
nuxt.config.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export default defineNuxtConfig({
|
||||||
|
modules: [
|
||||||
|
'@nuxt/ui',
|
||||||
|
'@nuxt/eslint',
|
||||||
|
'@nuxthub/core',
|
||||||
|
'nuxt-auth-utils',
|
||||||
|
'@pinia/nuxt',
|
||||||
|
'@pinia/colada-nuxt'
|
||||||
|
],
|
||||||
|
devtools: {
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
css: ['~/assets/main.css'],
|
||||||
|
future: { compatibilityVersion: 4 },
|
||||||
|
compatibilityDate: '2025-08-07',
|
||||||
|
hub: {
|
||||||
|
db: 'sqlite'
|
||||||
|
},
|
||||||
|
// Development config
|
||||||
|
eslint: {
|
||||||
|
config: {
|
||||||
|
stylistic: {
|
||||||
|
quotes: 'single',
|
||||||
|
commaDangle: 'never'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
51
package.json
Normal file
51
package.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nuxi dev",
|
||||||
|
"build": "nuxi build",
|
||||||
|
"preview": "npx nuxthub preview",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"postinstall": "nuxt prepare",
|
||||||
|
"typecheck": "nuxi typecheck"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@iconify-json/lucide": "^1.2.92",
|
||||||
|
"@iconify-json/simple-icons": "^1.2.71",
|
||||||
|
"@libsql/client": "^0.17.0",
|
||||||
|
"@nuxt/ui": "^4.4.0",
|
||||||
|
"@nuxthub/core": "^0.10.6",
|
||||||
|
"@pinia/colada": "^0.21.4",
|
||||||
|
"@pinia/colada-nuxt": "^0.3.1",
|
||||||
|
"@pinia/nuxt": "^0.11.3",
|
||||||
|
"drizzle-kit": "^0.31.9",
|
||||||
|
"drizzle-orm": "0.45.1",
|
||||||
|
"nuxt": "^4.3.1",
|
||||||
|
"nuxt-auth-utils": "^0.5.29",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nuxt/devtools": "^3.2.1",
|
||||||
|
"@nuxt/eslint": "^1.15.1",
|
||||||
|
"baseline-browser-mapping": "^2.10.0",
|
||||||
|
"eslint": "^10.0.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vue-tsc": "^3.2.4"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.30.1",
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"@parcel/watcher",
|
||||||
|
"@tailwindcss/oxide",
|
||||||
|
"better-sqlite3",
|
||||||
|
"esbuild",
|
||||||
|
"libpq",
|
||||||
|
"sharp",
|
||||||
|
"unrs-resolver",
|
||||||
|
"vue-demi",
|
||||||
|
"workerd"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
14344
pnpm-lock.yaml
generated
Normal file
14344
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/icon.png
Normal file
BIN
public/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/social-image.png
Normal file
BIN
public/social-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
6
server/api/auth/github.get.ts
Normal file
6
server/api/auth/github.get.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default defineOAuthGitHubEventHandler({
|
||||||
|
async onSuccess(event, { user }) {
|
||||||
|
await setUserSession(event, { user })
|
||||||
|
return sendRedirect(event, '/todos')
|
||||||
|
}
|
||||||
|
})
|
||||||
29
server/api/todos/[id].delete.ts
Normal file
29
server/api/todos/[id].delete.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { db, schema } from 'hub:db'
|
||||||
|
import { and, eq } from 'drizzle-orm'
|
||||||
|
|
||||||
|
const ParamsSchema = z.object({
|
||||||
|
id: z.coerce.number().int()
|
||||||
|
})
|
||||||
|
|
||||||
|
export default eventHandler(async (event) => {
|
||||||
|
const { id } = await getValidatedRouterParams(event, ParamsSchema.parse)
|
||||||
|
const { user } = await requireUserSession(event)
|
||||||
|
|
||||||
|
// Delete todo for the current user
|
||||||
|
const deletedTodos = await db.delete(schema.todos).where(
|
||||||
|
and(
|
||||||
|
eq(schema.todos.id, id),
|
||||||
|
eq(schema.todos.userId, user.id)
|
||||||
|
)
|
||||||
|
).returning()
|
||||||
|
|
||||||
|
const deletedTodo = deletedTodos[0]
|
||||||
|
if (!deletedTodo) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: 'Todo not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return deletedTodo
|
||||||
|
})
|
||||||
34
server/api/todos/[id].patch.ts
Normal file
34
server/api/todos/[id].patch.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { db, schema } from 'hub:db'
|
||||||
|
import { and, eq } from 'drizzle-orm'
|
||||||
|
|
||||||
|
const ParamsSchema = z.object({
|
||||||
|
id: z.coerce.number().int()
|
||||||
|
})
|
||||||
|
|
||||||
|
const BodySchema = z.object({
|
||||||
|
completed: z.boolean()
|
||||||
|
})
|
||||||
|
|
||||||
|
export default eventHandler(async (event) => {
|
||||||
|
const { id } = await getValidatedRouterParams(event, ParamsSchema.parse)
|
||||||
|
const { completed } = await readValidatedBody(event, BodySchema.parse)
|
||||||
|
const { user } = await requireUserSession(event)
|
||||||
|
|
||||||
|
// Update todo for the current user
|
||||||
|
const updatedTodos = await db.update(schema.todos).set({
|
||||||
|
completed: completed ? 1 : 0
|
||||||
|
}).where(and(
|
||||||
|
eq(schema.todos.id, id),
|
||||||
|
eq(schema.todos.userId, user.id)
|
||||||
|
)).returning()
|
||||||
|
|
||||||
|
const todo = updatedTodos[0]
|
||||||
|
if (!todo) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: 'Todo not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return todo
|
||||||
|
})
|
||||||
11
server/api/todos/index.get.ts
Normal file
11
server/api/todos/index.get.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { db, schema } from 'hub:db'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
|
||||||
|
export default eventHandler(async (event) => {
|
||||||
|
const { user } = await requireUserSession(event)
|
||||||
|
|
||||||
|
// List todos for the current user
|
||||||
|
const todos = await db.select().from(schema.todos).where(eq(schema.todos.userId, user.id))
|
||||||
|
|
||||||
|
return todos
|
||||||
|
})
|
||||||
20
server/api/todos/index.post.ts
Normal file
20
server/api/todos/index.post.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { db, schema } from 'hub:db'
|
||||||
|
|
||||||
|
const BodySchema = z.object({
|
||||||
|
title: z.string().min(1).max(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default eventHandler(async (event) => {
|
||||||
|
const { title } = await readValidatedBody(event, body => BodySchema.parse(body))
|
||||||
|
const { user } = await requireUserSession(event)
|
||||||
|
|
||||||
|
// Insert todo for the current user
|
||||||
|
const todos = await db.insert(schema.todos).values({
|
||||||
|
userId: user.id,
|
||||||
|
title,
|
||||||
|
createdAt: new Date()
|
||||||
|
}).returning()
|
||||||
|
|
||||||
|
return todos[0]
|
||||||
|
})
|
||||||
12
server/api/todos/stats.ts
Normal file
12
server/api/todos/stats.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { db, schema } from 'hub:db'
|
||||||
|
import { sql } from 'drizzle-orm'
|
||||||
|
|
||||||
|
export default eventHandler(async () => {
|
||||||
|
// Count the total number of todos
|
||||||
|
const result = await db.select({
|
||||||
|
todos: sql<number>`count(*)`,
|
||||||
|
users: sql<number>`count(distinct(${schema.todos.userId}))`
|
||||||
|
}).from(schema.todos)
|
||||||
|
|
||||||
|
return result[0]
|
||||||
|
})
|
||||||
7
server/db/migrations/sqlite/0000_brainy_nehzno.sql
Normal file
7
server/db/migrations/sqlite/0000_brainy_nehzno.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE `todos` (
|
||||||
|
`id` integer PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`completed` integer DEFAULT 0 NOT NULL,
|
||||||
|
`created_at` integer NOT NULL
|
||||||
|
);
|
||||||
64
server/db/migrations/sqlite/meta/0000_snapshot.json
Normal file
64
server/db/migrations/sqlite/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "91b7734d-d696-4df6-9b68-d865ff714cbb",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"tables": {
|
||||||
|
"todos": {
|
||||||
|
"name": "todos",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"completed": {
|
||||||
|
"name": "completed",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
server/db/migrations/sqlite/meta/_journal.json
Normal file
13
server/db/migrations/sqlite/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1764775050537,
|
||||||
|
"tag": "0000_brainy_nehzno",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
9
server/db/schema.ts
Normal file
9
server/db/schema.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
|
||||||
|
|
||||||
|
export const todos = sqliteTable('todos', {
|
||||||
|
id: integer('id').primaryKey(),
|
||||||
|
userId: integer('user_id').notNull(), // GitHub Id
|
||||||
|
title: text('title').notNull(),
|
||||||
|
completed: integer('completed').notNull().default(0),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
|
||||||
|
})
|
||||||
3
server/tsconfig.json
Normal file
3
server/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "../.nuxt/tsconfig.server.json"
|
||||||
|
}
|
||||||
7
shared/types/auth.d.ts
vendored
Normal file
7
shared/types/auth.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
declare module '#auth-utils' {
|
||||||
|
interface User {
|
||||||
|
id: number
|
||||||
|
login: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export {}
|
||||||
3
shared/types/db.d.ts
vendored
Normal file
3
shared/types/db.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type { schema } from 'hub:db'
|
||||||
|
|
||||||
|
export type Todo = typeof schema.todos.$inferSelect
|
||||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.server.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.shared.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.node.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user