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"> <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" /> <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 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 class="relative inline-flex rounded-full h-2 w-2" :class="getColor()" />
</span> </span>
</div> </div>

View File

@@ -7,7 +7,7 @@ function jumpVal(val: number) {
return Math.random() > 0.5 ? val + (Math.random() - 0.5) / 2 : Math.random() return Math.random() > 0.5 ? val + (Math.random() - 0.5) / 2 : Math.random()
} }
let timeout: any let timeout: NodeJS.Timeout
function jumpPoints() { function jumpPoints() {
for (let i = 0; i < points.value.length; i++) for (let i = 0; i < points.value.length; i++)
points.value[i] = [jumpVal(points.value[i][0]), jumpVal(points.value[i][1])] points.value[i] = [jumpVal(points.value[i][0]), jumpVal(points.value[i][1])]
@@ -21,7 +21,10 @@ onUnmounted(() => clearTimeout(timeout))
<template> <template>
<ClientOnly> <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 <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" 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})` }" :style="{ 'clip-path': `polygon(${poly})` }"

View File

@@ -13,11 +13,25 @@ const year = computed(() => new Date().getFullYear())
<p class="text-subtitle"> <p class="text-subtitle">
Designed & Built by Designed & Built by
</p> </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> </div>
<p class="text-subtitle flex items-center"> <p class="text-subtitle flex items-center">
Made with 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> </p>
</div> </div>
</div> </div>

View File

@@ -2,8 +2,8 @@
defineProps({ defineProps({
title: { title: {
type: String, type: String,
default: 'Uses Section title', default: 'Uses Section title'
}, }
}) })
const appConfig = useAppConfig() const appConfig = useAppConfig()
@@ -14,7 +14,10 @@ const getColor = computed(() => `text-${appConfig.ui.primary}-500`)
<template> <template>
<section class="md:border-l md:border-zinc-100 md:pl-6 md:dark:border-zinc-700/40 mb-24 px-4"> <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"> <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="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 class="h-4 w-0.5 rounded-full bg-zinc-200 dark:bg-zinc-500" />
</span> </span>

View File

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

View File

@@ -2,13 +2,13 @@
defineProps({ defineProps({
href: { href: {
type: String, type: String,
default: '', default: ''
}, },
target: { target: {
type: String, type: String,
default: undefined, default: undefined,
required: false, required: false
}, }
}) })
const appConfig = useAppConfig() 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> </div>
<USlideover v-model="isOpenSidebar"> <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> <template #header>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<Logo /> <Logo />
@@ -47,7 +50,11 @@ const { copy, copied } = useClipboard({ source: 'arthurdanjou@outlook.fr', copie
</template> </template>
<div class="flex flex-col space-y-2"> <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 <UButton
v-if="nav.to" v-if="nav.to"
size="sm" size="sm"
@@ -83,7 +90,12 @@ const { copy, copied } = useClipboard({ source: 'arthurdanjou@outlook.fr', copie
<h1 class="text-xl font-bold"> <h1 class="text-xl font-bold">
Contact me Contact me
</h1> </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>
<div class="flex flex-col space-y-6"> <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 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> </p>
</div> </div>
<div> <div>
<UButtonGroup size="sm" orientation="horizontal"> <UButtonGroup
<UButton variant="solid" color="gray" label="Compose" to="mailto:arthurdanjou@outlook.fr" icon="i-mdi-note-edit-outline" /> orientation="horizontal"
<UButton v-if="copied" variant="solid" color="green" label="Copied" icon="i-mdi-content-copy" /> size="sm"
<UButton v-else variant="solid" color="gray" label="Copy" icon="i-mdi-content-copy" @click.prevent="copy()" /> >
<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> </UButtonGroup>
</div> </div>
</div> </div>
@@ -114,14 +148,25 @@ const { copy, copied } = useClipboard({ source: 'arthurdanjou@outlook.fr', copie
</p> </p>
</div> </div>
<div> <div>
<UButtonGroup size="sm" orientation="horizontal"> <UButtonGroup
orientation="horizontal"
size="sm"
>
<UButton <UButton
variant="solid" color="gray" label="Github" icon="i-ph-github-logo-bold" color="gray"
to="https://github.com/ArthurDanjou" target="_blank" icon="i-ph-github-logo-bold"
label="Github"
target="_blank"
to="https://github.com/ArthurDanjou"
variant="solid"
/> />
<UButton <UButton
variant="solid" color="gray" label="Twitter" icon="i-ph-twitter-logo-bold" color="gray"
to="https://twitter.com/ArthurDanj" target="_blank" icon="i-ph-twitter-logo-bold"
label="Twitter"
target="_blank"
to="https://twitter.com/ArthurDanj"
variant="solid"
/> />
</UButtonGroup> </UButtonGroup>
</div> </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="{ :ui="{
background: 'bg-white dark:bg-stone-900', background: 'bg-white dark:bg-stone-900',
ring: 'ring-1 ring-gray-200 dark:ring-stone-800', ring: 'ring-1 ring-gray-200 dark:ring-stone-800',
container: 'z-30', container: 'z-30'
}" }"
> >
<template #default="{ open }"> <template #default="{ open }">
@@ -31,14 +31,23 @@ watch(isDark, () => {
:class="[open && 'bg-gray-50 dark:bg-gray-800']" :class="[open && 'bg-gray-50 dark:bg-gray-800']"
aria-label="Color picker" 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> </UButton>
</template> </template>
<template #panel> <template #panel>
<div class="p-2"> <div class="p-2">
<div class="grid grid-cols-5 gap-px"> <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 <UButton
color="gray" color="gray"
square square
@@ -46,15 +55,21 @@ watch(isDark, () => {
color: { color: {
white: { white: {
solid: 'ring-0 bg-gray-100 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800', 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'" :variant="color === getColor ? 'solid' : 'ghost'"
@click.stop.prevent="setColor(color)" @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`"> <span
<UIcon v-if="color === getColor" name="i-ic-round-check" /> :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> </span>
</UButton> </UButton>
</UTooltip> </UTooltip>

View File

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

View File

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

View File

@@ -5,12 +5,15 @@ const stats = await $fetch<Stats>('/api/stats')
const CardUi = { const CardUi = {
footer: { padding: 'px-4 py-2' }, footer: { padding: 'px-4 py-2' },
body: { base: 'h-full' }, body: {base: 'h-full'}
} }
</script> </script>
<template> <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"> <div class="flex items-center gap-x-4 h-full">
<p <p
class="uppercase tracking-widest text-sm" class="uppercase tracking-widest text-sm"
@@ -23,7 +26,9 @@ const CardUi = {
<div class="text-md"> <div class="text-md">
<div class="flex items-center gap-x-1"> <div class="flex items-center gap-x-1">
<h3>Total hours:</h3> <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>
<div class="flex items-start gap-x-1 flex-wrap"> <div class="flex items-start gap-x-1 flex-wrap">
<h3>Best Editors:</h3> <h3>Best Editors:</h3>
@@ -33,11 +38,15 @@ const CardUi = {
</div> </div>
<div class="flex items-center gap-x-1"> <div class="flex items-center gap-x-1">
<h3>Best OS:</h3> <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>
<div class="flex items-start gap-x-1 flex-wrap"> <div class="flex items-start gap-x-1 flex-wrap">
<h3>Top languages:</h3> <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> </div>
</div> </div>
@@ -46,7 +55,10 @@ const CardUi = {
<template #footer> <template #footer>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<ClientOnly> <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 }} Started {{ useTimeAgo(new Date(stats.coding.data.range.start)).value }}, the {{ useDateFormat(new Date(stats.coding.data.range.start), 'Do MMMM YYYY').value }}
</p> </p>
</ClientOnly> </ClientOnly>
@@ -62,7 +74,10 @@ const CardUi = {
target="_blank" target="_blank"
label="Wakatime" label="Wakatime"
/> />
<UIcon name="i-jam-thunder" class="text-subtitle" /> <UIcon
class="text-subtitle"
name="i-jam-thunder"
/>
</div> </div>
</div> </div>
</template> </template>

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
import type { Skill } from '~~/types' import type { Skill } from '~~/types'
defineProps({ defineProps({
skill: Object as PropType<Skill>, skill: Object as PropType<Skill>
}) })
const { $colorMode } = useNuxtApp() 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" 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"> <div class="flex items-center">
<UIcon v-if="isLight" :name="skill.icon.light ? skill.icon.light : skill.icon" size="20" dynamic /> <UIcon
<UIcon v-else :name="skill.icon.dark ? skill.icon.dark : skill.icon" size="20" dynamic /> 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> </div>
<span class="text-sm text-subtitle">{{ skill.name }}</span> <span class="text-sm text-subtitle">{{ skill.name }}</span>
</li> </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) => { router.afterEach((route) => {
useCookie('last-route', { path: '/', default: () => '/' }).value = route.fullPath useCookie('last-route', { path: '/', default: () => '/' }).value = route.fullPath
}) })
useHead({
link: [{ rel: 'icon', type: 'image/png', href: '/favicon.jpeg' }],
})
</script> </script>
<template> <template>

View File

@@ -1,7 +1,7 @@
export default defineNuxtRouteMiddleware(async (to) => { export default defineNuxtRouteMiddleware(async (to) => {
const isMaintenance = ref<boolean>(true) const isMaintenance = ref<boolean>(true)
try { try {
await $fetch('/api/maintenance').then((maintenance: any) => { await $fetch('/api/maintenance').then((maintenance) => {
isMaintenance.value = maintenance.enabled isMaintenance.value = maintenance.enabled
}) })
} }
@@ -11,14 +11,14 @@ export default defineNuxtRouteMiddleware(async (to) => {
if (isMaintenance.value && to.path !== '/maintenance') { if (isMaintenance.value && to.path !== '/maintenance') {
return navigateTo('/maintenance', { return navigateTo('/maintenance', {
redirectCode: 301, redirectCode: 301
}) })
} }
if (!isMaintenance.value && to.path === '/maintenance') { if (!isMaintenance.value && to.path === '/maintenance') {
return navigateTo('/', { return navigateTo('/', {
redirectCode: 301, 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 */ /* eslint-disable node/prefer-global/process */
export default defineNuxtConfig({ export default defineNuxtConfig({
srcDir: 'src',
css: [ css: [
'@/assets/css/main.scss', '@/assets/css/main.scss',
], ],

View File

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

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
useHead({ useHead({
title: 'About me • Arthur Danjou', title: 'About me • Arthur Danjou'
}) })
const { data: skills } = await getSkills() 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="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="lg:pl-20 flex justify-center">
<div class="max-w-xs px-2.5 lg:max-w-none"> <div class="max-w-xs px-2.5 lg:max-w-none">
<UTooltip text="It's me 👋" :popper="{ offsetDistance: 20 }"> <UTooltip
<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"> :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> </UTooltip>
</div> </div>
</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. 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> </GridSlot>
</GridSection> </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"> <div class="grid grid-cols-3 md:grid-cols-4 gap-2">
<Skill <Skill
v-for="skill in skills.body" v-for="skill in skills.body"
@@ -58,14 +68,31 @@ const { data: experiences } = await getWorkExperiences()
/> />
</div> </div>
</GridSection> </GridSection>
<GridSection v-if="experiences" title="Work Experiences"> <GridSection
<Experience v-for="experience in experiences" :key="experience.title" :experience="experience" /> v-if="experiences"
title="Work Experiences"
>
<Experience
v-for="experience in experiences"
:key="experience.title"
:experience="experience"
/>
</GridSection> </GridSection>
<GridSection v-if="educations" title="Educations"> <GridSection
<Education v-for="education in educations" :key="education.title" :education="education" /> v-if="educations"
title="Educations"
>
<Education
v-for="education in educations"
:key="education.title"
:education="education"
/>
</GridSection> </GridSection>
<div class="flex justify-center"> <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 <UButton
label="Download my CV" label="Download my CV"
icon="i-material-symbols-lab-profile-outline-rounded" icon="i-material-symbols-lab-profile-outline-rounded"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
useHead({ useHead({
title: 'My work • Arthur Danjou', title: 'My work • Arthur Danjou'
}) })
const { data: projects } = await getProjects() const { data: projects } = await getProjects()
</script> </script>
@@ -24,12 +24,19 @@ const { data: projects } = await getProjects()
> >
<div class="flex items-start gap-4"> <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"> <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>
<div> <div>
<h2 class="text-base font-semibold text-zinc-800 dark:text-zinc-100"> <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" /> <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="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> <span class="relative z-10">{{ project.title }}</span>
</NuxtLink> </NuxtLink>

View File

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

View File

@@ -3,7 +3,7 @@ const appConfig = useAppConfig()
const getColor = computed(() => `text-${appConfig.ui.primary}-500`) const getColor = computed(() => `text-${appConfig.ui.primary}-500`)
useHead({ useHead({
title: 'My Shelf • Arthur Danjou', title: 'My Shelf • Arthur Danjou'
}) })
const { data: posts } = await getPosts() 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="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="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"> <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"> <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"> <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" /> <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"> <p class="relative z-10 mt-2 text-sm text-zinc-600 dark:text-zinc-400">
{{ post.description }} {{ post.description }}
</p> </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> <p>Read article</p>
<UIcon name="i-ph-arrow-circle-right-bold" /> <UIcon name="i-ph-arrow-circle-right-bold" />
</div> </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) => { export default defineEventHandler(async (event) => {
const { slug } = await readValidatedBody(event, PostSchema) const { slug } = await readValidatedBody(event, PostSchema)
return await usePrisma().post.update({ return useDB().update(tables.posts)
where: { .set({
slug, likes: sql`${tables.posts.likes}
}, + 1`
data: { })
likes: { .where(eq(tables.posts.slug, slug))
increment: 1,
},
},
})
}) })

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

View File

@@ -1,7 +1,7 @@
import { z } from 'zod' import { z } from 'zod'
const MessageValidator = z.object({ const MessageValidator = z.object({
message: z.string(), message: z.string()
}).parse }).parse
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@@ -11,22 +11,20 @@ export default defineEventHandler(async (event) => {
await sendDiscordWebhookMessage(config, { await sendDiscordWebhookMessage(config, {
title: 'New guestbook message ✨', title: 'New guestbook message ✨',
description: `**${user.username}** as signed the book : "*${message}*"`, description: `**${user.username}** has signed the book : "*${message}*"`,
color: 15893567, color: 15893567
}) })
return useDB().insert(tables.guestbookMessages)
return await usePrisma().guestbookMessage.upsert({ .values({
where: {
email: user.email,
},
update: {
message, message,
},
create: {
email: user.email, email: user.email,
image: user.picture,
username: user.username, 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, coding,
editors, editors,
os, 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) => { export default defineEventHandler(async (event) => {
const { slug } = await readValidatedBody(event, PostSchema) const { slug } = await readValidatedBody(event, PostSchema)
return await usePrisma().post.update({ return useDB().update(tables.posts)
where: { .set({
slug, views: sql`${tables.posts.views}
}, + 1`
data: { })
views: { .where(eq(tables.posts.slug, slug))
increment: 1,
},
},
})
}) })

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({ export default oauth.githubEventHandler({
config: { config: {
emailRequired: true, emailRequired: true
}, },
async onSuccess(event: any, { user }: any) { async onSuccess(event, {user}) {
await setUserSession(event, { await setUserSession(event, {
user: { user: {
email: user.email, email: user.email,
picture: user.avatar_url, picture: user.avatar_url,
username: String(user.name).trim(), 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') || '/') return sendRedirect(event, getCookie(event, 'last-route') || '/')
}, },
onError(error: any) { onError(error) {
console.error('GitHub OAuth error:', error) console.error('GitHub OAuth error:', error)
}, }
}) })

View File

@@ -1,17 +1,16 @@
export default oauth.googleEventHandler({ export default oauth.googleEventHandler({
async onSuccess(event: any, { user }: any) { async onSuccess(event, {user}) {
await setUserSession(event, { await setUserSession(event, {
user: { user: {
email: user.email, email: user.email,
picture: user.picture, picture: user.picture,
username: String(user.name).trim(), 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') || '/') return sendRedirect(event, getCookie(event, 'last-route') || '/')
}, },
onError(error: any) { onError(error) {
console.error('Google OAuth error:', 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, color: content.color,
url: 'https://arthurdanjou.fr/talents', url: 'https://arthurdanjou.fr/talents',
footer: { footer: {
text: 'Powered by Nuxt', text: 'Powered by Nuxt'
}, },
timestamp: new Date().toISOString(), 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