Import drizzle replacing prisma

Signed-off-by: Arthur DANJOU <arthurdanjou@outlook.fr>
This commit is contained in:
2024-04-20 00:03:10 +02:00
parent a7f0a635ec
commit c6ba8c791b
108 changed files with 2367 additions and 1554 deletions

61
.vscode/settings.json vendored
View File

@@ -1,61 +0,0 @@
{
// TailwindCSS
"files.associations": {
"*.css": "tailwindcss"
},
"editor.quickSuggestions": {
"strings": true
},
"tailwindCSS.experimental.configFile": "tailwind.config.ts",
"tailwindCSS.experimental.classRegex": [
["ui:\\s*{([^)]*)\\s*}", "[\"'`]([^\"'`]*).*?[\"'`]"],
["/\\*ui\\*/\\s*{([^;]*)}", ":\\s*[\"'`]([^\"'`]*).*?[\"'`]"]
],
"tailwindCSS.classAttributes": [
"class",
"ui"
],
// Enable the ESlint flat config support
"eslint.experimental.useFlatConfig": true,
// Disable the default formatter, use eslint instead
"prettier.enable": false,
"editor.formatOnSave": false,
// Auto fix
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
// Silent the stylistic rules in you IDE, but still auto fix them
"eslint.rules.customizations": [
{ "rule": "style/*", "severity": "off" },
{ "rule": "*-indent", "severity": "off" },
{ "rule": "*-spacing", "severity": "off" },
{ "rule": "*-spaces", "severity": "off" },
{ "rule": "*-order", "severity": "off" },
{ "rule": "*-dangle", "severity": "off" },
{ "rule": "*-newline", "severity": "off" },
{ "rule": "*quotes", "severity": "off" },
{ "rule": "*semi", "severity": "off" }
],
// Enable eslint for all supported languages
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue",
"html",
"markdown",
"json",
"jsonc",
"yaml"
],
// Grammarly
"grammarly.files.include": ["**/*.txt", "**/*.md"]
}

View File

View File

@@ -12,7 +12,7 @@ function getColor() {
<div class="relative">
<h1 class="px-4 py-2 bg-white dark:bg-zinc-900 rounded-md border border-zinc-100 dark:border-zinc-300/10" v-html="announce.content" />
<span class="absolute -top-0.5 -right-0.5 flex h-2 w-2">
<span span class="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75" :class="getColor()" />
<span class="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75" :class="getColor()" />
<span class="relative inline-flex rounded-full h-2 w-2" :class="getColor()" />
</span>
</div>

View File

@@ -7,7 +7,7 @@ function jumpVal(val: number) {
return Math.random() > 0.5 ? val + (Math.random() - 0.5) / 2 : Math.random()
}
let timeout: any
let timeout: NodeJS.Timeout
function jumpPoints() {
for (let i = 0; i < points.value.length; i++)
points.value[i] = [jumpVal(points.value[i][0]), jumpVal(points.value[i][1])]
@@ -21,7 +21,10 @@ onUnmounted(() => clearTimeout(timeout))
<template>
<ClientOnly>
<div class="bg sm:mx-8 absolute inset-0 z-20 transform-gpu blur-3xl overflow-hidden" aria-hidden="true">
<div
aria-hidden="true"
class="bg sm:mx-8 absolute inset-0 z-20 transform-gpu blur-3xl overflow-hidden"
>
<div
class="aspect-[2] h-2/3 w-full bg-gradient-to-r from-[rgb(var(--color-primary-DEFAULT))] to-white/10 lg:opacity-30 xs:opacity-50"
:style="{ 'clip-path': `polygon(${poly})` }"

View File

@@ -13,11 +13,25 @@ const year = computed(() => new Date().getFullYear())
<p class="text-subtitle">
Designed & Built by
</p>
<UButton variant="link" color="primary" label="Arthur Danjou" to="https://twitter.com/arthurdanj" target="_blank" />
<UButton
color="primary"
label="Arthur Danjou"
target="_blank"
to="https://twitter.com/arthurdanj"
variant="link"
/>
</div>
<p class="text-subtitle flex items-center">
Made with
<UButton variant="link" color="green" label="Nuxt 3" to="https://nuxt.com/" target="_blank" icon="i-vscode-icons-file-type-nuxt" trailing />
<UButton
color="green"
icon="i-vscode-icons-file-type-nuxt"
label="Nuxt 3"
target="_blank"
to="https://nuxt.com/"
trailing
variant="link"
/>
</p>
</div>
</div>

View File

@@ -2,8 +2,8 @@
defineProps({
title: {
type: String,
default: 'Uses Section title',
},
default: 'Uses Section title'
}
})
const appConfig = useAppConfig()
@@ -14,7 +14,10 @@ const getColor = computed(() => `text-${appConfig.ui.primary}-500`)
<template>
<section class="md:border-l md:border-zinc-100 md:pl-6 md:dark:border-zinc-700/40 mb-24 px-4">
<div class="grid max-w-3xl grid-cols-1 items-baseline gap-y-8 md:grid-cols-4">
<h2 class="relative text-sm font-semibold pl-3.5" :class="getColor">
<h2
:class="getColor"
class="relative text-sm font-semibold pl-3.5"
>
<span class="md:hidden absolute inset-y-0 left-0 flex items-center">
<span class="h-4 w-0.5 rounded-full bg-zinc-200 dark:bg-zinc-500" />
</span>

View File

@@ -2,8 +2,8 @@
defineProps({
title: {
type: String,
default: 'Uses Slot title',
},
default: 'Uses Slot title'
}
})
</script>

View File

@@ -2,13 +2,13 @@
defineProps({
href: {
type: String,
default: '',
default: ''
},
target: {
type: String,
default: undefined,
required: false,
},
required: false
}
})
const appConfig = useAppConfig()

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
const appConfig = useAppConfig()
const getTextColor = computed(() => `text-${appConfig.ui.primary}-500`)
function getGroupColor() {
return `group-hover:text-${appConfig.ui.primary}-500`
}
</script>
<template>
<NuxtLink
class="flex gap-1 items-center rounded-xl group text-xl !bg-transparent !dark:bg-transparent"
to="/"
>
<span
:class="getTextColor"
class="font-black group-hover:text-black dark:group-hover:text-white duration-300"
>Arthur</span>
<span
:class="getGroupColor()"
class="font-bold text-gray-300 dark:text-neutral-600 duration-300"
>/</span>
<span
:class="getTextColor"
class="font-black group-hover:text-black dark:group-hover:text-white duration-300"
>Danjou</span>
</NuxtLink>
</template>

View File

