first commit
Some checks failed
ci / ci (22, ubuntu-latest) (push) Has been cancelled

This commit is contained in:
AlucardDev
2026-08-28 23:32:07 +03:00
commit 7d4ff5962c
35 changed files with 15426 additions and 0 deletions

3
.env.example Normal file
View 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
View 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
View 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

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
shamefully-hoist=true
strict-peer-dependencies=false

21
LICENSE Normal file
View 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
View 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/).
[![Deploy with Vercel](https://vercel.com/button)](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
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
export default defineNuxtRouteMiddleware(() => {
const { loggedIn } = useUserSession()
if (!loggedIn.value) {
return navigateTo('/')
}
})

43
app/pages/index.vue Normal file
View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
public/social-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -0,0 +1,6 @@
export default defineOAuthGitHubEventHandler({
async onSuccess(event, { user }) {
await setUserSession(event, { user })
return sendRedirect(event, '/todos')
}
})

View 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
})

View 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
})

View 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
})

View 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
View 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]
})

View 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
);

View 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": {}
}
}

View 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
View 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
View File

@@ -0,0 +1,3 @@
{
"extends": "../.nuxt/tsconfig.server.json"
}

7
shared/types/auth.d.ts vendored Normal file
View 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
View File

@@ -0,0 +1,3 @@
import type { schema } from 'hub:db'
export type Todo = typeof schema.todos.$inferSelect

17
tsconfig.json Normal file
View 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"
}
]
}