Merge branch 'Cobe_implementation'

# Conflicts:
#	package.json
#	pnpm-lock.yaml
This commit is contained in:
2025-02-02 18:30:52 +01:00
20 changed files with 1512 additions and 1290 deletions

View File

@@ -1,9 +1,32 @@
export default defineAppConfig({
ui: {
gray: 'neutral',
primary: 'gray',
container: {
constrained: 'max-w-4xl',
base: 'max-w-4xl',
},
colors: {
primary: 'neutral',
white: 'white',
black: 'black',
neutral: 'neutral',
red: 'red',
green: 'green',
blue: 'blue',
yellow: 'yellow',
purple: 'purple',
pink: 'pink',
indigo: 'indigo',
cyan: 'cyan',
teal: 'teal',
gray: 'gray',
orange: 'orange',
amber: 'amber',
lime: 'lime',
emerald: 'emerald',
rose: 'rose',
sky: 'sky',
violet: 'violet',
fuchsia: 'fuchsia',
lightBlue: 'lightBlue',
},
},
})

View File

@@ -39,16 +39,15 @@ const head = useLocaleHead({
</template>
</Head>
<Body>
<div>
<UApp>
<NuxtLoadingIndicator color="#808080" />
<AppBackground />
<UContainer class="z-50 relative">
<AppHeader />
<AppVisitors />
<NuxtPage class="mt-12" />
<AppFooter />
</UContainer>
</div>
</UApp>
</Body>
</Html>
</template>
@@ -56,7 +55,6 @@ const head = useLocaleHead({
<style>
body {
font-family: 'DM Sans', sans-serif;
@apply h-full w-full text-neutral-700 dark:text-neutral-300;
}
.sofia {

4
app/assets/css/main.css Normal file
View File

@@ -0,0 +1,4 @@
@import "tailwindcss";
@import "@nuxt/ui";
@plugin "@tailwindcss/typography";

View File

@@ -30,9 +30,9 @@ const { t } = useI18n({
<template>
<footer class="my-16">
<div class="flex justify-center mb-16">
<UDivider
<USeparator
class="md:w-2/3"
size="2xs"
size="xs"
type="solid"
/>
</div>

View File

@@ -14,11 +14,6 @@ const navs = [
},
to: '/',
icon: 'house-line-duotone',
shortcut: {
en: 'H',
fr: 'A',
es: 'I',
},
},
{
label: {
@@ -28,11 +23,6 @@ const navs = [
},
to: '/uses',
icon: 'backpack-duotone',
shortcut: {
en: 'U',
fr: 'U',
es: 'U',
},
},
{
label: {
@@ -42,11 +32,6 @@ const navs = [
},
to: '/portfolio',
icon: 'books-duotone',
shortcut: {
en: 'W',
fr: 'E',
es: 'E',
},
},
{
label: {
@@ -57,11 +42,6 @@ const navs = [
icon: 'address-book-duotone',
to: '/Resume2024.pdf',
target: '_blank',
shortcut: {
en: 'R',
fr: 'C',
es: 'C',
},
},
]
@@ -76,33 +56,27 @@ async function toggleTheme() {
document.body.style.animation = ''
}
const { locale, setLocale, locales, t, availableLocales } = useI18n()
const currentLocale = computed(() => locales.value.filter(l => l.code === locale.value)[0])
const { locale, setLocale, locales, t } = useI18n()
const currentLocale = computed(() => locales.filter(l => l.code === locale.value)[0])
const lang = ref(locale.value)
watch(lang, () => changeLocale(lang.value))
async function changeLocale(newLocale?: string) {
async function changeLocale(newLocale: string) {
document.body.style.animation = 'switch-on .2s'
await new Promise(resolve => setTimeout(resolve, 200))
if (!newLocale) {
const currentLocaleIndex = availableLocales.findIndex(l => l === locale.value)
const nextLocaleIndex = (currentLocaleIndex + 1) % availableLocales.length
newLocale = availableLocales[nextLocaleIndex]
lang.value = newLocale!
}
await setLocale(newLocale ?? 'en')
await setLocale(newLocale as 'en' | 'fr' | 'es')
document.body.style.animation = 'switch-off .2s'
await new Promise(resolve => setTimeout(resolve, 200))
document.body.style.animation = ''
}
const open = ref(false)
const router = useRouter()
defineShortcuts({
t: () => toggleTheme(),
l: () => changeLocale(),
l: () => open.value = !open.value,
backspace: () => router.back(),
})
</script>
@@ -110,7 +84,7 @@ defineShortcuts({
<template>
<header class="flex md:items-center justify-between my-8 gap-2">
<NuxtLinkLocale
class="handwriting text-xl sm:text-3xl text-nowrap gap-2 font-bold duration-300 text-gray-600 hover:text-black dark:text-gray-400 dark:hover:text-white"
class="handwriting text-xl sm:text-3xl text-nowrap gap-2 font-bold duration-300 text-neutral-600 hover:text-black dark:text-neutral-400 dark:hover:text-white"
to="/"
>
Arthur Danjou
@@ -126,52 +100,45 @@ defineShortcuts({
:target="nav.target ? nav.target : '_self'"
:to="nav.to"
:aria-label="nav.label"
color="white"
color="neutral"
size="sm"
variant="solid"
variant="outline"
/>
</UTooltip>
<ClientOnly>
<UTooltip
:shortcuts="['T']"
:kbds="['T']"
:text="t('theme')"
>
<UButton
:icon="isDark ? 'i-ph-moon-duotone' : 'i-ph-sun-duotone'"
color="white"
color="neutral"
aria-label="switch theme"
size="sm"
variant="solid"
variant="outline"
@click="toggleTheme()"
/>
</UTooltip>
<UTooltip
:shortcuts="['L']"
:kbds="['L']"
:text="t('language')"
:popper="{ placement: 'right' }"
:content="{
align: 'center',
side: 'right',
sideOffset: 8,
}"
>
<USelectMenu
<USelect
v-model="lang"
:options="locales"
v-model:open="open"
:items="locales"
size="sm"
option-attribute="code"
value-attribute="code"
:leading-icon="currentLocale.icon"
label-key="label"
value-key="code"
variant="outline"
>
<template #leading>
<UIcon
:name="currentLocale!.icon"
class="w-5 h-5"
/>
</template>
<template #option="{ option: language }">
<UIcon
:name="language.icon"
class="w-5 h-5"
/>
<span>{{ language.code }}</span>
</template>
</USelectMenu>
@update:model-value="changeLocale"
/>
</UTooltip>
</ClientOnly>
</nav>

View File

@@ -1,16 +0,0 @@
<script setup lang="ts">
const { isLoading, visitors } = useVisitors()
</script>
<template>
<div v-if="!isLoading" class="fixed bottom-4 right-4">
<UTooltip text="Visitors currently on my portfolio" placement="left">
<nav class="text-xs flex space-x-1 items-center border border-green-400 dark:border-green-600 px-2 rounded-xl bg-white dark:bg-black">
<p class="text-neutral-700 dark:text-neutral-300">
{{ visitors }}
</p>
<div class="h-3 w-3 bg-green-200 dark:bg-green-600 rounded-full border-2 border-green-400 dark:border-green-800" />
</nav>
</UTooltip>
</div>
</template>

View File

@@ -20,6 +20,12 @@ defineProps({
<ClientOnly>
<UTooltip
:popper="{ placement: position }"
:delay-duration="4"
:content="{
align: 'center',
side: position,
sideOffset: 8,
}"
:text="hover"
>
<strong class="leading-3 cursor-help">{{ text }}</strong>

View File

@@ -9,6 +9,22 @@ defineProps({
default: 'gray',
},
})
const colorVariants = {
gray: 'text-gray-500 decoration-gray-400',
red: 'text-red-500 decoration-red-400',
yellow: 'text-yellow-500 decoration-yellow-400',
green: 'text-green-500 decoration-green-400',
blue: 'text-blue-500 decoration-blue-400',
indigo: 'text-indigo-500 decoration-indigo-400',
purple: 'text-purple-500 decoration-purple-400',
pink: 'text-pink-500 decoration-pink-400',
sky: 'text-sky-500 decoration-sky-400',
zinc: 'text-zinc-500 decoration-zinc-400',
orange: 'text-orange-500 decoration-orange-400',
amber: 'text-amber-500 decoration-amber-400',
emerald: 'text-emerald-500 decoration-emerald-400',
}
</script>
<template>
@@ -18,7 +34,7 @@ defineProps({
:name="`i-logos:${icon}`"
/>
<span
:class="`text-${color}-500 decoration-${color}-300`"
:class="colorVariants[color]"
class="sofia font-medium underline-offset-2 underline"
>
<slot />

View File

@@ -1,83 +1,141 @@
<script setup lang="ts">
<script lang="ts" setup>
import type { COBEOptions } from 'cobe'
import createGlobe from 'cobe'
import { useSpring } from 'vue-use-spring'
const { t } = useI18n({
useScope: 'local',
interface GlobeProps {
class?: string
config?: Partial<COBEOptions>
mass?: number
tension?: number
friction?: number
precision?: number
locations?: Array<{ latitude: number, longitude: number }>
myLocation?: { latitude: number, longitude: number }
}
const props = withDefaults(defineProps<GlobeProps>(), {
mass: 1,
tension: 280,
friction: 100,
precision: 0.001,
})
const myLocation = useState<{ longitude: number, latitude: number }>('location')
const globe = ref<HTMLCanvasElement | null>(null)
const DEFAULT_CONFIG: COBEOptions = {
width: 400,
height: 400,
onRender: () => {},
devicePixelRatio: 2,
phi: 0,
theta: 0.3,
dark: 0,
diffuse: 0.4,
mapSamples: 20000,
mapBrightness: 1,
baseColor: [0.8, 0.8, 0.8],
opacity: 0.7,
markerColor: [251 / 255, 100 / 255, 21 / 255],
glowColor: [1, 1, 1],
markers: [],
}
const globeCanvasRef = ref<HTMLCanvasElement>()
const phi = ref(0)
const locations = ref<Array<{ latitude: number, longitude: number }>>([])
const width = ref(0)
const pointerInteracting = ref()
const pointerInteractionMovement = ref()
const { data, open } = useWebSocket(`/visitors?latitude=${myLocation.value.latitude}&longitude=${myLocation.value.longitude}`, { immediate: true })
watch(data, async (newData) => {
locations.value = JSON.parse(typeof newData === 'string' ? newData : await newData.text())
})
let globe: ReturnType<typeof createGlobe> | null = null
const spring = useSpring(
{
r: 0,
},
{
mass: props.mass,
tension: props.tension,
friction: props.friction,
precision: props.precision,
},
)
function updatePointerInteraction(clientX: number | null) {
if (clientX !== null) {
pointerInteracting.value = clientX - (pointerInteractionMovement.value ?? clientX)
}
else {
pointerInteracting.value = null
}
if (globeCanvasRef.value) {
globeCanvasRef.value.style.cursor = clientX ? 'grabbing' : 'grab'
}
}
function updateMovement(clientX: number) {
if (pointerInteracting.value !== null) {
const delta = clientX - (pointerInteracting.value ?? clientX)
pointerInteractionMovement.value = delta
spring.r = delta / 200
}
}
function onRender(state: Record<string, unknown>) {
if (!pointerInteracting.value) {
phi.value += 0.005
}
state.phi = phi.value + spring.r
state.width = width.value * 2
state.height = width.value * 2
state.markers = props.locations?.map(location => ({
location: [location.latitude, location.longitude],
// Set the size of the marker to 0.1 if it's the user's location, otherwise 0.05
size: props.myLocation?.latitude === location.latitude && props.myLocation?.longitude === location.longitude ? 0.1 : 0.05,
}))
}
function onResize() {
if (globeCanvasRef.value) {
width.value = globeCanvasRef.value.offsetWidth
}
}
function createGlobeOnMounted() {
const config = { ...DEFAULT_CONFIG, ...props.config }
globe = createGlobe(globeCanvasRef.value!, {
...config,
width: width.value,
height: width.value,
onRender,
})
}
onMounted(() => {
open()
createGlobe(globe.value!, {
devicePixelRatio: 2,
width: 400 * 2,
height: 400 * 2,
phi: 0,
theta: 0,
dark: 1,
diffuse: 1.2,
scale: 1,
mapSamples: 20000,
mapBrightness: 6,
baseColor: [0.3, 0.3, 0.3],
markerColor: [0.1, 0.8, 0.1],
glowColor: [0.2, 0.2, 0.2],
markers: [],
opacity: 0.3,
onRender(state) {
state.markers = locations.value.map(location => ({
location: [location.latitude, location.longitude],
size: myLocation.value.latitude === location.latitude && myLocation.value.longitude === location.longitude ? 0.1 : 0.05,
}))
state.phi = phi.value
phi.value += 0.01
},
})
window.addEventListener('resize', onResize)
onResize()
createGlobeOnMounted()
setTimeout(() => (globeCanvasRef.value!.style.opacity = '1'))
})
onBeforeUnmount(() => {
globe?.destroy()
window.removeEventListener('resize', onResize)
})
</script>
<template>
<section class="mt-12 not-prose w-full flex flex-col items-center justify-center">
<canvas ref="globe" />
<!-- <ClientOnly>
<div class="group text-[12px] flex items-center gap-1">
<UIcon
name="i-ph-map-pin-duotone"
/>
<p>{{ t('globe') }}</p>
</div>
</ClientOnly> -->
</section>
<div :class="props.class">
<canvas
ref="globeCanvasRef"
class="size-full opacity-0 transition-opacity duration-1000 ease-in-out [contain:layout_paint_size]"
@pointerdown="(e) => updatePointerInteraction(e.clientX)"
@pointerup="updatePointerInteraction(null)"
@pointerout="updatePointerInteraction(null)"
@mousemove="(e) => updateMovement(e.clientX)"
@touchmove="(e) => e.touches[0] && updateMovement(e.touches[0].clientX)"
/>
</div>
</template>
<style scoped>
canvas {
width: 400px;
height: 400px;
max-width: 100%;
aspect-ratio: 1;
}
</style>
<i18n>
{
"en": {
"globe": "Each marker represents a visitor connected to my site."
},
"fr": {
"globe": "Chaque point représente un visiteur connecté sur mon site."
},
"es": {
"globe": "Cada marcador representa un visitante conectado a mi sitio."
}
}
</i18n>

View File

@@ -2,7 +2,7 @@
import type { Stats } from '~~/types'
const { locale, locales } = useI18n()
const currentLocale = computed(() => locales.value.find(l => l.code === locale.value))
const currentLocale = computed(() => locales.find(l => l.code === locale.value))
const { data: stats } = await useFetch<Stats>('/api/stats')
const { t } = useI18n({

View File

@@ -9,7 +9,7 @@ defineProps({
<template>
<div class="space-y-8">
<UDivider
<USeparator
:label="title"
size="xs"
/>

View File

@@ -6,6 +6,8 @@ const { data: page } = await useAsyncData(`/home/${locale.value}`, () => {
}, {
watch: [locale],
})
const { myLocation, locations } = useVisitors()
</script>
<template>
@@ -15,6 +17,12 @@ const { data: page } = await useAsyncData(`/home/${locale.value}`, () => {
<HomeActivity />
<HomeQuote />
<HomeCatchPhrase />
<HomeGlobe />
{{ locations }}
{{ myLocation }}
<HomeGlobe
:my-location
:locations
class="mt-8 mx-auto aspect-[1/1] duration-500 md:w-1/2"
/>
</main>
</template>

View File

@@ -82,7 +82,7 @@ async function handleLike() {
icon="i-ph-warning-duotone"
variant="outline"
/>
<div class="border-l-2 pl-2 border-gray-300 dark:border-gray-700 rounded-sm flex gap-1 items-center">
<div class="border-l-2 pl-2 rounded-none border-gray-300 dark:border-neutral-700 flex gap-1 items-center">
<UIcon name="i-ph-heart-duotone" size="16" />
<p>{{ getDetails().likes }} </p>·
<UIcon name="i-ph-eye-duotone" size="16" />
@@ -117,7 +117,7 @@ async function handleLike() {
alt="Writing cover"
/>
</div>
<UDivider
<USeparator
class="mt-8"
icon="i-ph-pencil-line-duotone"
/>
@@ -126,7 +126,7 @@ async function handleLike() {
:value="post"
class="!max-w-none prose dark:prose-invert"
/>
<UDivider
<USeparator
class="my-16"
icon="i-ph-hands-clapping-duotone"
/>
@@ -142,18 +142,18 @@ async function handleLike() {
<div class="flex gap-4 items-center flex-wrap">
<UButton
:label="postDB?.likes > 1 ? `${postDB?.likes} likes` : `${postDB?.likes} like`"
:color="likeCookie ? 'red' : 'white'"
:color="likeCookie ? 'red' : 'neutral'"
icon="i-ph-heart-duotone"
size="lg"
variant="solid"
:variant="likeCookie ? 'solid' : 'outline'"
@click.prevent="handleLike()"
/>
<UButton
color="white"
color="neutral"
icon="i-ph-arrow-fat-lines-up-duotone"
:label="t('top')"
size="lg"
variant="solid"
variant="outline"
@click.prevent="top()"
/>
<UButton
@@ -167,11 +167,11 @@ async function handleLike() {
/>
<UButton
v-else
color="white"
color="neutral"
icon="i-ph-square-duotone"
:label="t('link.copy')"
size="lg"
variant="solid"
variant="outline"
@click.prevent="copy()"
/>
</div>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { type Tag, TAGS } from '~~/types'
import type { Tag } from '~~/types'
import { TAGS } from '~~/types'
const { t, locale } = useI18n({
useScope: 'local',
@@ -28,8 +29,8 @@ const tags: Array<{ label: string, icon: string } & Tag> = [
...TAGS.filter(tag => tag.title).sort((a, b) => a.label.localeCompare(b.label)),
]
function updateTag(index: number) {
const tag = tags[index]
function updateTag(payload: number | string) {
const tag = tags[Number(payload)]
tagFilter.value = tag?.label.toLowerCase() === 'all' ? undefined : tag?.label.toLowerCase()
}
</script>
@@ -48,7 +49,7 @@ function updateTag(index: number) {
icon="i-ph-warning-duotone"
variant="outline"
/>
<UTabs :items="tags" @change="updateTag">
<UTabs :items="tags" color="neutral" @update:model-value="updateTag">
<template #default="{ item }">
<span class="truncate">{{ t(item.translation) }}</span>
</template>
@@ -60,7 +61,7 @@ function updateTag(index: number) {
:to="writing.path"
>
<li
class=" h-full border p-4 shadow-sm border-neutral-200 rounded-md hover:border-neutral-500 dark:border-neutral-700 dark:hover:border-neutral-500 duration-300"
class=" h-full border p-4 shadow-sm border-neutral-200 rounded-md hover:border-neutral-500 dark:border-neutral-800 dark:hover:border-neutral-600 duration-300"
>
<article class="space-y-2">
<h1
@@ -88,7 +89,7 @@ function updateTag(index: number) {
:color="TAGS.find(color => color.label.toLowerCase() === tag)?.color"
variant="soft"
size="sm"
:ui="{ rounded: 'rounded-full' }"
class="rounded-full"
>
<div class="flex gap-1 items-center">
<UIcon :name="TAGS.find(icon => icon.label.toLowerCase() === tag)?.icon || ''" size="16" />

View File

@@ -38,7 +38,7 @@ const stack = items.value!.filter(item => item.category === 'stack')
/>
</UsesList>
<ul class="space-y-8">
<UDivider
<USeparator
:label="t('ide')"
size="xs"
/>

View File

@@ -1,8 +0,0 @@
export default defineNuxtPlugin(() => {
const event = useRequestEvent()
useState('location', () => ({
latitude: event?.context.cf?.latitude || Math.random() * 180 - 90, // default to random latitude (only in dev)
longitude: event?.context.cf?.longitude || Math.random() * 360 - 180, // default to random longitude (only in dev)
}))
})

View File

@@ -11,6 +11,8 @@ export default defineNuxtConfig({
},
},
css: ['~/assets/css/main.css'],
// Nuxt Modules
modules: [
'@nuxthub/core',
@@ -51,18 +53,30 @@ export default defineNuxtConfig({
// Nuxt UI
ui: {
safelistColors: [
'gray',
'zinc',
'red',
'orange',
'amber',
'green',
'emerald',
'sky',
'blue',
'purple',
],
theme: {
colors: [
'white',
'black',
'cyan',
'gray',
'zinc',
'red',
'orange',
'amber',
'green',
'emerald',
'sky',
'blue',
'purple',
'pink',
'neutral',
],
},
},
// Nuxt Visitors
visitors: {
locations: true,
},
// Nuxt Color Mode
@@ -82,16 +96,19 @@ export default defineNuxtConfig({
strategy: 'no_prefix',
locales: [
{
label: 'English',
code: 'en',
language: 'en-EN',
icon: 'i-twemoji-flag-united-kingdom',
},
{
label: 'Français',
code: 'fr',
language: 'fr-FR',
icon: 'i-twemoji-flag-france',
},
{
label: 'Español',
code: 'es',
language: 'es-ES',
icon: 'i-twemoji-flag-spain',
@@ -103,6 +120,9 @@ export default defineNuxtConfig({
// Nuxt Icon
icon: {
serverBundle: 'remote',
clientBundle: {
scan: true,
},
},
// Nuxt Google Fonts
@@ -118,9 +138,9 @@ export default defineNuxtConfig({
// Nitro
nitro: {
preset: 'cloudflare_durable',
experimental: {
websocket: true,
openAPI: true,
},
},

View File

@@ -16,18 +16,20 @@
"dependencies": {
"@nuxt/content": "3.0.1",
"@nuxt/image": "^1.9.0",
"@nuxt/ui": "^2.21.0",
"@nuxt/ui": "3.0.0-alpha.12",
"@nuxthub/core": "^0.8.15",
"@nuxtjs/google-fonts": "^3.2.0",
"@nuxtjs/i18n": "^8.5.3",
"@nuxtjs/i18n": "9.1.5",
"cobe": "^0.6.3",
"drizzle-orm": "^0.33.0",
"h3-zod": "^0.5.3",
"nuxt": "^3.15.3",
"nuxt": "^3.15.4",
"nuxt-visitors": "^1.1.2",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1",
"vue-use-spring": "^0.3.3",
"zod": "^3.24.1"
},
"devDependencies": {

2325
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,3 @@
import type { BadgeColor } from '#ui/types'
interface WakatimeData {
name: string
percent: number