@@ -33,7 +33,10 @@ const { copy, copied } = useClipboard({ source: 'arthurdanjou@outlook.fr', copie
</div>
<USlideover v-model="isOpenSidebar">
<UCard class="flex flex-col flex-1" :ui="{ body: { base: 'flex-1' }, ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }">
<UCard
:ui="{ body: { base: 'flex-1' }, ring: '', divide: 'divide-y divide-gray-100 dark:divide-gray-800' }"
class="flex flex-col flex-1"
>
<template #header>
<div class="flex justify-between items-center">
<Logo />
@@ -47,7 +50,11 @@ const { copy, copied } = useClipboard({ source: 'arthurdanjou@outlook.fr', copie
</template>
<div class="flex flex-col space-y-2">
<div v-for="nav in navs" :key="nav.label" class="w-full">
<div
v-for="nav in navs"
:key="nav.label"
class="w-full"
>
<UButton
v-if="nav.to"
size="sm"
@@ -83,7 +90,12 @@ const { copy, copied } = useClipboard({ source: 'arthurdanjou@outlook.fr', copie
<h1 class="text-xl font-bold">
Contact me
</h1>
<UButton size="xs" icon="i-akar-icons-cross" variant="ghost" @click.prevent="isOpenModal = false" />
<UButton
icon="i-akar-icons-cross"
size="xs"
variant="ghost"
@click.prevent="isOpenModal = false"
/>
</div>
<div class="flex flex-col space-y-6">
<div class="flex flex-col md:flex-row justify-between md:items-center space-y-2">
@@ -96,10 +108,32 @@ const { copy, copied } = useClipboard({ source: 'arthurdanjou@outlook.fr', copie
</p>
</div>
<div>
<UButtonGroup size="sm" orientation="horizontal">
<UButton variant="solid" color="gray" label="Compose" to="mailto:arthurdanjou@outlook.fr" icon="i-mdi-note-edit-outline" />
<UButton v-if="copied" variant="solid" color="green" label="Copied" icon="i-mdi-content-copy" />
<UButton v-else variant="solid" color="gray" label="Copy" icon="i-mdi-content-copy" @click.prevent="copy()" />
<UButtonGroup
orientation="horizontal"
size="sm"
>
<UButton
color="gray"
icon="i-mdi-note-edit-outline"
label="Compose"
to="mailto:arthurdanjou@outlook.fr"
variant="solid"
/>
<UButton
v-if="copied"
color="green"
icon="i-mdi-content-copy"
label="Copied"
variant="solid"
/>
<UButton
v-else
color="gray"
icon="i-mdi-content-copy"
label="Copy"
variant="solid"
@click.prevent="copy()"
/>
</UButtonGroup>
</div>
</div>
@@ -114,14 +148,25 @@ const { copy, copied } = useClipboard({ source: 'arthurdanjou@outlook.fr', copie
</p>
</div>
<div>
<UButtonGroup size="sm" orientation="horizontal">
<UButtonGroup
orientation="horizontal"
size="sm"
>
<UButton
variant="solid" color="gray" label="Github" icon="i-ph-github-logo-bold"
to="https://github.com/ArthurDanjou" target="_blank"
color="gray"
icon="i-ph-github-logo-bold"
label="Github"
target="_blank"
to="https://github.com/ArthurDanjou"
variant="solid"
/>
<UButton
variant="solid" color="gray" label="Twitter" icon="i-ph-twitter-logo-bold"
to="https://twitter.com/ArthurDanj" target="_blank"
color="gray"
icon="i-ph-twitter-logo-bold"
label="Twitter"
target="_blank"
to="https://twitter.com/ArthurDanj"
variant="solid"
/>
</UButtonGroup>
</div>

View File

@@ -0,0 +1,174 @@
<script setup lang="ts">
import { otherTab } from '~~/types'
const route = useRoute()
const isOpenModal = ref(false)
const { copy, copied } = useClipboard({ source: 'arthurdanjou@outlook.fr', copiedDuring: 3000 })
</script>
<template>
<nav class="hidden lg:block z-50">
<div class="flex items-center h-10 rounded-md p-1 gap-1 relative bg-black/5 text-sm font-medium text-zinc-700 dark:bg-zinc-800/90 dark:text-zinc-300">
<UButton
:class="{ 'link-active': route.path === '/' }"
color="white"
size="sm"
to="/"
variant="ghost"
>
Home
</UButton>
<UButton
:class="{ 'link-active': route.path.includes('/about') }"
color="white"
size="sm"
to="/about"
variant="ghost"
>
About
</UButton>
<!-- <UButton to="/writing" size="sm" variant="ghost" color="white" :class="{ 'link-active': route.path.includes('/writing') }">
Articles
</UButton> -->
<UButton
:class="{ 'link-active': route.path.includes('/work') }"
color="white"
size="sm"
to="/work"
variant="ghost"
>
Projects
</UButton>
<UButton
:class="{ 'link-active': route.path.includes('/uses') }"
color="white"
size="sm"
to="/uses"
variant="ghost"
>
Uses
</UButton>
<UDropdown
:items="otherTab"
:popper="{ placement: 'bottom' }"
mode="hover"
>
<UButton
class="duration-300"
color="white"
size="sm"
variant="ghost"
>
Other
</UButton>
</UDropdown>
<UButton
color="white"
size="sm"
variant="ghost"
@click="isOpenModal = true"
>
Contact
</UButton>
</div>
<UModal v-model="isOpenModal">
<UCard class="p-4">
<div>
<div class="mb-8 flex justify-between items-center">
<h1 class="text-xl font-bold">
Contact me
</h1>
<UButton
icon="i-akar-icons-cross"
size="xs"
variant="ghost"
@click.prevent="isOpenModal = false"
/>
</div>
<div class="flex flex-col space-y-6">
<div class="flex flex-col md:flex-row justify-between md:items-center space-y-2">
<div class="flex flex-col">
<h3 class="text-sm">
Email
</h3>
<p class="text-xs text-subtitle">
arthurdanjou@outlook.fr
</p>
</div>
<div>
<UButtonGroup
orientation="horizontal"
size="sm"
>
<UButton
color="gray"
icon="i-mdi-note-edit-outline"
label="Compose"
to="mailto:arthurdanjou@outlook.fr"
variant="solid"
/>
<UButton
v-if="copied"
color="green"
icon="i-mdi-content-copy"
label="Copied"
variant="solid"
/>
<UButton
v-else
color="gray"
icon="i-mdi-content-copy"
label="Copy"
variant="solid"
@click.prevent="copy()"
/>
</UButtonGroup>
</div>
</div>
<UDivider label="OR" />
<div class="flex flex-col md:flex-row justify-between md:items-center space-y-2">
<div class="flex flex-col">
<h3 class="text-sm">
Get in touch
</h3>
<p class="text-xs text-subtitle">
I'm most active on Twitter
</p>
</div>
<div>
<UButtonGroup
orientation="horizontal"
size="sm"
>
<UButton
color="gray"
icon="i-ph-github-logo-bold"
label="Github"
target="_blank"
to="https://github.com/ArthurDanjou"
variant="solid"
/>
<UButton
color="gray"
icon="i-ph-twitter-logo-bold"
label="Twitter"
target="_blank"
to="https://twitter.com/ArthurDanj"
variant="solid"
/>
</UButtonGroup>
</div>
</div>
</div>
</div>
</UCard>
</UModal>
</nav>
</template>
<style lang="scss">
.link-active {
@apply bg-white/60 dark:bg-black
}
</style>

View File

@@ -19,7 +19,7 @@ watch(isDark, () => {
:ui="{
background: 'bg-white dark:bg-stone-900',
ring: 'ring-1 ring-gray-200 dark:ring-stone-800',
container: 'z-30',
container: 'z-30'
}"
>
<template #default="{ open }">
@@ -31,14 +31,23 @@ watch(isDark, () => {
:class="[open && 'bg-gray-50 dark:bg-gray-800']"
aria-label="Color picker"
>
<UIcon name="i-ph-paint-brush-bold" class="w-5 h-5 text-primary-500 dark:text-primary-400" />
<UIcon
class="w-5 h-5 text-primary-500 dark:text-primary-400"
name="i-ph-paint-brush-bold"
/>
</UButton>
</template>
<template #panel>
<div class="p-2">
<div class="grid grid-cols-5 gap-px">
<UTooltip v-for="color in colors" :key="color" :text="color" class="capitalize" :open-delay="500">
<UTooltip
v-for="color in colors"
:key="color"
:open-delay="500"
:text="color"
class="capitalize"
>
<UButton
color="gray"
square
@@ -46,15 +55,21 @@ watch(isDark, () => {
color: {
white: {
solid: 'ring-0 bg-gray-100 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800',
ghost: 'hover:bg-gray-50 dark:hover:bg-gray-800/50',
},
},
ghost: 'hover:bg-gray-50 dark:hover:bg-gray-800/50'
}
}
}"
:variant="color === getColor ? 'solid' : 'ghost'"
@click.stop.prevent="setColor(color)"
>
<span class="flex items-center justify-center w-3 h-3 rounded-full border text-white" :class="`bg-${color}-500/80 border-${color}-500`">
<UIcon v-if="color === getColor" name="i-ic-round-check" />
<span
:class="`bg-${color}-500/80 border-${color}-500`"
class="flex items-center justify-center w-3 h-3 rounded-full border text-white"
>
<UIcon
v-if="color === getColor"
name="i-ic-round-check"
/>
</span>
</UButton>
</UTooltip>

View File

@@ -1,8 +1,8 @@
<script lang="ts" setup>
import type { Activity } from '~~/types'
import {type Activity, IDEs} from '~~/types'
const { data: activity, refresh } = await useAsyncData<Activity>('activity', () => $fetch('/api/activity'))
const codingActivity = computed(() => activity.value!.data.activities.filter(activity => activity.name === 'Visual Studio Code')[0])
const codingActivity = computed(() => activity.value!.data.activities.filter(activity => IDEs.some(ide => ide.name === activity.name))[0])
function formatDate(date: number) {
return `${useDateFormat(date, 'DD MMM YYYY').value} at ${useDateFormat(date, 'HH:mm:ss').value}`
@@ -10,15 +10,21 @@ function formatDate(date: number) {
const CardUi = {
footer: { padding: 'px-4 py-2' },
body: { base: 'h-full flex items-center' },
body: {base: 'h-full flex items-center'}
}
useIntervalFn(async () => await refresh(), 5000)
</script>
<template>
<UCard class="flex flex-col justify-between" :ui="CardUi">
<div v-if="activity && activity.data.activities" class="flex items-center gap-x-4">
<UCard
:ui="CardUi"
class="flex flex-col justify-between"
>
<div
v-if="activity && activity.data.activities"
class="flex items-center gap-x-4"
>
<p
class="uppercase tracking-widest text-sm"
:style="{ writingMode: 'vertical-rl', textOrientation: 'sideways' }"
@@ -29,7 +35,7 @@ useIntervalFn(async () => await refresh(), 5000)
<div class="flex gap-4 items-center">
<UIcon
class="h-10 w-10"
name="i-skill-icons-vscode-light"
:name="IDEs.find(ide => ide.name === codingActivity.name)!.icon"
/>
<div>
<div class="flex items-center gap-2">
@@ -45,12 +51,15 @@ useIntervalFn(async () => await refresh(), 5000)
I'm Idling on my computer
</h3>
<h3 v-else>
{{ codingActivity.details }}
{{ codingActivity.details }} - {{ codingActivity.state }}
</h3>
</div>
</div>
</div>
<div v-else class="text-subtitle">
<div
v-else
class="text-subtitle"
>
<div class="flex items-center gap-2">
<h1>I'm currently offline</h1>
<UTooltip text="I'm offline 🫥">
@@ -63,7 +72,10 @@ useIntervalFn(async () => await refresh(), 5000)
<template #footer>
<div class="flex items-center justify-end w-full">
<ClientOnly>
<p v-if="codingActivity" class="text-subtitle text-xs w-1/2">
<p
v-if="codingActivity"
class="text-subtitle text-xs w-1/2"
>
Started {{ useTimeAgo(codingActivity.timestamps.start).value }}, the {{ formatDate(codingActivity.timestamps.start) }}
</p>
</ClientOnly>
@@ -79,7 +91,10 @@ useIntervalFn(async () => await refresh(), 5000)
target="_blank"
label="Lanyard"
/>
<UIcon name="i-jam-thunder" class="text-subtitle" />
<UIcon
class="text-subtitle"
name="i-jam-thunder"
/>
</div>
</div>
</template>

View File

@@ -3,23 +3,23 @@ const socials = [
{
name: 'mail',
icon: 'i-material-symbols-alternate-email',
link: 'mailto:arthurdanjou@outlook.fr',
link: 'mailto:arthurdanjou@outlook.fr'
},
{
name: 'twitter',
icon: 'i-ph-twitter-logo-bold',
link: 'https://twitter.com/ArthurDanj',
link: 'https://twitter.com/ArthurDanj'
},
{
name: 'github',
icon: 'i-ph-github-logo-bold',
link: 'https://github.com/ArthurDanjou',
link: 'https://github.com/ArthurDanjou'
},
{
name: 'linkedin',
icon: 'i-ph-linkedin-logo-bold',
link: 'https://www.linkedin.com/in/arthurdanjou/',
},
link: 'https://www.linkedin.com/in/arthurdanjou/'
}
]
</script>

View File

@@ -5,12 +5,15 @@ const stats = await $fetch<Stats>('/api/stats')
const CardUi = {
footer: { padding: 'px-4 py-2' },
body: { base: 'h-full' },
body: {base: 'h-full'}
}
</script>
<template>
<UCard class="flex flex-col justify-between" :ui="CardUi">
<UCard
:ui="CardUi"
class="flex flex-col justify-between"
>
<div class="flex items-center gap-x-4 h-full">
<p
class="uppercase tracking-widest text-sm"
@@ -23,7 +26,9 @@ const CardUi = {
<div class="text-md">
<div class="flex items-center gap-x-1">
<h3>Total hours:</h3>
<p class="text-subtitle">{{ usePrecision(stats.coding.data.grand_total.total_seconds_including_other_language / 3600, 0) }} hours</p>
<p class="text-subtitle">
{{ usePrecision(stats.coding.data.grand_total.total_seconds_including_other_language / 3600, 0) }} hours
</p>
</div>
<div class="flex items-start gap-x-1 flex-wrap">
<h3>Best Editors:</h3>
@@ -33,11 +38,15 @@ const CardUi = {
</div>
<div class="flex items-center gap-x-1">
<h3>Best OS:</h3>
<p class="text-subtitle">{{ stats.os.data[0].name }} with {{ stats.os.data[0].percent }}%</p>
<p class="text-subtitle">
{{ stats.os.data[0].name }} with {{ stats.os.data[0].percent }}%
</p>
</div>
<div class="flex items-start gap-x-1 flex-wrap">
<h3>Top languages:</h3>
<p class="text-subtitle">{{ stats.languages.data.slice(0, 2).map(language => `${language.name} (${language.percent}%)`).join(', ') }}</p>
<p class="text-subtitle">
{{ stats.languages.data.slice(0, 2).map(language => `${language.name} (${language.percent}%)`).join(', ') }}
</p>
</div>
</div>
</div>
@@ -46,7 +55,10 @@ const CardUi = {
<template #footer>
<div class="flex items-center justify-between">
<ClientOnly>
<p v-if="stats" class="text-subtitle text-xs w-1/2">
<p
v-if="stats"
class="text-subtitle text-xs w-1/2"
>
Started {{ useTimeAgo(new Date(stats.coding.data.range.start)).value }}, the {{ useDateFormat(new Date(stats.coding.data.range.start), 'Do MMMM YYYY').value }}
</p>
</ClientOnly>
@@ -62,7 +74,10 @@ const CardUi = {
target="_blank"
label="Wakatime"
/>
<UIcon name="i-jam-thunder" class="text-subtitle" />
<UIcon
class="text-subtitle"
name="i-jam-thunder"
/>
</div>
</div>
</template>

View File

@@ -3,8 +3,8 @@ defineProps({
startDate: String,
endDate: {
type: String,
required: true,
},
required: true
}
})
function formatTodayDate(date: string) {
@@ -14,10 +14,18 @@ function formatTodayDate(date: string) {
</script>
<template>
<UBadge v-if="startDate !== endDate" variant="soft" size="xs">
<UBadge
v-if="startDate !== endDate"
size="xs"
variant="soft"
>
{{ formatTodayDate(startDate!.toString()) }} {{ formatTodayDate(endDate) }}
</UBadge>
<UBadge v-else variant="soft" size="xs">
<UBadge
v-else
size="xs"
variant="soft"
>
{{ formatTodayDate(endDate) }}
</UBadge>
</template>

View File

@@ -2,15 +2,21 @@
import type { Education } from '~~/types'
defineProps({
education: Object as PropType<Education>,
education: Object as PropType<Education>
})
</script>
<template>
<div v-if="education" class="group relative flex flex-col items-start">
<div
v-if="education"
class="group relative flex flex-col items-start"
>
<div class="flex flex-col">
<div>
<DateTag :start-date="education.startDate" :end-date="education.endDate" />
<DateTag
:end-date="education.endDate"
:start-date="education.startDate"
/>
</div>
<h1 class="my-1 text-base font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
{{ education.title }}

View File

@@ -2,16 +2,22 @@
import type { WorkExperience } from '~~/types'
defineProps({
experience: Object as PropType<WorkExperience>,
experience: Object as PropType<WorkExperience>
})
</script>
<template>
<div v-if="experience" class="group relative flex flex-col items-start">
<div
v-if="experience"
class="group relative flex flex-col items-start"
>
<div>
<div class="flex flex-col">
<div>
<DateTag :start-date="experience.startDate" :end-date="experience.endDate" />
<DateTag
:end-date="experience.endDate"
:start-date="experience.startDate"
/>
</div>
<div class="flex items-center my-1">
<UButton
@@ -26,10 +32,16 @@ defineProps({
class="mr-3 text-base font-semibold tracking-tight text-zinc-800 dark:text-zinc-100"
>
<template #leading>
<UIcon name="i-akar-icons-link-chain" color="gray" />
<UIcon
color="gray"
name="i-akar-icons-link-chain"
/>
</template>
</UButton>
<h1 v-else class="mr-3 my-1 text-base font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
<h1
v-else
class="mr-3 my-1 text-base font-semibold tracking-tight text-zinc-800 dark:text-zinc-100"
>
{{ experience.company }}
</h1>
<div class="text-subtitle text-xs">

View File

@@ -2,7 +2,7 @@
import type { Skill } from '~~/types'
defineProps({
skill: Object as PropType<Skill>,
skill: Object as PropType<Skill>
})
const { $colorMode } = useNuxtApp()
@@ -15,8 +15,18 @@ const isLight = computed(() => $colorMode.value === 'light')
class="flex items-center gap-2 rounded-md px-2 py-3 duration-300 md:hover:bg-gray-100 md:dark:hover:bg-neutral-800"
>
<div class="flex items-center">
<UIcon v-if="isLight" :name="skill.icon.light ? skill.icon.light : skill.icon" size="20" dynamic />
<UIcon v-else :name="skill.icon.dark ? skill.icon.dark : skill.icon" size="20" dynamic />
<UIcon
v-if="isLight"
:name="skill.icon.light ? skill.icon.light : skill.icon"
dynamic
size="20"
/>
<UIcon
v-else
:name="skill.icon.dark ? skill.icon.dark : skill.icon"
dynamic
size="20"
/>
</div>
<span class="text-sm text-subtitle">{{ skill.name }}</span>
</li>

12
drizzle.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
driver: 'pg',
schema: './server/database/schema.ts',
out: './server/database/migrations',
dbCredentials: {
connectionString: process.env.DATABASE_URL as string
},
strict: true,
verbose: true
})

View File

@@ -5,6 +5,10 @@ const router = useRouter()
router.afterEach((route) => {
useCookie('last-route', { path: '/', default: () => '/' }).value = route.fullPath
})
useHead({
link: [{ rel: 'icon', type: 'image/png', href: '/favicon.jpeg' }],
})
</script>
<template>

View File

@@ -1,7 +1,7 @@
export default defineNuxtRouteMiddleware(async (to) => {
const isMaintenance = ref<boolean>(true)
try {
await $fetch('/api/maintenance').then((maintenance: any) => {
await $fetch('/api/maintenance').then((maintenance) => {
isMaintenance.value = maintenance.enabled
})
}
@@ -11,14 +11,14 @@ export default defineNuxtRouteMiddleware(async (to) => {
if (isMaintenance.value && to.path !== '/maintenance') {
return navigateTo('/maintenance', {
redirectCode: 301,
redirectCode: 301
})
}
if (!isMaintenance.value && to.path === '/maintenance') {
return navigateTo('/', {
redirectCode: 301,
replace: true,
replace: true
})
}
})

20
modules/drizzle-studio.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineNuxtModule } from 'nuxt/kit'
import { addCustomTab } from '@nuxt/devtools-kit'
export default defineNuxtModule({
meta: {
name: 'drizzle-studio',
version: '0.0.1'
},
setup() {
addCustomTab({
name: 'drizzle-studio',
title: 'Drizzle Studio',
icon: 'simple-icons:drizzle',
view: {
type: 'iframe',
src: 'https://local.drizzle.studio/?themeId=azX2nOTScT9U6SWEmlq7z'
}
})
}
})

View File

@@ -1,7 +1,5 @@
/* eslint-disable node/prefer-global/process */
export default defineNuxtConfig({
srcDir: 'src',
css: [
'@/assets/css/main.scss',
],

View File

@@ -8,7 +8,7 @@
"dev": "nuxt dev --host",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "prisma generate && nuxt prepare",
"postinstall": "nuxt prepare",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
@@ -16,30 +16,29 @@
"@nuxt/content": "2.12.1",
"@nuxt/ui": "2.14.2",
"@pinia/nuxt": "0.5.1",
"@prisma/client": "5.11.0",
"@vercel/analytics": "1.2.2",
"@vercel/speed-insights": "1.0.10",
"drizzle-kit": "0.20.14",
"drizzle-orm": "0.30.8",
"nuxt": "3.10.3",
"nuxt-auth-utils": "0.0.20",
"pinia": "2.1.7",
"postcss-custom-properties": "13.3.5",
"prisma": "5.11.0",
"sass": "1.71.1",
"tailwindcss": "3.4.1",
"zod": "3.22.4"
"postcss-custom-properties": "13.3.7",
"postgres": "3.4.4",
"sass": "1.75.0",
"tailwindcss": "3.4.3",
"zod": "3.22.5"
},
"devDependencies": {
"@antfu/eslint-config": "2.8.1",
"@iconify/json": "2.2.191",
"@nuxt/eslint-config": "^0.2.0",
"@antfu/eslint-config": "2.15.0",
"@iconify/json": "2.2.202",
"@nuxthq/studio": "1.0.13",
"@nuxtjs/seo": "2.0.0-rc.9",
"@nuxtjs/seo": "2.0.0-rc.10",
"@pinia-plugin-persistedstate/nuxt": "1.2.0",
"@tailwindcss/typography": "0.5.10",
"@types/node": "20.11.26",
"@vueuse/core": "10.9.0",
"@vueuse/nuxt": "10.9.0",
"eslint": "8.57.0",
"typescript": "5.4.2"
"eslint": "9.1.0",
"typescript": "5.4.5"
}
}

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
useHead({
title: 'About me • Arthur Danjou',
title: 'About me • Arthur Danjou'
})
const { data: skills } = await getSkills()
@@ -13,8 +13,15 @@ const { data: experiences } = await getWorkExperiences()
<div class="px-4 grid grid-cols-1 gap-y-16 lg:grid-cols-2 lg:grid-rows-[auto_1fr] lg:gap-y-12">
<div class="lg:pl-20 flex justify-center">
<div class="max-w-xs px-2.5 lg:max-w-none">
<UTooltip text="It's me 👋" :popper="{ offsetDistance: 20 }">
<img src="/about.png" class="border dark:border-0 aspect-square rotate-3 hover:rotate-0 duration-300 transition-transform rounded-3xl bg-zinc-100 object-cover dark:bg-zinc-800">
<UTooltip
:popper="{ offsetDistance: 20 }"
text="It's me 👋"
>
<img
alt="My main profile picture"
class="border dark:border-0 aspect-square rotate-3 hover:rotate-0 duration-300 transition-transform rounded-3xl bg-zinc-100 object-cover dark:bg-zinc-800"
src="/about.png"
>
</UTooltip>
</div>
</div>
@@ -49,7 +56,10 @@ const { data: experiences } = await getWorkExperiences()
In addition to my studies and programming, I go to the gym every day to relax and stay in shape. Sport allows me to recharge my batteries and move on to other things.
</GridSlot>
</GridSection>
<GridSection v-if="skills" title="Skills">
<GridSection
v-if="skills"
title="Skills"
>
<div class="grid grid-cols-3 md:grid-cols-4 gap-2">
<Skill
v-for="skill in skills.body"
@@ -58,14 +68,31 @@ const { data: experiences } = await getWorkExperiences()
/>
</div>
</GridSection>
<GridSection v-if="experiences" title="Work Experiences">
<Experience v-for="experience in experiences" :key="experience.title" :experience="experience" />
<GridSection
v-if="experiences"
title="Work Experiences"
>
<Experience
v-for="experience in experiences"
:key="experience.title"
:experience="experience"
/>
</GridSection>
<GridSection v-if="educations" title="Educations">
<Education v-for="education in educations" :key="education.title" :education="education" />
<GridSection
v-if="educations"
title="Educations"
>
<Education
v-for="education in educations"
:key="education.title"
:education="education"
/>
</GridSection>
<div class="flex justify-center">
<UTooltip text="Click to discover my journey" :popper="{ offsetDistance: 20 }">
<UTooltip
:popper="{ offsetDistance: 20 }"
text="Click to discover my journey"
>
<UButton
label="Download my CV"
icon="i-material-symbols-lab-profile-outline-rounded"

View File

@@ -1,30 +1,24 @@
<script lang="ts" setup>
import type { Bookmark, Category } from '@prisma/client'
import { useBookmarksStore } from '~/store/bookmarks'
useHead({
title: 'Discover new talents • Arthur Danjou',
title: 'Discover my library • Arthur Danjou'
})
const categories = ref<Array<{ label: string, slug: string }>>([{ label: 'All', slug: 'all' }])
const { getCategory, setCategory, isFavorite, toggleFavorite } = useBookmarksStore()
const {
data: bookmarks,
pending,
} = await useFetch<Array<Bookmark & { categories: Array<{ category: Category }> }>>('/api/bookmarks', {
const { data: bookmarks, pending } = await useFetch('/api/bookmarks', {
method: 'get',
query: {
favorite: isFavorite,
category: getCategory,
category: getCategory
},
watch: [isFavorite, getCategory],
watch: [isFavorite, getCategory]
})
const {
data: getCategories,
} = await useFetch<Array<Category>>('/api/categories', { method: 'GET', query: { type: 'BOOKMARK' } })
getCategories.value!.forEach((category: any) => categories.value.push({ label: category.name, slug: category.slug }))
const { data: getCategories } = await useFetch('/api/categories', { method: 'GET', query: { type: 'bookmark' } })
getCategories.value!.forEach(category => categories.value.push({label: category.name, slug: category.slug}))
function isCategory(slug: string) {
return getCategory.value === slug
@@ -33,10 +27,10 @@ function isCategory(slug: string) {
const getMarkerStyle = computed(() => {
const selected = window.document.getElementById(categories.value.find(category => category.slug === getCategory.value)?.slug || 'all')
return {
top: `${selected?.offsetTop}px` || '0px',
left: `${selected?.offsetLeft === 12 ? 4 : selected?.offsetLeft}px` || '4px',
height: `${selected?.offsetHeight}px` || '0px',
width: `${selected?.offsetWidth}px` || '0px',
top: `${selected?.offsetTop}px`,
left: `${selected?.offsetLeft === 12 ? 4 : selected?.offsetLeft}px`,
height: `${selected?.offsetHeight}px`,
width: `${selected?.offsetWidth}px`
}
})
@@ -50,13 +44,16 @@ function getColor() {
<section class="w-container lg:my-24 my-16">
<div class="max-w-2xl space-y-8 mb-16">
<h1 class="text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl !leading-tight">
My librairy where I save some ressources
My library where I save some resources
</h1>
<p class="leading-relaxed text-subtitle">
You will find a selection of some of the most inspiring and complete content I have read through my research and work experience.
</p>
</div>
<div v-if="getCategories" class="sticky z-40 top-[4.8rem] left-0 z-100 flex gap-2 w-full items-center justify-between">
<div
v-if="getCategories"
class="sticky z-40 top-[4.8rem] left-0 z-100 flex gap-2 w-full items-center justify-between"
>
<div class="flex gap-2 overflow-x-scroll sm:overflow-x-hidden bg-gray-100 dark:bg-gray-800 rounded-lg p-1 relative">
<ClientOnly>
<div
@@ -91,16 +88,28 @@ function getColor() {
class="flex p-2 gap-2 items-center cursor-pointer select-none text-subtitle"
@click.prevent="toggleFavorite()"
>
<UIcon v-if="isFavorite" name="i-material-symbols-check-box-outline-rounded" />
<UIcon v-else name="i-material-symbols-check-box-outline-blank" />
<UIcon
v-if="isFavorite"
name="i-material-symbols-check-box-outline-rounded"
/>
<UIcon
v-else
name="i-material-symbols-check-box-outline-blank"
/>
<p>Show favorites only</p>
</div>
</template>
</UPopover>
</div>
<UDivider class="my-2" />
<div v-if="bookmarks && getCategories" class="mt-8">
<div v-if="bookmarks.length > 0 && !pending" class="grid grid-cols-1 gap-4 md:gap-x-16 md:gap-y-12 sm:grid-cols-2 md:grid-cols-3">
<div
v-if="bookmarks && getCategories"
class="mt-8"
>
<div
v-if="bookmarks.length > 0 && !pending"
class="grid grid-cols-1 gap-4 md:gap-x-16 md:gap-y-12 sm:grid-cols-2 md:grid-cols-3"
>
<div
v-for="bookmark in bookmarks"
:key="bookmark.name.toLowerCase().trim()"
@@ -109,7 +118,7 @@ function getColor() {
<div class="flex flex-col gap-y-1">
<div class="flex gap-6 items-center">
<h2 class="text-base font-semibold text-zinc-800 dark:text-zinc-100">
<div class="absolute -inset-y-2 md:-inset-y-4 -inset-x-4 z-0 scale-95 bg-zinc-50 opacity-0 transition group-hover:scale-100 group-hover:opacity-100 dark:bg-zinc-800/50 sm:-inset-x-6 sm:rounded-2xl" />
<span class="absolute -inset-y-2 md:-inset-y-4 -inset-x-4 z-0 scale-95 bg-zinc-50 opacity-0 transition group-hover:scale-100 group-hover:opacity-100 dark:bg-zinc-800/50 sm:-inset-x-6 sm:rounded-2xl" />
<NuxtLink
:href="bookmark.website"
external
@@ -120,8 +129,14 @@ function getColor() {
<h1 class="relative z-10">
{{ bookmark.name }}
</h1>
<UTooltip v-if="bookmark.favorite" text="You can set the filter to only show favorites.">
<UIcon name="i-ic-round-star" class="z-20 text-amber-500 text-xl font-bold hover:rotate-[143deg] duration-300" />
<UTooltip
v-if="bookmark.favorite"
text="You can set the filter to only show favorites."
>
<UIcon
class="z-20 text-amber-500 text-xl font-bold hover:rotate-[143deg] duration-300"
name="i-ic-round-star"
/>
</UTooltip>
</div>
</NuxtLink>
@@ -129,7 +144,7 @@ function getColor() {
</div>
<div class="flex gap-2 z-10">
<UBadge
v-for="category in bookmark.categories"
v-for="category in bookmark.bookmarkCategories"
:key="category.category.slug"
color="primary"
variant="soft"
@@ -139,19 +154,28 @@ function getColor() {
</UBadge>
</div>
</div>
<p class="relative z-10 flex text-sm font-medium items-center" :class="getColor()">
<p
:class="getColor()"
class="relative z-10 flex text-sm font-medium items-center"
>
<UIcon name="i-ph-link-bold" />
<span class="ml-2">{{ bookmark.website.replace('https://', '').replace('www.', '').replace('/', '') }}</span>
</p>
</div>
</div>
<div v-else-if="bookmarks?.length === 0 && !pending" class="my-4 text-subtitle">
<div
v-else-if="bookmarks?.length === 0 && !pending"
class="my-4 text-subtitle"
>
<div class="flex gap-2 items-center">
<UIcon name="i-akar-icons-cross" />
<p>There are no talents for this category. Maybe soon...</p>
<p>There are no bookmarks for this category. Maybe soon...</p>
</div>
</div>
<div v-else class="my-4 text-subtitle">
<div
v-else
class="my-4 text-subtitle"
>
<div class="flex gap-2 items-center">
<UIcon name="i-eos-icons-loading" />
<p>The bookmarks are loading...</p>

View File

@@ -1,13 +1,12 @@
<script lang="ts" setup>
import type { GuestbookMessage } from '@prisma/client'
import { providers } from '~~/types'
useHead({
title: 'Sign my guestbook • Arthur Danjou',
title: 'Sign my guestbook • Arthur Danjou'
})
const { loggedIn, clear, user } = useUserSession()
const { data: messages, refresh } = useFetch<Array<GuestbookMessage>>('/api/messages', { method: 'get' })
const { data: messages, refresh } = useFetch('/api/messages', { method: 'get' })
const isOpen = ref(false)
@@ -21,20 +20,20 @@ async function sign() {
await $fetch('/api/message', {
method: 'post',
body: {
message: messageContent.value,
},
message: messageContent.value
}
}).then(async () => {
toast.add({
title: `Thank's for leaving a message!`,
title: `Thanks for leaving a message!`,
description: 'Your can see it at the top of the messages.',
icon: 'i-material-symbols-check-circle-outline-rounded',
timeout: 4000,
timeout: 4000
})
await refresh()
}).catch(() => {
toast.add({
title: 'An error occured when signing the book!',
color: 'red',
title: 'An error occurred when signing the book!',
color: 'red'
})
})
messageContent.value = ''
@@ -46,20 +45,20 @@ async function deleteMessage(id: number) {
await $fetch('/api/message', {
method: 'delete',
body: {
id,
},
id
}
}).then(async () => {
toast.add({
title: `Message successfully deleted`,
icon: 'i-material-symbols-check-circle-outline-rounded',
color: 'green',
timeout: 4000,
timeout: 4000
})
await refresh()
}).catch(() => {
toast.add({
title: 'An error occured when deleting a message!',
color: 'red',
color: 'red'
})
})
}
@@ -88,18 +87,33 @@ async function deleteMessage(id: number) {
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<h1 v-if="loggedIn" class="text-md font-bold">
<h1
v-if="loggedIn"
class="text-md font-bold"
>
Enter just below your message to sign my book
</h1>
<h1 v-else class="text-md font-bold">
<h1
v-else
class="text-md font-bold"
>
Sign before writing your message
</h1>
</div>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="isOpen = false" />
<UButton
class="-my-1"
color="gray"
icon="i-heroicons-x-mark-20-solid"
variant="ghost"
@click="isOpen = false"
/>
</div>
</template>
<div>
<div v-if="loggedIn" class="flex items-center justify-between gap-4">
<div
v-if="loggedIn"
class="flex items-center justify-between gap-4"
>
<div class="w-full relative flex items-center">
<input
v-model="messageContent"
@@ -125,7 +139,10 @@ async function deleteMessage(id: number) {
Logout
</UButton>
</div>
<div v-else class="flex gap-2 justify-center">
<div
v-else
class="flex gap-2 justify-center"
>
<UButton
v-for="provider in providers"
:key="provider.slug"
@@ -134,13 +151,16 @@ async function deleteMessage(id: number) {
variant="solid"
:to="provider.link"
:icon="provider.icon"
external
:external="true"
/>
</div>
</div>
</UCard>
</UModal>
<div v-if="messages" class="columns-1 md:columns-2 lg:columns-4 gap-8 space-y-8">
<div
v-if="messages"
class="columns-1 md:columns-2 lg:columns-4 gap-8 space-y-8"
>
<div
v-for="message in messages"
:key="message.id"
@@ -151,7 +171,11 @@ async function deleteMessage(id: number) {
</p>
<div class="flex items-center gap-4 mt-4">
<div class="h-8 w-8 rounded-full">
<img class="w-full h-full rounded-full" :src="message.image" alt="Nature">
<img
:src="message.image"
alt="Author profile picture"
class="w-full h-full rounded-full"
>
</div>
<p class="font-bold">
{{ message.username }}
@@ -169,7 +193,10 @@ async function deleteMessage(id: number) {
/>
</div>
</div>
<div v-else class="my-4 text-subtitle">
<div
v-else
class="my-4 text-subtitle"
>
<div class="flex gap-2 items-center">
<UIcon name="i-eos-icons-loading" />
<p>The messages are loading...</p>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
useHead({
title: 'Arthur Danjou • Software Engineer and Maths Lover',
title: 'Arthur Danjou • Software Engineer and Maths Lover'
})
</script>

View File

@@ -1,10 +1,10 @@
<script lang="ts" setup>
definePageMeta({
layout: 'maintenance',
layout: 'maintenance'
})
useHead({
title: 'Site under maintenance • Arthur Danjou',
title: 'Site under maintenance • Arthur Danjou'
})
const { data: maintenance } = await useFetch('/api/maintenance')
@@ -17,23 +17,23 @@ const socials = [
{
name: 'mail',
icon: 'i-material-symbols-alternate-email',
link: 'mailto:arthurdanjou@outlook.fr',
link: 'mailto:arthurdanjou@outlook.fr'
},
{
name: 'twitter',
icon: 'i-ph-twitter-logo-bold',
link: 'https://twitter.com/ArthurDanj',
link: 'https://twitter.com/ArthurDanj'
},
{
name: 'github',
icon: 'i-ph-github-logo-bold',
link: 'https://github.com/ArthurDanjou',
link: 'https://github.com/ArthurDanjou'
},
{
name: 'linkedin',
icon: 'i-ph-linkedin-logo-bold',
link: 'https://www.linkedin.com/in/arthurdanjou/',
},
link: 'https://www.linkedin.com/in/arthurdanjou/'
}
]
</script>
@@ -47,7 +47,10 @@ const socials = [
The website is under maintenance
</h1>
<div v-if="maintenance && maintenance.maintenance">
<p :class="getColor" class="font-bold mb-8 text-xl">
<p
:class="getColor"
class="font-bold mb-8 text-xl"
>
{{ maintenance.maintenance.reason }}
</p>
<div>
@@ -64,7 +67,11 @@ const socials = [
class="link"
target="_blank"
>
<span class="flex-shrink-0 h-5 w-5" aria-hidden="true" :class="social.icon" />
<span
:class="social.icon"
aria-hidden="true"
class="flex-shrink-0 h-5 w-5"
/>
</a>
</div>
</div>

View File

@@ -1,32 +1,30 @@
<script lang="ts" setup>
import type { Category, Suggestion, Talent } from '@prisma/client'
import { useTalentsStore } from '~/store/talents'
import { providers } from '~~/types'
useHead({
title: 'Discover new talents • Arthur Danjou',
title: 'Discover new talents • Arthur Danjou'
})
const categories = ref<Array<{ label: string, slug: string, id: number }>>([{ label: 'All', slug: 'all', id: 0 }])
const { getCategory, setCategory, isFavorite, toggleFavorite } = useTalentsStore()
const { loggedIn, clear } = useUserSession()
const {
data: talents,
pending,
} = await useFetch<Array<Talent & { categories: Array<{ category: Category }> }>>('/api/talents', {
const { data: talents, pending } = await useFetch('/api/talents', {
method: 'get',
query: {
favorite: isFavorite,
category: getCategory,
category: getCategory
},
watch: [isFavorite, getCategory],
watch: [isFavorite, getCategory]
})
const {
data: getCategories,
} = await useFetch<Array<Category>>('/api/categories', { method: 'GET', query: { type: 'TALENT' } })
getCategories.value!.forEach((category: any) => categories.value.push({ label: category.name, slug: category.slug, id: category.id }))
const { data: getCategories } = await useFetch('/api/categories', { method: 'GET', query: { type: 'talent' } })
getCategories.value!.forEach(category => categories.value.push({
label: category.name,
slug: category.slug,
id: category.id
}))
function isCategory(slug: string) {
return getCategory.value === slug
@@ -35,10 +33,10 @@ function isCategory(slug: string) {
const getMarkerStyle = computed(() => {
const selected = window.document.getElementById(categories.value.find(category => category.slug === getCategory.value)?.slug || 'all')
return {
top: `${selected?.offsetTop}px` || '0px',
left: `${selected?.offsetLeft === 12 ? 4 : selected?.offsetLeft}px` || '4px',
height: `${selected?.offsetHeight}px` || '0px',
width: `${selected?.offsetWidth}px` || '0px',
top: `${selected?.offsetTop}px`,
left: `${selected?.offsetLeft === 12 ? 4 : selected?.offsetLeft}px`,
height: `${selected?.offsetHeight}px`,
width: `${selected?.offsetWidth}px`
}
})
@@ -55,21 +53,22 @@ async function suggest() {
return
isOpen.value = false
await $fetch<Suggestion>('/api/suggestion', {
await $fetch('/api/suggestion', {
method: 'post',
body: {
content: suggestContent.value,
},
}).then((suggestion) => {
content: suggestContent.value
}
}).then((response) => {
toast.add({
title: `Your suggestion for '${suggestion.content}'' has been successfully added`,
title: `Your suggestion for '${response[0].content}' has been successfully added`,
color: 'green',
icon: 'i-material-symbols-check-circle-outline-rounded',
timeout: 4000,
timeout: 4000
})
}).catch(() => {
toast.add({
title: 'You already have suggested someone',
color: 'red',
title: 'An error occurred when suggesting someone',
color: 'red'
})
})
suggestContent.value = ''
@@ -103,11 +102,20 @@ async function suggest() {
Are you a web talent? Do you want to promote your project? Do you want to launch your career or gain visibility?
</h1>
</div>
<UButton color="gray" variant="ghost" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="isOpen = false" />
<UButton
class="-my-1"
color="gray"
icon="i-heroicons-x-mark-20-solid"
variant="ghost"
@click="isOpen = false"
/>
</div>
</template>
<div>
<div v-if="loggedIn" class="flex items-center justify-between gap-4">
<div
v-if="loggedIn"
class="flex items-center justify-between gap-4"
>
<div class="w-full relative flex items-center">
<input
v-model="suggestContent"
@@ -132,7 +140,10 @@ async function suggest() {
Logout
</UButton>
</div>
<div v-else class="flex gap-2 justify-center">
<div
v-else
class="flex gap-2 justify-center"
>
<UButton
v-for="provider in providers"
:key="provider.slug"
@@ -141,14 +152,17 @@ async function suggest() {
variant="solid"
:to="provider.link"
:icon="provider.icon"
external
:external="true"
/>
</div>
</div>
</UCard>
</UModal>
<div class="sticky z-40 top-[4.55rem] left-0 z-100 bg-white dark:bg-gray-900 pt-2">
<div v-if="getCategories" class="flex gap-2 w-full items-center justify-between">
<div
v-if="getCategories"
class="flex gap-2 w-full items-center justify-between"
>
<div class="flex gap-2 overflow-x-scroll sm:overflow-x-hidden bg-gray-100 dark:bg-gray-800 rounded-lg p-1 relative">
<ClientOnly>
<div
@@ -183,8 +197,14 @@ async function suggest() {
class="flex p-2 gap-2 items-center cursor-pointer select-none text-subtitle"
@click.prevent="toggleFavorite()"
>
<UIcon v-if="isFavorite" name="i-material-symbols-check-box-outline-rounded" />
<UIcon v-else name="i-material-symbols-check-box-outline-blank" />
<UIcon
v-if="isFavorite"
name="i-material-symbols-check-box-outline-rounded"
/>
<UIcon
v-else
name="i-material-symbols-check-box-outline-blank"
/>
<p>Show favorites only</p>
</div>
</template>
@@ -192,8 +212,14 @@ async function suggest() {
</div>
<UDivider class="my-2" />
</div>
<div v-if="talents && getCategories" class="mt-8">
<div v-if="talents.length > 0 && !pending" class="grid grid-cols-1 gap-y-4 md:gap-x-12 md:gap-y-16 sm:grid-cols-2 lg:grid-cols-3">
<div
v-if="talents && getCategories"
class="mt-8"
>
<div
v-if="talents.length > 0 && !pending"
class="grid grid-cols-1 gap-y-4 md:gap-x-12 md:gap-y-16 sm:grid-cols-2 lg:grid-cols-3"
>
<div
v-for="talent in talents"
:key="talent.name.toLowerCase().trim()"
@@ -201,18 +227,31 @@ async function suggest() {
>
<div class="flex">
<div class="flex gap-6 items-center">
<img :src="talent.logo" class="z-20 h-8 w-8 md:h-12 md:w-12 rounded-md">
<img
:src="talent.logo"
alt="Talent profile picture"
class="z-20 h-8 w-8 md:h-12 md:w-12 rounded-md"
>
<div>
<h2 class="text-base font-semibold text-zinc-800 dark:text-zinc-100">
<div class="absolute -inset-y-2 md:-inset-y-6 -inset-x-4 z-0 scale-95 bg-zinc-50 opacity-0 transition group-hover:scale-100 group-hover:opacity-100 dark:bg-zinc-800/50 sm:-inset-x-6 sm:rounded-2xl" />
<NuxtLink :to="talent.website" target="_blank">
<span class="absolute -inset-y-2 md:-inset-y-6 -inset-x-4 z-0 scale-95 bg-zinc-50 opacity-0 transition group-hover:scale-100 group-hover:opacity-100 dark:bg-zinc-800/50 sm:-inset-x-6 sm:rounded-2xl" />
<NuxtLink
:to="talent.website"
target="_blank"
>
<span class="absolute -inset-y-2 md:-inset-y-6 -inset-x-4 z-20 sm:-inset-x-6 sm:rounded-2xl" />
<div class="flex gap-2 items-center">
<h1 class="relative z-10">
{{ talent.name }}
</h1>
<UTooltip v-if="talent.favorite" text="You can set the filter to only show favorites.">
<UIcon name="i-ic-round-star" class="z-20 text-amber-500 text-xl font-bold hover:rotate-[143deg] duration-300" />
<UTooltip
v-if="talent.favorite"
text="You can set the filter to only show favorites."
>
<UIcon
class="z-20 text-amber-500 text-xl font-bold hover:rotate-[143deg] duration-300"
name="i-ic-round-star"
/>
</UTooltip>
</div>
</NuxtLink>
@@ -224,13 +263,16 @@ async function suggest() {
</div>
</div>
<div class="flex items-center gap-4 mt-2">
<p class="relative z-10 flex text-xs md:text-sm font-medium items-center" :class="getColor()">
<p
:class="getColor()"
class="relative z-10 flex text-xs md:text-sm font-medium items-center"
>
<UIcon name="i-ph-link-bold" />
<span class="ml-2">{{ talent.website.replace('https://', '').replace('/', '') }}</span>
</p>
<div class="flex gap-2 z-10 flex-wrap">
<UBadge
v-for="category in talent.categories"
v-for="category in talent.talentCategories"
:key="category.category.slug"
color="primary"
variant="soft"
@@ -242,13 +284,19 @@ async function suggest() {
</div>
</div>
</div>
<div v-else-if="talents?.length === 0 && !pending" class="my-4 text-subtitle">
<div
v-else-if="talents?.length === 0 && !pending"
class="my-4 text-subtitle"
>
<div class="flex gap-2 items-center">
<UIcon name="i-akar-icons-cross" />
<p>There are no talents for this category. Maybe soon...</p>
</div>
</div>
<div v-else class="my-4 text-subtitle">
<div
v-else
class="my-4 text-subtitle"
>
<div class="flex gap-2 items-center">
<UIcon name="i-eos-icons-loading" />
<p>The talents are loading...</p>

View File

@@ -13,7 +13,7 @@
path="/uses"
/>
<template #fallback>
<USkeleton class="w-full h-1/2"/>
<USkeleton class="w-full h-1/2" />
</template>
</ClientOnly>
</div>

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
useHead({
title: 'My work • Arthur Danjou',
title: 'My work • Arthur Danjou'
})
const { data: projects } = await getProjects()
</script>
@@ -24,12 +24,19 @@ const { data: projects } = await getProjects()
>
<div class="flex items-start gap-4">
<div class="relative z-10 flex p-2 items-center justify-center rounded-full bg-white shadow-md shadow-zinc-800/5 ring-1 ring-zinc-900/5 dark:border dark:border-zinc-700/50 dark:bg-zinc-800 dark:ring-0">
<UIcon :name="project.icon" size="24" dynamic />
<UIcon
:name="project.icon"
dynamic
size="24"
/>
</div>
<div>
<h2 class="text-base font-semibold text-zinc-800 dark:text-zinc-100">
<div class="absolute -inset-y-4 md:-inset-y-6 -inset-x-4 z-0 scale-95 bg-zinc-50 opacity-0 transition group-hover:scale-100 group-hover:opacity-100 dark:bg-zinc-800/50 sm:-inset-x-6 sm:rounded-2xl" />
<NuxtLink :to="project.link" target="_blank">
<NuxtLink
:to="project.link"
target="_blank"
>
<span class="absolute -inset-y-4 md:-inset-y-6 -inset-x-4 z-20 sm:-inset-x-6 sm:rounded-2xl" />
<span class="relative z-10">{{ project.title }}</span>
</NuxtLink>

View File

@@ -7,12 +7,12 @@ const appConfig = useAppConfig()
const route = useRoute()
const { data: postContent } = await useAsyncData<Post>(`writing:${route.params.slug}`, () => queryContent<Post>(`/writing/${route.params.slug}`).findOne())
const {
data: post,
data: post
} = await useFetch<PrismaPost>('/api/article', {
method: 'post',
body: {
slug: route.params.slug.toString(),
},
slug: route.params.slug.toString()
}
})
const likes = ref(post.value?.likes)
@@ -20,8 +20,8 @@ async function like() {
const data = await $fetch<PrismaPost>('/api/like', {
method: 'PUT',
body: {
slug: post.value?.slug,
},
slug: post.value?.slug
}
})
likes.value = data.likes
}
@@ -29,30 +29,30 @@ async function like() {
if (!postContent.value) {
throw showError({
statusMessage: 'The post you are looking for was not found.',
statusCode: 404,
statusCode: 404
})
}
const format = (date: string) => useDateFormat(date, 'D MMMM YYYY').value.replaceAll('"', '')
useHead({
title: `${postContent.value?.title} • Arthur Danjou's shelf`,
title: `${postContent.value?.title} • Arthur Danjou's shelf`
})
function top() {
window.scrollTo({
top: 0,
left: 0,
behavior: 'smooth',
behavior: 'smooth'
})
}
const { copy, copied } = useClipboard({
source: `https://arthurdanjou.fr/writing/${route.params.slug}`,
copiedDuring: 4000,
copiedDuring: 4000
})
const likeCookie = useCookie<boolean>(`post:like:${postContent.value.slug}`, {
maxAge: 604_800,
maxAge: 604_800
})
async function handleLike() {
@@ -62,7 +62,10 @@ async function handleLike() {
</script>
<template>
<section v-if="postContent && post" class="w-container lg:mt-24 mt-16">
<section
v-if="postContent && post"
class="w-container lg:mt-24 mt-16"
>
<div class="lg:relative">
<div class="max-w-3xl space-y-8 mx-auto">
<div class="mx-auto max-w-2xl">
@@ -95,7 +98,10 @@ async function handleLike() {
{{ postContent.description }}
</p>
</header>
<div v-if="postContent.cover" class="w-full rounded-md my-8">
<div
v-if="postContent.cover"
class="w-full rounded-md my-8"
>
{{ postContent.cover }}
</div>
<ClientOnly>

View File

@@ -3,7 +3,7 @@ const appConfig = useAppConfig()
const getColor = computed(() => `text-${appConfig.ui.primary}-500`)
useHead({
title: 'My Shelf • Arthur Danjou',
title: 'My Shelf • Arthur Danjou'
})
const { data: posts } = await getPosts()
@@ -25,7 +25,11 @@ const format = (date: string) => useDateFormat(date, 'D MMMM YYYY').value.replac
<div class="mt-16 md:mt-20">
<div class="md:border-l md:border-zinc-100 md:pl-6 md:dark:border-zinc-700/40">
<div class="flex max-w-3xl flex-col space-y-16">
<article v-for="post in posts" :key="post.slug" class="px-6 md:grid md:grid-cols-4 md:items-baseline">
<article
v-for="post in posts"
:key="post.slug"
class="px-6 md:grid md:grid-cols-4 md:items-baseline"
>
<div class="group md:col-span-3 group relative flex flex-col items-start">
<h2 class="text-base font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
<div class="absolute -inset-y-6 -inset-x-4 z-0 scale-95 bg-zinc-50 opacity-0 transition group-hover:scale-100 group-hover:opacity-100 dark:bg-zinc-800/50 sm:-inset-x-6 md:rounded-2xl" />
@@ -45,7 +49,10 @@ const format = (date: string) => useDateFormat(date, 'D MMMM YYYY').value.replac
<p class="relative z-10 mt-2 text-sm text-zinc-600 dark:text-zinc-400">
{{ post.description }}
</p>
<div class="relative z-10 mt-4 flex items-center gap-2 justify-center text-sm font-medium" :class="getColor">
<div
:class="getColor"
class="relative z-10 mt-4 flex items-center gap-2 justify-center text-sm font-medium"
>
<p>Read article</p>
<UIcon name="i-ph-arrow-circle-right-bold" />
</div>

View File

@@ -1,117 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
model Maintenance {
id Int @id @default(autoincrement())
reason String @default("")
beginAt DateTime @default(now())
endAt DateTime @default(now())
createdAt DateTime @default(now())
enabled Boolean @default(true)
}
model Announcement {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
content String @default("")
}
enum CategoryType {
TALENT
BOOKMARK
}
model Category {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
slug String @default("")
name String @default("")
type CategoryType @default(TALENT)
talents CategoriesOnTalents[]
bookmarks CategoriesOnBookmarks[]
}
model Talent {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
logo String @default("")
name String @unique @default("")
website String @default("")
work String @default("")
favorite Boolean @default(false)
categories CategoriesOnTalents[]
}
model Bookmark {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
name String @unique @default("")
website String @default("")
favorite Boolean @default(false)
categories CategoriesOnBookmarks[]
}
model CategoriesOnTalents {
talentId Int
categoryId Int
category Category @relation(fields: [categoryId], references: [id])
talent Talent @relation(fields: [talentId], references: [id])
@@id([talentId, categoryId])
@@index([talentId])
@@index([categoryId])
}
model CategoriesOnBookmarks {
bookmarkId Int
categoryId Int
category Category @relation(fields: [categoryId], references: [id])
bookmark Bookmark @relation(fields: [bookmarkId], references: [id])
@@id([bookmarkId, categoryId])
@@index([bookmarkId])
@@index([categoryId])
}
model Post {
id Int @id @default(autoincrement())
slug String @unique @default("")
createdAt DateTime @default(now())
views Int @default(0)
likes Int @default(0)
}
model Suggestion {
id Int @id @default(autoincrement())
email String @unique @default("")
content String @default("")
added Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Form {
id Int @id @default(autoincrement())
name String @default("")
email String @default("")
content String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model GuestbookMessage {
id Int @id @default(autoincrement())
message String @default("")
email String @unique @default("")
image String @default("")
username String @default("")
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
}

View File

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,5 @@
export default defineEventHandler(async () => {
return useDB().query.announcements.findFirst({
orderBy: (announcement, {asc}) => [asc(announcement.createdAt)]
})
})

View File

@@ -0,0 +1,16 @@
import {z} from 'zod'
const PostSchema = z.object({ slug: z.string() }).parse
export default defineEventHandler(async (event) => {
const { slug } = await readValidatedBody(event, PostSchema)
return useDB().insert(tables.posts).values({
slug
}).onConflictDoUpdate({
target: tables.posts.id,
set: {
views: sql`${tables.posts.views}
+ 1`
}
})
})

View File

@@ -0,0 +1,20 @@
export default defineEventHandler(async (event) => {
const { favorite, category } = getQuery(event)
const bookmarks = await useDB().query.bookmarks
.findMany({
orderBy: [asc(tables.talents.id)],
with: {
bookmarkCategories: {
with: {
category: true
}
}
}
})
return bookmarks.filter(bookmark =>
(category === 'all' || bookmark.bookmarkCategories.some(cat => cat.category.slug === category))
&& (favorite === 'false' || bookmark.favorite)
)
})

View File

@@ -0,0 +1,4 @@
export default defineEventHandler(async (event) => {
const { type } = getQuery<{ type: 'talent' | 'bookmark' }>(event)
return useDB().select().from(tables.categories).where(eq(tables.categories.type, type))
})

View File

@@ -4,14 +4,10 @@ const PostSchema = z.object({ slug: z.string() }).parse
export default defineEventHandler(async (event) => {
const { slug } = await readValidatedBody(event, PostSchema)
return await usePrisma().post.update({
where: {
slug,
},
data: {
likes: {
increment: 1,
},
},
return useDB().update(tables.posts)
.set({
likes: sql`${tables.posts.likes}
+ 1`
})
.where(eq(tables.posts.slug, slug))
})

View File

@@ -0,0 +1,22 @@
export default defineEventHandler(async () => {
const maintenance = await useDB().query.maintenances.findFirst({
orderBy: [asc(tables.maintenances.createdAt)]
})
let enabled = true
// eslint-disable-next-line node/prefer-global/process
if (process.env.NODE_ENV === 'development') {
enabled = false
}
else {
const today = new Date()
enabled = !!maintenance
&& maintenance.enabled
&& new Date(maintenance.beginAt).getTime() < today.getTime()
&& new Date(maintenance.endAt).getTime() > today.getTime()
}
return {
enabled,
maintenance
}
})

View File

@@ -1,7 +1,7 @@
import { z } from 'zod'
const MessageValidator = z.object({
id: z.number(),
id: z.number()
}).parse
export default defineEventHandler(async (event) => {
@@ -9,10 +9,5 @@ export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event)
if (!user.admin)
throw createError({ statusCode: 400, message: 'You need the permission to delete a message!' })
return await usePrisma().guestbookMessage.delete({
where: {
id,
},
})
return useDB().delete(tables.guestbookMessages).where(eq(tables.guestbookMessages.id, id))
})

View File

@@ -1,7 +1,7 @@
import { z } from 'zod'
const MessageValidator = z.object({
message: z.string(),
message: z.string()
}).parse
export default defineEventHandler(async (event) => {
@@ -11,22 +11,20 @@ export default defineEventHandler(async (event) => {
await sendDiscordWebhookMessage(config, {
title: 'New guestbook message ✨',
description: `**${user.username}** as signed the book : "*${message}*"`,
color: 15893567,
description: `**${user.username}** has signed the book : "*${message}*"`,
color: 15893567
})
return await usePrisma().guestbookMessage.upsert({
where: {
email: user.email,
},
update: {
return useDB().insert(tables.guestbookMessages)
.values({
message,
},
create: {
email: user.email,
image: user.picture,
username: user.username,
message,
},
image: user.picture
})
.onConflictDoUpdate({
target: tables.guestbookMessages.email,
set: {
message
}
})
})

View File

@@ -0,0 +1,3 @@
export default defineEventHandler(async () => {
return useDB().select().from(tables.guestbookMessages).orderBy(asc(tables.guestbookMessages.createdAt))
})

View File

@@ -8,8 +8,8 @@ export default defineCachedEventHandler(async (event) => {
coding,
editors,
os,
languages,
languages
}
}, {
maxAge: 60 * 60 * 3, // 3 hours,
maxAge: 60 * 60 * 3 // 3 hours,
})

View File

@@ -0,0 +1,34 @@
import { z } from 'zod'
const SuggestionValidator = z.object({
content: z.string()
}).parse
export default defineEventHandler(async (event) => {
const { content } = await readValidatedBody(event, SuggestionValidator)
const { user } = await requireUserSession(event)
const config = useRuntimeConfig(event)
await sendDiscordWebhookMessage(config, {
title: 'New suggestion ✨',
description: `**${user.username}** has requested **${content}** for the talents page.`,
color: 15237114
})
return useDB().insert(tables.suggestions)
.values({
email: user.email,
content
})
.onConflictDoUpdate({
target: tables.suggestions.email,
set: {
content
},
setWhere: sql`${tables.suggestions.email}
=
${user.email}`
}).returning({
content: tables.suggestions.content
})
})

20
server/api/talents.get.ts Normal file
View File

@@ -0,0 +1,20 @@
export default defineEventHandler(async (event) => {
const { favorite, category } = getQuery(event)
const talents = await useDB().query.talents
.findMany({
orderBy: [asc(tables.talents.id)],
with: {
talentCategories: {
with: {
category: true
}
}
}
})
return talents.filter(talent =>
(category === 'all' || talent.talentCategories.some(cat => cat.category.slug === category))
&& (favorite === 'false' || talent.favorite)
)
})

View File

@@ -4,14 +4,10 @@ const PostSchema = z.object({ slug: z.string() }).parse
export default defineEventHandler(async (event) => {
const { slug } = await readValidatedBody(event, PostSchema)
return await usePrisma().post.update({
where: {
slug,
},
data: {
views: {
increment: 1,
},
},
return useDB().update(tables.posts)
.set({
views: sql`${tables.posts.views}
+ 1`
})
.where(eq(tables.posts.slug, slug))
})

128
server/database/schema.ts Normal file
View File

@@ -0,0 +1,128 @@
import { boolean, date, integer, pgEnum, pgTable, primaryKey, serial, text, timestamp } from 'drizzle-orm/pg-core'
import { relations } from 'drizzle-orm'
// O B J E C T S
export const maintenances = pgTable('maintenances', {
id: serial('id').primaryKey(),
reason: text('reason').default(''),
enabled: boolean('enabled').default(false).notNull(),
beginAt: date('begin_at').defaultNow().notNull(),
endAt: date('end_at').defaultNow().notNull(),
createdAt: timestamp('created_at').defaultNow().notNull()
})
export const announcements = pgTable('announcements', {
id: serial('id').primaryKey(),
content: text('content').default('').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull()
})
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
slug: text('slug').notNull(),
likes: integer('likes').default(0).notNull(),
views: integer('views').default(0).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull()
})
export const suggestions = pgTable('suggestions', {
id: serial('id').notNull(),
email: text('email').notNull().unique(),
content: text('content').notNull(),
added: boolean('added').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
}, t => ({
pk: primaryKey({columns: [t.id, t.email]})
}))
export const guestbookMessages = pgTable('guestbook_messages', {
id: serial('id').primaryKey(),
message: text('message').notNull(),
email: text('email').notNull().unique(),
username: text('username').notNull(),
image: text('image').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull()
})
export const categoriesType = pgEnum('categoryType', ['talent', 'bookmark'])
export const categories = pgTable('categories', {
id: serial('id').primaryKey(),
slug: text('slug').unique().notNull(),
name: text('name').notNull(),
type: categoriesType('type').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull()
})
export const talents = pgTable('talents', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
logo: text('logo').default('').notNull(),
website: text('website').default('').notNull(),
work: text('work').default('').notNull(),
favorite: boolean('favorite').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull()
})
export const talentsToCategories = pgTable('talents_categories', {
talentId: integer('talent_id').notNull()
.references(() => talents.id, { onDelete: 'cascade' }),
categoryId: integer('category_id').notNull()
.references(() => categories.id, {onDelete: 'cascade'})
})
export const bookmarks = pgTable('bookmarks', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
website: text('website').default('').notNull(),
favorite: boolean('favorite').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull()
})
export const bookmarksToCategories = pgTable('bookmarks_categories', {
bookmarkId: integer('bookmark_id').notNull()
.references(() => bookmarks.id, { onDelete: 'cascade' }),
categoryId: integer('category_id').notNull()
.references(() => categories.id, {onDelete: 'cascade'})
}, t => ({
pk: primaryKey({columns: [t.bookmarkId, t.categoryId]})
}))
// R E L A T I O N S
export const talentsRelations = relations(talents, ({ many }) => ({
talentCategories: many(talentsToCategories)
}))
export const bookmarksRelations = relations(bookmarks, ({ many }) => ({
bookmarkCategories: many(bookmarksToCategories)
}))
export const categoriesRelations = relations(categories, ({ many }) => ({
talentsToCategories: many(talentsToCategories),
bookmarksToCategories: many(bookmarksToCategories)
}))
export const talentsToCategoriesRelations = relations(talentsToCategories, ({ one }) => ({
talent: one(talents, {
references: [talents.id],
fields: [talentsToCategories.talentId]
}),
category: one(categories, {
references: [categories.id],
fields: [talentsToCategories.categoryId]
})
}))
export const bookmarksToCategoriesRelations = relations(bookmarksToCategories, ({ one }) => ({
bookmark: one(bookmarks, {
references: [bookmarks.id],
fields: [bookmarksToCategories.bookmarkId]
}),
category: one(categories, {
references: [categories.id],
fields: [bookmarksToCategories.categoryId]
})
}))

View File

@@ -1,20 +1,19 @@
export default oauth.githubEventHandler({
config: {
emailRequired: true,
emailRequired: true
},
async onSuccess(event: any, { user }: any) {
async onSuccess(event, {user}) {
await setUserSession(event, {
user: {
email: user.email,
picture: user.avatar_url,
username: String(user.name).trim(),
// eslint-disable-next-line node/prefer-global/process
admin: user.email === process.env.NUXT_AUTH_ADMIN_EMAIL,
},
admin: user.email === process.env.NUXT_AUTH_ADMIN_EMAIL
}
})
return sendRedirect(event, getCookie(event, 'last-route') || '/')
},
onError(error: any) {
onError(error) {
console.error('GitHub OAuth error:', error)
},
}
})

View File

@@ -1,17 +1,16 @@
export default oauth.googleEventHandler({
async onSuccess(event: any, { user }: any) {
async onSuccess(event, {user}) {
await setUserSession(event, {
user: {
email: user.email,
picture: user.picture,
username: String(user.name).trim(),
// eslint-disable-next-line node/prefer-global/process
admin: user.email === process.env.NUXT_AUTH_ADMIN_EMAIL,
},
admin: user.email === process.env.NUXT_AUTH_ADMIN_EMAIL
}
})
return sendRedirect(event, getCookie(event, 'last-route') || '/')
},
onError(error: any) {
onError(error) {
console.error('Google OAuth error:', error)
},
}
})

14
server/utils/db.ts Normal file
View File

@@ -0,0 +1,14 @@
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as schema from '../database/schema'
export const tables = schema
export { sql, eq, and, or, asc, desc, sum, inArray } from 'drizzle-orm'
// eslint-disable-next-line node/prefer-global/process
const connectionString = process.env.DATABASE_URL as string
const client = postgres(connectionString, { prepare: false })
export function useDB() {
return drizzle(client, { schema })
}

View File

@@ -17,12 +17,12 @@ export async function sendDiscordWebhookMessage(config: RuntimeConfig, content:
color: content.color,
url: 'https://arthurdanjou.fr/talents',
footer: {
text: 'Powered by Nuxt',
},
timestamp: new Date().toISOString(),
text: 'Powered by Nuxt'
},
timestamp: new Date().toISOString()
}
],
username: 'ArtDanjRobot - Website',
},
username: 'ArtDanjRobot - Website'
}
})
}

View File

@@ -1,16 +0,0 @@
<script lang="ts" setup>
const appConfig = useAppConfig()
const getTextColor = computed(() => `text-${appConfig.ui.primary}-500`)
function getGroupColor() {
return `group-hover:text-${appConfig.ui.primary}-500`
}
</script>
<template>
<NuxtLink to="/" class="flex gap-1 items-center rounded-xl group text-xl !bg-transparent !dark:bg-transparent">
<span :class="getTextColor" class="font-black group-hover:text-black dark:group-hover:text-white duration-300">Arthur</span>
<span :class="getGroupColor()" class="font-bold text-gray-300 dark:text-neutral-600 duration-300">/</span>
<span :class="getTextColor" class="font-black group-hover:text-black dark:group-hover:text-white duration-300">Danjou</span>
</NuxtLink>
</template>

View File

@@ -1,98 +0,0 @@
<script setup lang="ts">
import { otherTab } from '~~/types'
const route = useRoute()
const isOpenModal = ref(false)
const { copy, copied } = useClipboard({ source: 'arthurdanjou@outlook.fr', copiedDuring: 3000 })
</script>
<template>
<nav class="hidden lg:block z-50">
<div class="flex items-center h-10 rounded-md p-1 gap-1 relative bg-black/5 text-sm font-medium text-zinc-700 dark:bg-zinc-800/90 dark:text-zinc-300">
<UButton to="/" size="sm" variant="ghost" color="white" :class="{ 'link-active': route.path === '/' }">
Home
</UButton>
<UButton to="/about" size="sm" variant="ghost" color="white" :class="{ 'link-active': route.path.includes('/about') }">
About
</UButton>
<!-- <UButton to="/writing" size="sm" variant="ghost" color="white" :class="{ 'link-active': route.path.includes('/writing') }">
Articles
</UButton> -->
<UButton to="/work" size="sm" variant="ghost" color="white" :class="{ 'link-active': route.path.includes('/work') }">
Projects
</UButton>
<UButton to="/uses" size="sm" variant="ghost" color="white" :class="{ 'link-active': route.path.includes('/uses') }">
Uses
</UButton>
<UDropdown mode="hover" :items="otherTab" :popper="{ placement: 'bottom' }">
<UButton size="sm" variant="ghost" color="white" class="duration-300">
Other
</UButton>
</UDropdown>
<UButton size="sm" variant="ghost" color="white" @click="isOpenModal = true">
Contact
</UButton>
</div>
<UModal v-model="isOpenModal">
<UCard class="p-4">
<div>
<div class="mb-8 flex justify-between items-center">
<h1 class="text-xl font-bold">
Contact me
</h1>
<UButton size="xs" icon="i-akar-icons-cross" variant="ghost" @click.prevent="isOpenModal = false" />
</div>
<div class="flex flex-col space-y-6">
<div class="flex flex-col md:flex-row justify-between md:items-center space-y-2">
<div class="flex flex-col">
<h3 class="text-sm">
Email
</h3>
<p class="text-xs text-subtitle">
arthurdanjou@outlook.fr
</p>
</div>
<div>
<UButtonGroup size="sm" orientation="horizontal">
<UButton variant="solid" color="gray" label="Compose" to="mailto:arthurdanjou@outlook.fr" icon="i-mdi-note-edit-outline" />
<UButton v-if="copied" variant="solid" color="green" label="Copied" icon="i-mdi-content-copy" />
<UButton v-else variant="solid" color="gray" label="Copy" icon="i-mdi-content-copy" @click.prevent="copy()" />
</UButtonGroup>
</div>
</div>
<UDivider label="OR" />
<div class="flex flex-col md:flex-row justify-between md:items-center space-y-2">
<div class="flex flex-col">
<h3 class="text-sm">
Get in touch
</h3>
<p class="text-xs text-subtitle">
I'm most active on Twitter
</p>
</div>
<div>
<UButtonGroup size="sm" orientation="horizontal">
<UButton
variant="solid" color="gray" label="Github" icon="i-ph-github-logo-bold"
to="https://github.com/ArthurDanjou" target="_blank"
/>
<UButton
variant="solid" color="gray" label="Twitter" icon="i-ph-twitter-logo-bold"
to="https://twitter.com/ArthurDanj" target="_blank"
/>
</UButtonGroup>
</div>
</div>
</div>
</div>
</UCard>
</UModal>
</nav>
</template>
<style lang="scss">
.link-active {
@apply bg-white/60 dark:bg-black
}
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -1,7 +0,0 @@
export default defineEventHandler(async () => {
return await usePrisma().announcement.findFirst({
orderBy: {
createdAt: 'desc',
},
})
})

View File

@@ -1,20 +0,0 @@
import { z } from 'zod'
const PostSchema = z.object({ slug: z.string() }).parse
export default defineEventHandler(async (event) => {
const { slug } = await readValidatedBody(event, PostSchema)
return await usePrisma().post.upsert({
where: {
slug,
},
update: {
views: {
increment: 1,
},
},
create: {
slug,
},
})
})

View File

@@ -1,46 +0,0 @@
export default defineEventHandler(async (event) => {
const { favorite, category } = getQuery(event)
const prisma = usePrisma()
let whereClause: any
if (favorite === 'true') {
category === 'all'
? whereClause = {
favorite: true,
categories: { every: { category: {} } },
}
: whereClause = {
favorite: true,
categories: { some: { category: { slug: category } } },
}
}
else {
category === 'all'
? whereClause = {
categories: { every: { category: {} } },
}
: whereClause = {
categories: { some: { category: { slug: category } } },
}
}
return await prisma.bookmark.findMany({
where: whereClause,
orderBy: {
name: 'asc',
},
include: {
categories: {
include: {
category: true,
},
orderBy: {
category: {
name: 'asc',
},
},
},
},
})
})

View File

@@ -1,10 +0,0 @@
import type { CategoryType } from '@prisma/client'
export default defineEventHandler(async (event) => {
const { type } = getQuery<{ type: CategoryType }>(event)
return await usePrisma().category.findMany({
where: {
type,
},
})
})

View File

@@ -1,23 +0,0 @@
export default defineEventHandler(async () => {
const maintenance = await usePrisma().maintenance.findFirst({
orderBy: {
createdAt: 'desc',
},
})
let enabled = true
if (process.env.NODE_ENV === 'development') {
enabled = false
}
else {
const today = new Date()
enabled = !!maintenance
&& maintenance.enabled
&& maintenance.beginAt.getTime() < today.getTime()
&& maintenance.endAt.getTime() > today.getTime()
}
return {
enabled,
maintenance,
}
})

View File

@@ -1,7 +0,0 @@
export default defineEventHandler(async () => {
return await usePrisma().guestbookMessage.findMany({
orderBy: {
updatedAt: 'desc',
},
})
})

View File

@@ -1,30 +0,0 @@
import { z } from 'zod'
const SuggestionValidator = z.object({
content: z.string(),
}).parse
export default defineEventHandler(async (event) => {
const { content } = await readValidatedBody(event, SuggestionValidator)
const { user } = await requireUserSession(event)
const config = useRuntimeConfig(event)
await sendDiscordWebhookMessage(config, {
title: 'New suggestion ✨',
description: `**${user.username}** as requested **${content}** for the talents page.`,
color: 15237114,
})
return await usePrisma().suggestion.upsert({
where: {
email: user.email,
},
update: {
content,
},
create: {
email: user.email,
content,
},
})
})

Some files were not shown because too many files have changed in this diff Show More