Initial commit
This commit is contained in:
6
.env.example
Normal file
6
.env.example
Normal file
@@ -0,0 +1,6 @@
|
||||
NUXT_OAUTH_STRAVA_CLIENT_ID=
|
||||
NUXT_OAUTH_STRAVA_CLIENT_SECRET=
|
||||
NUXT_SESSION_PASSWORD=
|
||||
NUXT_WEBHOOKS_URL=
|
||||
NUXT_STRAVA_VERIFY_TOKEN=
|
||||
NUXT_HUB_PROJECT_KEY=
|
||||
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
.wrangler
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
60
README.md
Normal file
60
README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Hello Edge
|
||||
|
||||
A minimal [Nuxt](https://nuxt.com) starter deployed on the Edge using [NuxtHub](https://hub.nuxt.com).
|
||||
|
||||
https://hello.nuxt.dev
|
||||
|
||||
<a href="https://hello.nuxt.dev">
|
||||
<img src="https://github.com/nuxt-hub/hello-edge/assets/904724/99d1bd54-ef7e-4ac9-83ad-0a290f85edcf" alt="Hello World template for NuxtHub" />
|
||||
</a>
|
||||
|
||||
## Features
|
||||
|
||||
- Server-Side rendering on Cloudflare Workers
|
||||
- ESLint setup
|
||||
- Ready to add a database, blob and KV storage
|
||||
- One click deploy on 275+ locations for free
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to install the dependencies with [pnpm](https://pnpm.io/installation#using-corepack):
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
You can update the main text displayed by creating a `.env`:
|
||||
|
||||
```bash
|
||||
NUXT_PUBLIC_HELLO_TEXT="Hello my world!"
|
||||
```
|
||||
|
||||
## Development Server
|
||||
|
||||
Start the development server on `http://localhost:3000`:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
|
||||
Deploy the application on the Edge with [NuxtHub](https://hub.nuxt.com) on your Cloudflare account:
|
||||
|
||||
```bash
|
||||
npx nuxthub deploy
|
||||
```
|
||||
|
||||
Then checkout your server logs, analaytics and more in the [NuxtHub Admin](https://admin.hub.nuxt.com).
|
||||
|
||||
You can also deploy using [Cloudflare Pages CI](https://hub.nuxt.com/docs/getting-started/deploy#cloudflare-pages-ci).
|
||||
|
||||
9
app.config.ts
Normal file
9
app.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
toast: {
|
||||
slots: {
|
||||
progress: "hidden!",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
8
app/app.vue
Normal file
8
app/app.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<NuxtRouteAnnouncer />
|
||||
<UApp :toaster="{ position: 'bottom-center' }">
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</UApp>
|
||||
</template>
|
||||
17
app/assets/css/main.css
Normal file
17
app/assets/css/main.css
Normal file
@@ -0,0 +1,17 @@
|
||||
@import "tailwindcss";
|
||||
@import "@nuxt/ui";
|
||||
|
||||
@theme static {
|
||||
--ui-primary: var(--color-orange-500);
|
||||
|
||||
--ui-color-primary-50: var(--color-orange-50);
|
||||
--ui-color-primary-100: var(--color-orange-100);
|
||||
--ui-color-primary-200: var(--color-orange-200);
|
||||
--ui-color-primary-300: var(--color-orange-300);
|
||||
--ui-color-primary-400: var(--color-orange-400);
|
||||
--ui-color-primary-500: var(--color-orange-500);
|
||||
--ui-color-primary-600: var(--color-orange-700);
|
||||
--ui-color-primary-700: var(--color-orange-700);
|
||||
--ui-color-primary-800: var(--color-orange-800);
|
||||
--ui-color-primary-900: var(--color-orange-900);
|
||||
}
|
||||
14
app/components/app-bar.vue
Normal file
14
app/components/app-bar.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
const { user, clear } = useUserSession();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-16 bg-black text-white flex">
|
||||
<UContainer class="max-w-2xl flex justify-between items-center">
|
||||
<div class="font-bold text-xl">Joyful</div>
|
||||
<UDropdownMenu :items="[{ label: 'Log out', onSelect: () => clear() }]">
|
||||
<UAvatar :src="user!.avatar" />
|
||||
</UDropdownMenu>
|
||||
</UContainer>
|
||||
</div>
|
||||
</template>
|
||||
12
app/components/app-footer.vue
Normal file
12
app/components/app-footer.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<UContainer
|
||||
class="max-w-2xl py-8 text-sm text-slate-600 place-items-center grid gap-4"
|
||||
>
|
||||
<div class="text-center">
|
||||
Built with 💪 in Athens, Greece 🇬🇷 by Marios Antonoudiou.
|
||||
<ULink class="underline">Send feedback</ULink> or
|
||||
<ULink class="underline">buy me a coffee</ULink>.
|
||||
</div>
|
||||
<NuxtImg src="/images/powered-by-strava.svg" width="80px" />
|
||||
</UContainer>
|
||||
</template>
|
||||
13
app/components/card-field.vue
Normal file
13
app/components/card-field.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="flex justify-between items-center gap-4">
|
||||
<div>
|
||||
<div class="font-semibold"><slot name="title" /></div>
|
||||
<div class="text-slate-600 text-sm">
|
||||
<slot name="description" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-nowrap flex items-end">
|
||||
<slot name="value" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
30
app/components/register.vue
Normal file
30
app/components/register.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import AppFooter from "./app-footer.vue";
|
||||
|
||||
const { openInPopup } = useUserSession();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UContainer class="flex justify-center w-full p-16">
|
||||
<UCard class="w-md">
|
||||
<div class="flex flex-col gap-4 items-center justify-center text-center">
|
||||
<UIcon name="heroicons:user" class="size-16" />
|
||||
<div class="font-bold text-xl">Joyful</div>
|
||||
<div>
|
||||
Welcome to Joyful. Use the button below to sign in with your Strava
|
||||
account.
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
aria-role="button"
|
||||
@click="openInPopup('/auth/strava')"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<NuxtImg src="/images/connect-with-strava.svg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</UContainer>
|
||||
<AppFooter />
|
||||
</template>
|
||||
10
app/layouts/default.vue
Normal file
10
app/layouts/default.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<AuthState v-slot="{ loggedIn }">
|
||||
<Register v-if="!loggedIn" />
|
||||
<template v-if="loggedIn">
|
||||
<AppBar />
|
||||
<slot />
|
||||
<AppFooter />
|
||||
</template>
|
||||
</AuthState>
|
||||
</template>
|
||||
126
app/pages/index.vue
Normal file
126
app/pages/index.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
const { user } = useUserSession();
|
||||
|
||||
const stravaLink = computed(() => {
|
||||
return `https://www.strava.com/athletes/${toValue(user).id}`;
|
||||
});
|
||||
|
||||
interface FormData {
|
||||
enabled: boolean;
|
||||
language: string;
|
||||
}
|
||||
|
||||
const preferences = useState<FormData>("preferences", () => ({
|
||||
enabled: false,
|
||||
language: "English",
|
||||
}));
|
||||
|
||||
const { status } = useLazyFetch("/api/preferences", {
|
||||
onResponse({ error, response }) {
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
preferences.value = response._data;
|
||||
},
|
||||
});
|
||||
|
||||
watchEffect(async () => {
|
||||
await $fetch("/api/preferences", {
|
||||
method: "PUT",
|
||||
body: toValue(preferences),
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UContainer class="max-w-2xl py-8 flex flex-col gap-4">
|
||||
<div class="font-bold text-lg">Welcome to Joyful!</div>
|
||||
|
||||
<div>
|
||||
Joyful automatically generates fun and engaging titles and descriptions
|
||||
for your Strava activities, right when they are created. Customize your
|
||||
preferences below.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Add a touch of creativity to your Strava workouts. Simply enable it and
|
||||
choose your language, and we'll do the rest!
|
||||
</div>
|
||||
</UContainer>
|
||||
<UContainer
|
||||
class="max-w-2xl py-8 flex flex-col gap-4"
|
||||
v-if="status === 'success'"
|
||||
>
|
||||
<div class="font-bold text-lg">🛠️ Preferences</div>
|
||||
<UCard class="">
|
||||
<div class="flex flex-col gap-8">
|
||||
<CardField>
|
||||
<template #title> Enabled </template>
|
||||
<template #description>
|
||||
Enable/disable automatic generation.
|
||||
</template>
|
||||
<template #value>
|
||||
<USwitch size="lg" v-model="preferences.enabled" />
|
||||
</template>
|
||||
</CardField>
|
||||
|
||||
<CardField>
|
||||
<template #title> Model language </template>
|
||||
<template #description>
|
||||
Language for generated titles and descriptions.
|
||||
</template>
|
||||
<template #value>
|
||||
<USelect
|
||||
class="min-w-28"
|
||||
:items="languages"
|
||||
v-model="preferences.language"
|
||||
/>
|
||||
</template>
|
||||
</CardField>
|
||||
</div>
|
||||
</UCard>
|
||||
</UContainer>
|
||||
|
||||
<UContainer class="max-w-2xl py-8 flex flex-col gap-4">
|
||||
<div class="font-bold text-lg">🪪 Your connected Strava account</div>
|
||||
<UCard class="bg-neutral-50">
|
||||
<div class="flex flex-col gap-8">
|
||||
<CardField>
|
||||
<template #title> Full name </template>
|
||||
<template #description>
|
||||
Full name from your linked Strava account.
|
||||
</template>
|
||||
<template #value>
|
||||
{{ user.name }}
|
||||
</template>
|
||||
</CardField>
|
||||
|
||||
<CardField>
|
||||
<template #title> Country </template>
|
||||
<template #description>
|
||||
Country associated with your Strava account.
|
||||
</template>
|
||||
<template #value> {{ user.country }} </template>
|
||||
</CardField>
|
||||
|
||||
<CardField>
|
||||
<template #title> Athlete ID </template>
|
||||
<template #description>
|
||||
Your Athlete ID. Click it to view your profile on Strava.</template
|
||||
>
|
||||
|
||||
<template #value>
|
||||
<ULink :href="stravaLink" class="underline flex items-center gap-2">
|
||||
{{ user.id }}
|
||||
<UIcon
|
||||
name="heroicons:arrow-top-right-on-square"
|
||||
class="size-4"
|
||||
/>
|
||||
</ULink>
|
||||
</template>
|
||||
</CardField>
|
||||
</div>
|
||||
</UCard>
|
||||
</UContainer>
|
||||
</template>
|
||||
7
app/utils/languages.ts
Normal file
7
app/utils/languages.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const languages = ref([
|
||||
"English",
|
||||
"Greek",
|
||||
"German",
|
||||
"Italian",
|
||||
"Polish",
|
||||
]);
|
||||
7
drizzle.config.ts
Normal file
7
drizzle.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "sqlite",
|
||||
schema: "./server/database/schema.ts",
|
||||
out: "./server/database/migrations",
|
||||
});
|
||||
22
nuxt.config.ts
Normal file
22
nuxt.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export default defineNuxtConfig({
|
||||
css: ["~/assets/css/main.css"],
|
||||
modules: [
|
||||
"@nuxthub/core",
|
||||
"@nuxt/ui",
|
||||
"@nuxt/icon",
|
||||
"@vee-validate/nuxt",
|
||||
"nuxt-auth-utils",
|
||||
"@nuxt/image",
|
||||
],
|
||||
devtools: { enabled: true },
|
||||
runtimeConfig: {
|
||||
webhooksUrl: "",
|
||||
stravaVerifyToken: "",
|
||||
},
|
||||
future: { compatibilityVersion: 4 },
|
||||
compatibilityDate: "2025-03-01",
|
||||
hub: {
|
||||
ai: true,
|
||||
database: true,
|
||||
},
|
||||
});
|
||||
10969
package-lock.json
generated
Normal file
10969
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
package.json
Normal file
42
package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "nuxt-app",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev --host=0.0.0.0",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "npx nuxthub preview",
|
||||
"deploy": "npx nuxthub deploy",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formkit/tempo": "^0.1.2",
|
||||
"@nuxt/icon": "1.11.0",
|
||||
"@nuxt/image": "^1.10.0",
|
||||
"@nuxt/ui": "3.0.2",
|
||||
"@nuxthub/core": "^0.8.23",
|
||||
"@vee-validate/nuxt": "^4.15.0",
|
||||
"drizzle-orm": "^0.41.0",
|
||||
"nuxt": "^3.16.2",
|
||||
"nuxt-auth-utils": "0.5.18",
|
||||
"openai": "^4.91.1",
|
||||
"radash": "^12.1.0",
|
||||
"url": "^0.11.4",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"drizzle-kit": "^0.30.6",
|
||||
"typescript": "^5.8.2",
|
||||
"vue-tsc": "^2.2.8",
|
||||
"wrangler": "^4.6.0"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@parcel/watcher",
|
||||
"esbuild",
|
||||
"sharp",
|
||||
"vue-demi",
|
||||
"workerd"
|
||||
]
|
||||
}
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
15
public/images/connect-with-strava.svg
Normal file
15
public/images/connect-with-strava.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg width="237" height="48" viewBox="0 0 237 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="236.867" height="48" rx="6" fill="#FC5200"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M180.749 31.8195L180.748 31.8188H185.357L188.188 26.1268L191.019 31.8188H196.618L188.187 15.5403L180.184 30.9945L177.111 26.5078C179.008 25.5928 180.191 24.0081 180.191 21.7318V21.687C180.191 20.0803 179.7 18.9197 178.763 17.9822C177.669 16.8887 175.906 16.1968 173.139 16.1968H165.506V31.8195H170.728V27.3558H171.844L174.79 31.8195H180.749ZM212.954 15.5403L204.524 31.8188H210.124L212.955 26.1268L215.786 31.8188H221.385L212.954 15.5403ZM200.576 32.4593L209.006 16.1808H203.406L200.575 21.8729L197.744 16.1808H192.144L200.576 32.4593ZM172.982 23.6287C174.232 23.6287 174.991 23.0708 174.991 22.1112V22.0663C174.991 21.0621 174.21 20.5711 173.005 20.5711H170.728V23.6287H172.982ZM154.337 20.6158H149.74V16.1968H164.157V20.6158H159.56V31.8195H154.337V20.6158ZM137.015 26.1507L134.225 29.4761C136.211 31.2172 139.068 32.1097 142.237 32.1097C146.433 32.1097 149.133 30.101 149.133 26.82V26.7756C149.133 23.6287 146.455 22.468 142.46 21.7318C140.808 21.419 140.384 21.1515 140.384 20.7273V20.6827C140.384 20.3033 140.742 20.0355 141.523 20.0355C142.973 20.0355 144.737 20.5042 146.209 21.5754L148.754 18.0493C146.946 16.6209 144.714 15.9065 141.701 15.9065C137.394 15.9065 135.073 18.2055 135.073 21.1737V21.2185C135.073 24.5214 138.153 25.526 141.656 26.2398C143.33 26.5747 143.821 26.82 143.821 27.2665V27.3113C143.821 27.7352 143.42 27.9805 142.482 27.9805C140.652 27.9805 138.711 27.4452 137.015 26.1507Z" fill="white"/>
|
||||
<path d="M117.92 31.6812V21.9622H119.919V25.7533H124.137V21.9622H126.136V31.6812H124.137V27.5179H119.919V31.6812H117.92Z" fill="white"/>
|
||||
<path d="M110.959 31.6812V23.713H107.844V21.9622H116.088V23.713H112.958V31.6812H110.959Z" fill="white"/>
|
||||
<path d="M104.02 31.6812V21.9622H106.018V31.6812H104.02Z" fill="white"/>
|
||||
<path d="M92.1013 31.6812L89.5371 21.9622H91.6464L92.7492 27.2008C92.8871 27.9039 93.0112 28.4691 93.1352 29.3376H93.1904C93.3282 28.607 93.4109 28.152 93.6315 27.187L94.8723 21.9622H96.9677L98.2084 27.187C98.429 28.152 98.5117 28.607 98.6496 29.3376H98.7047C98.8288 28.4691 98.9529 27.9039 99.0907 27.2008L100.207 21.9622H102.303L99.7387 31.6812H97.657L96.4301 26.4977C96.2646 25.7671 96.1543 25.2846 95.9476 24.2782H95.8924C95.6856 25.2846 95.5753 25.7671 95.4099 26.4977L94.183 31.6812H92.1013Z" fill="white"/>
|
||||
<path d="M79.9965 31.6812V23.713H76.8809V21.9622H85.1248V23.713H81.9954V31.6812H79.9965Z" fill="white"/>
|
||||
<path d="M71.524 31.888C68.7806 31.888 66.9746 29.8753 66.9746 26.8148C66.9746 23.7681 68.7806 21.7554 71.524 21.7554C73.6883 21.7554 75.3151 23.0237 75.577 24.8434L73.5781 25.3121C73.3575 24.1955 72.5855 23.5338 71.4964 23.5338C69.9799 23.5338 69.0287 24.7883 69.0287 26.8148C69.0287 28.8413 69.9799 30.1096 71.4964 30.1096C72.5855 30.1096 73.3575 29.4479 73.5781 28.345L75.577 28.8138C75.3151 30.6335 73.6883 31.888 71.524 31.888Z" fill="white"/>
|
||||
<path d="M58.459 31.6812V21.9622H65.2003V23.6578H60.4579V25.8636H64.8556V27.5041H60.4579V29.9856H65.2003V31.6812H58.459Z" fill="white"/>
|
||||
<path d="M47.7656 31.6812V21.9622H49.9576L52.9216 26.9113C53.3765 27.6557 53.6798 28.2899 54.0106 28.993H54.0796L54.0382 26.5115V21.9622H55.9407V31.6812H53.7487L50.771 26.7321C50.3298 25.9876 50.0265 25.3673 49.6819 24.6504H49.6267L49.6681 27.1319V31.6812H47.7656Z" fill="white"/>
|
||||
<path d="M37.0742 31.6812V21.9622H39.2662L42.2301 26.9113C42.6851 27.6557 42.9884 28.2899 43.3192 28.993H43.3882L43.3468 26.5115V21.9622H45.2493V31.6812H43.0573L40.0795 26.7321C39.6384 25.9876 39.3351 25.3673 38.9905 24.6504H38.9353L38.9767 27.1319V31.6812H37.0742Z" fill="white"/>
|
||||
<path d="M30.2903 31.888C27.4642 31.888 25.6582 29.8201 25.6582 26.8148C25.6582 23.8233 27.4642 21.7554 30.2903 21.7554C33.1164 21.7554 34.9223 23.8233 34.9223 26.8148C34.9223 29.8201 33.1164 31.888 30.2903 31.888ZM30.2903 30.1096C31.8481 30.1096 32.8682 28.9378 32.8682 26.8148C32.8682 24.6918 31.8481 23.5338 30.2903 23.5338C28.7325 23.5338 27.7123 24.6918 27.7123 26.8148C27.7123 28.9378 28.7325 30.1096 30.2903 30.1096Z" fill="white"/>
|
||||
<path d="M19.9868 31.888C17.2435 31.888 15.4375 29.8753 15.4375 26.8148C15.4375 23.7681 17.2435 21.7554 19.9868 21.7554C22.1512 21.7554 23.778 23.0237 24.0399 24.8434L22.0409 25.3121C21.8204 24.1955 21.0484 23.5338 19.9593 23.5338C18.4428 23.5338 17.4916 24.7883 17.4916 26.8148C17.4916 28.8413 18.4428 30.1096 19.9593 30.1096C21.0484 30.1096 21.8204 29.4479 22.0409 28.345L24.0399 28.8138C23.778 30.6335 22.1512 31.888 19.9868 31.888Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
4
public/images/powered-by-strava.svg
Normal file
4
public/images/powered-by-strava.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="176" height="60" viewBox="0 0 176 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.997728 12.9365H2.78645V8.40394H5.77344C8.361 8.40394 9.88923 6.97991 9.88923 4.60074C9.88923 2.23893 8.361 0.78017 5.7908 0.78017H0.997728V12.9365ZM2.78645 6.73678V2.44733H5.72134C7.31903 2.44733 8.10051 3.14198 8.10051 4.60074C8.10051 6.02477 7.30167 6.73678 5.70397 6.73678H2.78645ZM17.8991 13.197C21.025 13.197 23.0916 10.6789 23.0916 6.85835C23.0916 3.03778 21.025 0.519677 17.8991 0.519677C14.7732 0.519677 12.724 3.03778 12.724 6.85835C12.724 10.6789 14.7732 13.197 17.8991 13.197ZM17.8991 11.5299C15.7978 11.5299 14.5127 9.75851 14.5127 6.85835C14.5127 3.95819 15.7978 2.18683 17.8991 2.18683C20.0178 2.18683 21.3029 3.95819 21.3029 6.85835C21.3029 9.75851 20.0178 11.5299 17.8991 11.5299ZM28.124 12.9365H30.1732L32.9692 3.61086H33.0039L35.7825 12.9365H37.8491L40.1762 0.78017H38.3527L36.6161 10.0885H36.5814L33.8202 0.78017H32.1356L29.3918 10.0885H29.357L27.6204 0.78017H25.7796L28.124 12.9365ZM43.6196 12.9365H51.0697V11.2694H45.4083V7.48353H50.5834V5.88584H45.4083V2.44733H51.0697V0.78017H43.6196V12.9365ZM60.9085 8.05662C62.6972 7.65719 63.7565 6.35473 63.7565 4.47917C63.7565 2.16947 62.2109 0.78017 59.6755 0.78017H55.0908V12.9365H56.8795V8.19555H59.0155L61.5163 12.9365H63.5308L60.9085 8.07398V8.05662ZM56.8795 6.54575V2.44733H59.5018C61.1342 2.44733 61.9678 3.12461 61.9678 4.47917C61.9678 5.83374 61.1169 6.54575 59.5018 6.54575H56.8795ZM67.4778 12.9365H74.9279V11.2694H69.2665V7.48353H74.4416V5.88584H69.2665V2.44733H74.9279V0.78017H67.4778V12.9365ZM78.949 12.9365H82.7869C85.9823 12.9365 88.1183 10.9915 88.1183 6.84098C88.1183 3.02041 85.9823 0.78017 82.8564 0.78017H78.949V12.9365ZM80.7377 11.2694V2.44733H82.8043C84.9577 2.44733 86.3296 3.92346 86.3296 6.84098C86.3296 10.0016 84.9577 11.2694 82.7175 11.2694H80.7377ZM98.33 12.9365H102.828C105.711 12.9365 107.204 11.6862 107.204 9.28962C107.204 7.91769 106.492 6.85835 105.398 6.42419V6.38946C106.232 5.97267 106.753 5.08699 106.753 3.95819C106.753 2.06527 105.311 0.78017 103.192 0.78017H98.33V12.9365ZM100.119 5.83374V2.42996H102.897C104.252 2.42996 104.964 3.00305 104.964 4.11448C104.964 5.26065 104.287 5.83374 102.897 5.83374H100.119ZM100.119 11.2867V7.3446H102.863C104.651 7.3446 105.415 7.93505 105.415 9.27225C105.415 10.6789 104.634 11.2867 102.828 11.2867H100.119ZM113.105 12.9365H114.893V7.90032L119.079 0.78017H117.082L114.008 6.23316H113.973L110.899 0.78017H108.902L113.105 7.90032V12.9365Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M93.6921 57.7137L93.6911 57.7123H102.972L108.673 46.2495L114.374 57.7123H125.651L108.672 24.9302L92.5543 56.0524L86.3661 47.0167C90.1869 45.1741 92.5685 41.9828 92.5685 37.3987V37.3085C92.5685 34.073 91.5802 31.7356 89.6922 29.8477C87.4897 27.6455 83.9392 26.2523 78.3664 26.2523H62.995V57.7137H73.5122V48.7246H75.7594L81.6921 57.7137H93.6921ZM158.547 24.9302L141.57 57.7123H152.848L158.549 46.2495L164.25 57.7123H175.527L158.547 24.9302ZM133.62 59.0022L150.597 26.22H139.32L133.619 37.6829L127.918 26.22H116.641L133.62 59.0022ZM78.0517 41.2191C80.5681 41.2191 82.0965 40.0956 82.0965 38.163V38.0728C82.0965 36.0504 80.5232 35.0617 78.0966 35.0617H73.5122V41.2191H78.0517ZM40.5035 35.1512H31.2454V26.2523H60.2792V35.1512H51.0211V57.7137H40.5035V35.1512ZM5.61851 46.2977L0 52.9945C4.00023 56.5007 9.75321 58.2981 16.135 58.2981C24.5849 58.2981 30.0233 54.2529 30.0233 47.6456V47.5561C30.0233 41.2189 24.6298 38.8815 16.5848 37.3988C13.2587 36.7689 12.4048 36.2303 12.4048 35.376V35.2861C12.4048 34.5221 13.1242 33.9828 14.6969 33.9828C17.6181 33.9828 21.1693 34.9266 24.1351 37.0838L29.2593 29.9829C25.6186 27.1063 21.1243 25.6678 15.0568 25.6678C6.38181 25.6678 1.70781 30.2975 1.70781 36.2749V36.3651C1.70781 43.0166 7.91057 45.0397 14.9665 46.4771C18.3376 47.1516 19.3259 47.6456 19.3259 48.5448V48.6351C19.3259 49.4886 18.5173 49.9826 16.6294 49.9826C12.9441 49.9826 9.03413 48.9047 5.61851 46.2977Z" fill="#FC5200"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
1
server/api/ping.ts
Normal file
1
server/api/ping.ts
Normal file
@@ -0,0 +1 @@
|
||||
export default eventHandler(() => 'pong')
|
||||
13
server/api/preferences.get.ts
Normal file
13
server/api/preferences.get.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await requireUserSession(event);
|
||||
const db = useDrizzle();
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: (f, o) => o.eq(f.id, session.user.id),
|
||||
with: {
|
||||
preferences: true,
|
||||
},
|
||||
});
|
||||
|
||||
return user?.preferences.data;
|
||||
});
|
||||
18
server/api/preferences.put.ts
Normal file
18
server/api/preferences.put.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await requireUserSession(event);
|
||||
const db = useDrizzle();
|
||||
const body = await readBody(event);
|
||||
|
||||
const [preferences] = await db
|
||||
.update(tables.preferences)
|
||||
.set({
|
||||
data: {
|
||||
enabled: body.enabled,
|
||||
language: body.language,
|
||||
},
|
||||
})
|
||||
.where(eq(tables.preferences.userId, session.user.id))
|
||||
.returning();
|
||||
|
||||
return preferences.data;
|
||||
});
|
||||
19
server/database/migrations/0000_tearful_the_initiative.sql
Normal file
19
server/database/migrations/0000_tearful_the_initiative.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE `tokens` (
|
||||
`user_id` integer PRIMARY KEY NOT NULL,
|
||||
`refresh_token` text,
|
||||
`access_token` text,
|
||||
`expires_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `tokens_user_id_unique` ON `tokens` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`avatar` text NOT NULL,
|
||||
`city` text,
|
||||
`country` text,
|
||||
`sex` text,
|
||||
`weight` integer,
|
||||
`created_at` integer
|
||||
);
|
||||
14
server/database/migrations/0001_spooky_gravity.sql
Normal file
14
server/database/migrations/0001_spooky_gravity.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_tokens` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`user_id` integer,
|
||||
`refresh_token` text,
|
||||
`access_token` text,
|
||||
`expires_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_tokens`("id", "user_id", "refresh_token", "access_token", "expires_at") SELECT "id", "user_id", "refresh_token", "access_token", "expires_at" FROM `tokens`;--> statement-breakpoint
|
||||
DROP TABLE `tokens`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_tokens` RENAME TO `tokens`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;
|
||||
1
server/database/migrations/0002_plain_shooting_star.sql
Normal file
1
server/database/migrations/0002_plain_shooting_star.sql
Normal file
@@ -0,0 +1 @@
|
||||
CREATE UNIQUE INDEX `tokens_user_id_unique` ON `tokens` (`user_id`);
|
||||
15
server/database/migrations/0003_perpetual_bill_hollister.sql
Normal file
15
server/database/migrations/0003_perpetual_bill_hollister.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_tokens` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`user_id` integer,
|
||||
`refresh_token` text,
|
||||
`access_token` text,
|
||||
`expires_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_tokens`("id", "user_id", "refresh_token", "access_token", "expires_at") SELECT "id", "user_id", "refresh_token", "access_token", "expires_at" FROM `tokens`;--> statement-breakpoint
|
||||
DROP TABLE `tokens`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_tokens` RENAME TO `tokens`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `tokens_user_id_unique` ON `tokens` (`user_id`);
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE `preferences` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`user_id` integer,
|
||||
`data` jsonb,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `preferences_user_id_unique` ON `preferences` (`user_id`);
|
||||
144
server/database/migrations/meta/0000_snapshot.json
Normal file
144
server/database/migrations/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "7cffca6e-97cc-4159-bf48-2f8bbd7aded4",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"tokens": {
|
||||
"name": "tokens",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"tokens_user_id_unique": {
|
||||
"name": "tokens_user_id_unique",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"tokens_user_id_users_id_fk": {
|
||||
"name": "tokens_user_id_users_id_fk",
|
||||
"tableFrom": "tokens",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"avatar": {
|
||||
"name": "avatar",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"city": {
|
||||
"name": "city",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"country": {
|
||||
"name": "country",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sex": {
|
||||
"name": "sex",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"weight": {
|
||||
"name": "weight",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
143
server/database/migrations/meta/0001_snapshot.json
Normal file
143
server/database/migrations/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "9b994045-7d5b-470e-bd5a-bf4bf93bb270",
|
||||
"prevId": "7cffca6e-97cc-4159-bf48-2f8bbd7aded4",
|
||||
"tables": {
|
||||
"tokens": {
|
||||
"name": "tokens",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"tokens_user_id_users_id_fk": {
|
||||
"name": "tokens_user_id_users_id_fk",
|
||||
"tableFrom": "tokens",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"avatar": {
|
||||
"name": "avatar",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"city": {
|
||||
"name": "city",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"country": {
|
||||
"name": "country",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sex": {
|
||||
"name": "sex",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"weight": {
|
||||
"name": "weight",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
151
server/database/migrations/meta/0002_snapshot.json
Normal file
151
server/database/migrations/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "2aec8d0e-ddc1-49ff-983c-ac0f5be3d10e",
|
||||
"prevId": "9b994045-7d5b-470e-bd5a-bf4bf93bb270",
|
||||
"tables": {
|
||||
"tokens": {
|
||||
"name": "tokens",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"tokens_user_id_unique": {
|
||||
"name": "tokens_user_id_unique",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"tokens_user_id_users_id_fk": {
|
||||
"name": "tokens_user_id_users_id_fk",
|
||||
"tableFrom": "tokens",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"avatar": {
|
||||
"name": "avatar",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"city": {
|
||||
"name": "city",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"country": {
|
||||
"name": "country",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sex": {
|
||||
"name": "sex",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"weight": {
|
||||
"name": "weight",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
151
server/database/migrations/meta/0003_snapshot.json
Normal file
151
server/database/migrations/meta/0003_snapshot.json
Normal file
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "828a623e-bbd1-4a3e-adcf-1ac58e81c9b3",
|
||||
"prevId": "2aec8d0e-ddc1-49ff-983c-ac0f5be3d10e",
|
||||
"tables": {
|
||||
"tokens": {
|
||||
"name": "tokens",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"tokens_user_id_unique": {
|
||||
"name": "tokens_user_id_unique",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"tokens_user_id_users_id_fk": {
|
||||
"name": "tokens_user_id_users_id_fk",
|
||||
"tableFrom": "tokens",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"avatar": {
|
||||
"name": "avatar",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"city": {
|
||||
"name": "city",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"country": {
|
||||
"name": "country",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sex": {
|
||||
"name": "sex",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"weight": {
|
||||
"name": "weight",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
204
server/database/migrations/meta/0004_snapshot.json
Normal file
204
server/database/migrations/meta/0004_snapshot.json
Normal file
@@ -0,0 +1,204 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "b1388198-f0bd-4d77-ba71-d5740f1a09a7",
|
||||
"prevId": "828a623e-bbd1-4a3e-adcf-1ac58e81c9b3",
|
||||
"tables": {
|
||||
"preferences": {
|
||||
"name": "preferences",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"data": {
|
||||
"name": "data",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"preferences_user_id_unique": {
|
||||
"name": "preferences_user_id_unique",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"preferences_user_id_users_id_fk": {
|
||||
"name": "preferences_user_id_users_id_fk",
|
||||
"tableFrom": "preferences",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tokens": {
|
||||
"name": "tokens",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"tokens_user_id_unique": {
|
||||
"name": "tokens_user_id_unique",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"tokens_user_id_users_id_fk": {
|
||||
"name": "tokens_user_id_users_id_fk",
|
||||
"tableFrom": "tokens",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"avatar": {
|
||||
"name": "avatar",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"city": {
|
||||
"name": "city",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"country": {
|
||||
"name": "country",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sex": {
|
||||
"name": "sex",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"weight": {
|
||||
"name": "weight",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
41
server/database/migrations/meta/_journal.json
Normal file
41
server/database/migrations/meta/_journal.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1743665160397,
|
||||
"tag": "0000_tearful_the_initiative",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1743691140050,
|
||||
"tag": "0001_spooky_gravity",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1743691375389,
|
||||
"tag": "0002_plain_shooting_star",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1743691967975,
|
||||
"tag": "0003_perpetual_bill_hollister",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1743776869050,
|
||||
"tag": "0004_cultured_doctor_strange",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
89
server/database/schema.ts
Normal file
89
server/database/schema.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
sqliteTable,
|
||||
text,
|
||||
integer,
|
||||
customType,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
|
||||
const customJsonb = <TData>(name: string) =>
|
||||
customType<{ data: TData; driverData: string }>({
|
||||
dataType() {
|
||||
return "jsonb";
|
||||
},
|
||||
toDriver(value: TData): string {
|
||||
return JSON.stringify(value);
|
||||
},
|
||||
fromDriver(value: string): TData {
|
||||
return JSON.parse(value);
|
||||
},
|
||||
})(name);
|
||||
|
||||
export const users = sqliteTable("users", {
|
||||
id: integer("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
avatar: text("avatar").notNull(),
|
||||
city: text("city"),
|
||||
country: text("country"),
|
||||
sex: text("sex"),
|
||||
weight: integer("weight"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||
() => new Date()
|
||||
),
|
||||
});
|
||||
|
||||
export const preferences = sqliteTable("preferences", {
|
||||
id: integer("id").primaryKey(),
|
||||
userId: integer("user_id")
|
||||
.references(() => users.id, {
|
||||
onDelete: "cascade",
|
||||
})
|
||||
.unique(),
|
||||
data: customJsonb("data")
|
||||
.$type<{
|
||||
enabled: boolean;
|
||||
language: string;
|
||||
}>()
|
||||
.$defaultFn(() => ({
|
||||
enabled: true,
|
||||
language: "English",
|
||||
})),
|
||||
});
|
||||
|
||||
export const tokens = sqliteTable("tokens", {
|
||||
id: integer("id").primaryKey(),
|
||||
userId: integer("user_id")
|
||||
.references(() => users.id, {
|
||||
onDelete: "cascade",
|
||||
})
|
||||
.unique(),
|
||||
refreshToken: text("refresh_token"),
|
||||
accessToken: text("access_token"),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||
});
|
||||
|
||||
// Define relationships
|
||||
export const usersRelations = relations(users, ({ one }) => ({
|
||||
tokens: one(tokens, {
|
||||
fields: [users.id],
|
||||
references: [tokens.userId],
|
||||
}),
|
||||
preferences: one(preferences, {
|
||||
fields: [users.id],
|
||||
references: [preferences.userId],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const referencesRelations = relations(preferences, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [preferences.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const tokensRelations = relations(tokens, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [tokens.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
34
server/plugins/webhook.ts
Normal file
34
server/plugins/webhook.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { isEmpty } from "radash";
|
||||
import { URLSearchParams } from "url";
|
||||
|
||||
export default defineNitroPlugin(() => {
|
||||
onHubReady(async () => {
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
const webhooks = await $fetch(
|
||||
"https://www.strava.com/api/v3/push_subscriptions",
|
||||
{
|
||||
params: {
|
||||
client_id: config.oauth.strava.clientId,
|
||||
client_secret: config.oauth.strava.clientSecret,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!isEmpty(webhooks)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await $fetch("https://www.strava.com/api/v3/push_subscriptions", {
|
||||
method: "post",
|
||||
body: new URLSearchParams({
|
||||
client_id: config.oauth.strava.clientId,
|
||||
client_secret: config.oauth.strava.clientSecret,
|
||||
callback_url: config.webhooksUrl,
|
||||
verify_token: config.stravaVerifyToken,
|
||||
}),
|
||||
});
|
||||
|
||||
console.log("Webhook registered successfully!");
|
||||
});
|
||||
});
|
||||
65
server/routes/auth/strava.ts
Normal file
65
server/routes/auth/strava.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { omit } from "radash";
|
||||
|
||||
export default defineOAuthStravaEventHandler({
|
||||
config: {
|
||||
scope: ["read,activity:read,activity:write"],
|
||||
},
|
||||
onSuccess: async (event, auth) => {
|
||||
const userPayload = {
|
||||
id: auth.user.id,
|
||||
name: `${auth.user.firstname} ${auth.user.lastname}`,
|
||||
city: auth.user.city,
|
||||
country: auth.user.country,
|
||||
sex: auth.user.sex,
|
||||
weight: auth.user.weight,
|
||||
avatar: auth.user.profile,
|
||||
};
|
||||
|
||||
await setUserSession(event, {
|
||||
user: userPayload,
|
||||
});
|
||||
|
||||
const db = useDrizzle();
|
||||
|
||||
const [user] = await db
|
||||
.insert(tables.users)
|
||||
.values(userPayload)
|
||||
.onConflictDoUpdate({
|
||||
target: tables.users.id,
|
||||
set: omit(userPayload, ["id"]),
|
||||
})
|
||||
.returning();
|
||||
|
||||
const tokenExpiration = new Date(auth.tokens.expires_at * 1000);
|
||||
|
||||
await db
|
||||
.insert(tables.tokens)
|
||||
.values({
|
||||
userId: user.id,
|
||||
refreshToken: auth.tokens.refresh_token,
|
||||
accessToken: auth.tokens.access_token,
|
||||
expiresAt: tokenExpiration,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: tables.tokens.userId,
|
||||
set: {
|
||||
refreshToken: auth.tokens.refresh_token,
|
||||
accessToken: auth.tokens.access_token,
|
||||
expiresAt: tokenExpiration,
|
||||
},
|
||||
});
|
||||
|
||||
await db
|
||||
.insert(tables.preferences)
|
||||
.values({
|
||||
userId: user.id,
|
||||
data: {
|
||||
enabled: true,
|
||||
language: "english",
|
||||
},
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
sendRedirect(event, "/");
|
||||
},
|
||||
});
|
||||
58
server/routes/webhooks/strava/activity-create.post.ts
Normal file
58
server/routes/webhooks/strava/activity-create.post.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { get } from "radash";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
const db = useDrizzle();
|
||||
|
||||
const ai = hubAI();
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: (f, o) => o.eq(f.id, body.owner_id),
|
||||
with: {
|
||||
preferences: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.preferences.data?.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const strava = await useStrava(body.owner_id);
|
||||
|
||||
const activity = await strava!(`/activities/${body.object_id}`);
|
||||
|
||||
const aiResponse = await ai.run("@cf/meta/llama-3-8b-instruct", {
|
||||
response_format: {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: "string",
|
||||
description: "string",
|
||||
},
|
||||
required: ["title", "description"],
|
||||
},
|
||||
},
|
||||
prompt: `
|
||||
Generate a title and a short description for my strava activity. Use my preferred language. Make sure to include emojis and make it fun.
|
||||
|
||||
My user profile:
|
||||
Sex: ${user?.sex}
|
||||
City: ${user?.city}
|
||||
Country: ${user?.country}
|
||||
Weight: ${user?.weight}
|
||||
Language: ${user?.preferences.data.language}
|
||||
|
||||
The activity data in json format:
|
||||
${JSON.stringify(activity)}
|
||||
`,
|
||||
});
|
||||
|
||||
await strava!(`activities/${body.object_id}`, {
|
||||
method: "PUT",
|
||||
body: {
|
||||
name: get(aiResponse, "response.title"),
|
||||
description: get(aiResponse, "response.description"),
|
||||
},
|
||||
});
|
||||
});
|
||||
17
server/routes/webhooks/strava/athelete-update.post.ts
Normal file
17
server/routes/webhooks/strava/athelete-update.post.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { get, isEmpty } from "radash";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
const db = useDrizzle();
|
||||
|
||||
if (get(body, "updates.authorized") !== false) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(tables.users)
|
||||
.where(eq(tables.users.id, get(body, "object_id")));
|
||||
|
||||
sendNoContent(event);
|
||||
});
|
||||
5
server/routes/webhooks/strava/index.get.ts
Normal file
5
server/routes/webhooks/strava/index.get.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event);
|
||||
|
||||
return { "hub.challenge": query["hub.challenge"] };
|
||||
});
|
||||
14
server/routes/webhooks/strava/index.post.ts
Normal file
14
server/routes/webhooks/strava/index.post.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { get } from "radash";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
const db = useDrizzle();
|
||||
|
||||
const aspectType = get(body, "aspect_type");
|
||||
const objectType = get(body, "object_type");
|
||||
|
||||
await $fetch(`/webhooks/strava/${objectType}-${aspectType}`, {
|
||||
method: "post",
|
||||
body,
|
||||
});
|
||||
});
|
||||
3
server/tsconfig.json
Normal file
3
server/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../.nuxt/tsconfig.server.json"
|
||||
}
|
||||
13
server/utils/drizzle.ts
Normal file
13
server/utils/drizzle.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { drizzle } from "drizzle-orm/d1";
|
||||
export { sql, eq, and, or } from "drizzle-orm";
|
||||
|
||||
import * as schema from "../database/schema";
|
||||
|
||||
export const tables = schema;
|
||||
|
||||
export function useDrizzle() {
|
||||
return drizzle(hubDatabase(), { schema });
|
||||
}
|
||||
|
||||
export type User = typeof schema.users.$inferSelect;
|
||||
export type Tokens = typeof schema.tokens.$inferSelect;
|
||||
66
server/utils/strava-client.ts
Normal file
66
server/utils/strava-client.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { get, isEmpty } from "radash";
|
||||
import { isAfter } from "@formkit/tempo";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { URLSearchParams } from "url";
|
||||
|
||||
const refreshStravaToken = async (refreshToken: string) => {
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
const tokensResponse = await $fetch("https://www.strava.com/oauth/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: config.oauth.strava.clientId,
|
||||
client_secret: config.oauth.strava.clientSecret,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
refreshToken: get(tokensResponse, "refresh_token"),
|
||||
accessToken: get(tokensResponse, "access_token"),
|
||||
expiresAt: new Date(get(tokensResponse, "expires_at", 0) * 1000),
|
||||
needsUpdate: true,
|
||||
};
|
||||
};
|
||||
|
||||
export const useStrava = async (userId: number) => {
|
||||
const db = useDrizzle();
|
||||
const now = new Date();
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: (f, o) => o.eq(f.id, userId),
|
||||
with: {
|
||||
tokens: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (isEmpty(user)) {
|
||||
console.error("No user found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const tokens = isAfter(now, user?.tokens?.expiresAt!)
|
||||
? await refreshStravaToken(user?.tokens?.refreshToken!)
|
||||
: user?.tokens;
|
||||
|
||||
if (get(tokens, "needsUpdate", false)) {
|
||||
db.update(tables.tokens)
|
||||
.set({
|
||||
refreshToken: tokens?.refreshToken as string,
|
||||
accessToken: tokens?.accessToken as string,
|
||||
expiresAt: tokens?.expiresAt,
|
||||
})
|
||||
.where(eq(tables.tokens.userId, userId));
|
||||
}
|
||||
|
||||
return $fetch.create({
|
||||
baseURL: "https://www.strava.com/api/v3/",
|
||||
onRequest({ options }) {
|
||||
options.headers.set("Authorization", `Bearer ${tokens?.accessToken}`);
|
||||
},
|
||||
});
|
||||
};
|
||||
8
shell.nix
Normal file
8
shell.nix
Normal file
@@ -0,0 +1,8 @@
|
||||
{ pkgs ? import <nixpkgs> {} }:
|
||||
|
||||
pkgs.mkShell {
|
||||
buildInputs = [
|
||||
pkgs.nodejs
|
||||
pkgs.git
|
||||
];
|
||||
}
|
||||
4
tsconfig.json
Normal file
4
tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// https://nuxt.com/docs/guide/concepts/typescript
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
1
types.d.ts
vendored
Normal file
1
types.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "url";
|
||||
Reference in New Issue
Block a user