feat: réorganiser les composants de la page d'accueil et améliorer l'affichage des statistiques

This commit is contained in:
2025-12-23 22:54:23 +01:00
parent 0e70996a3a
commit 466baf1eb8
8 changed files with 407 additions and 184 deletions

View File

@@ -2,154 +2,124 @@
import type { Activity } from '~~/types'
import { IDEs } from '~~/types'
const { data: activity, refresh } = await useAsyncData<Activity>('activity', () => $fetch('/api/activity'),
{ lazy: true }
)
const { data: activity, refresh } = await useAsyncData<Activity>('activity', () => $fetch('/api/activity'), { lazy: true })
useIntervalFn(refresh, 5000)
useIntervalFn(refresh, 1000)
const codingActivities = computed(() => {
const currentSession = computed(() => {
const list = activity.value?.data.activities ?? []
return list
.filter(a => IDEs.some(ide => ide.name === a.name))
.map(a => ({ ...a, name: a.assets?.small_text === 'Cursor' ? 'Cursor' : a.name }))
})
const ideActivity = list.find(a => IDEs.some(ide => ide.name === a.name))
const codingActivity = computed(() => {
if (!codingActivities.value.length) return null
return codingActivities.value.length > 1
? codingActivities.value[Math.floor(Math.random() * codingActivities.value.length)]
: codingActivities.value[0]
})
if (!ideActivity) return null
const isActive = computed(() => {
const act = codingActivity.value
if (!act) return false
const name = ideActivity.assets?.small_text === 'Cursor' ? 'Cursor' : ideActivity.name
const { name, details = '', state = '' } = act
const isIdling = ideActivity.details?.includes('Idling') || (!ideActivity.state?.toLowerCase().includes('editing') && name !== 'Visual Studio Code')
if (name === 'Visual Studio Code' || name === 'Cursor')
return !details.includes('Idling')
return state.toLowerCase().includes('editing')
})
type FormattedActivity = {
name: string
project: string
state: string
start: {
ago: string
formatted: { date: string, time: string }
}
} | null
const formattedActivity = computed<FormattedActivity>(() => {
const act = codingActivity.value
if (!act) return null
const { name, details = '', state = '', timestamps } = act
const project = details
? (details.charAt(0).toUpperCase() + details.slice(1).replace('Workspace:', '').trim())
: ''
const stateWord = (state && state.split(' ').length >= 2 ? state.split(' ')[1] : 'Secret project') as string
const ago = useTimeAgo(timestamps.start).value
const formatDate = (date: number, format: string) =>
useDateFormat(date, format).value
const rawProject = ideActivity.details ? ideActivity.details.replace('Workspace:', '').replace('Editing', '').trim() : 'Unknown Context'
const project = rawProject.charAt(0).toUpperCase() + rawProject.slice(1)
const file = ideActivity.state?.replace('Editing', '').trim() || 'No active file'
return {
name,
project,
state: stateWord,
start: {
ago,
formatted: {
date: formatDate(timestamps.start, 'DD MMM YYYY'),
time: formatDate(timestamps.start, 'HH:mm')
}
}
file,
isIdling,
startTime: ideActivity.timestamps?.start,
icon: IDEs.find(ide => ide.name === name)?.icon ?? 'i-ph-code-duotone'
}
})
const editorIcon = computed(() => {
const name = formattedActivity.value?.name ?? codingActivity.value?.name
return IDEs.find(ide => ide.name === name)?.icon ?? 'file'
const timeAgo = useTimeAgo(computed(() => currentSession.value?.startTime ?? new Date()))
const statusColor = computed(() => {
if (!currentSession.value) return 'red'
return currentSession.value.isIdling ? 'orange' : 'green'
})
const statusLabel = computed(() => {
if (!currentSession.value) return 'System Offline'
if (currentSession.value.isIdling) return 'System Idling'
return 'Active Development'
})
</script>
<template>
<ClientOnly>
<div
v-if="formattedActivity"
class="flex items-start gap-2 mt-4"
>
<UTooltip :text="isActive ? 'I\'m online 👋' : 'I\'m sleeping 😴'">
<div class="relative flex h-3 w-3 mt-2">
<div
v-if="isActive"
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"
<div class="w-full mb-4">
<UCard v-if="activity">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-3">
<div class="relative flex h-3 w-3">
<span
v-if="statusColor === 'green'"
class="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 bg-green-400"
/>
<span
class="relative inline-flex rounded-full h-3 w-3 transition-colors duration-300"
:class="{
'bg-green-500': statusColor === 'green',
'bg-orange-500': statusColor === 'orange',
'bg-red-500': statusColor === 'red'
}"
/>
</div>
<span class="text-xs font-bold uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
{{ statusLabel }}
</span>
</div>
<UIcon
v-if="currentSession"
:name="currentSession.icon"
class="w-5 h-5 opacity-80"
/>
<div
:class="isActive ? 'bg-green-500' : 'bg-amber-500'"
class="relative inline-flex rounded-full h-3 w-3"
<UIcon
v-else
name="i-ph-power-duotone"
class="w-5 h-5 text-red-400 opacity-80"
/>
</div>
</UTooltip>
<div
v-if="isActive"
class="space-x-1"
>
<span>
I'm actually working on
<strong>{{ formattedActivity.state.split(' ').map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(' ') }}</strong>,
editing <i>{{ formattedActivity.project.replace('Editing', '') }}</i>, using
<span class="space-x-1">
<div
v-if="currentSession"
class="space-y-1 pl-6 border-l-2 border-neutral-100 dark:border-neutral-800 ml-1.5"
>
<div class="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
<h3 class="font-semibold text-neutral-900 dark:text-white truncate">
{{ currentSession.project }}
</h3>
<span class="hidden sm:inline text-neutral-400 text-xs"></span>
<span class="text-sm text-neutral-500 dark:text-neutral-400 truncate">
{{ currentSession.file }}
</span>
</div>
<div class="flex items-center gap-2 text-xs text-neutral-400 mt-1">
<UIcon
:name="editorIcon"
size="16"
name="i-ph-timer-duotone"
class="w-4 h-4"
/>
<strong>{{ formattedActivity.name }}</strong>
</span>.
I've started <strong>{{ formattedActivity.start.ago }}</strong>, on
<strong>{{ formattedActivity.start.formatted.date }}</strong>
at <strong>{{ formattedActivity.start.formatted.time }}</strong>.
</span>
</div>
<div
v-else
class="space-x-1"
>
<span>
I'm idling on my computer with
<span class="space-x-1">
<UIcon
:name="editorIcon"
size="16"
/>
<strong>{{ formattedActivity.name }}</strong>
</span>
running in background.
</span>
</div>
</div>
<div
v-else
class="my-5 flex md:items-start gap-2"
>
<UTooltip text="I'm offline 🫥">
<div class="relative flex h-3 w-3 mt-2">
<div class="relative cursor-not-allowed inline-flex rounded-full h-3 w-3 bg-red-500" />
<span>Started {{ timeAgo }}</span>
</div>
</div>
</UTooltip>
<p class="not-prose">
I'm currently offline. Come back later to see what I'm working on. <i>I am probably doing some maths or sleeping.</i>
</p>
<div
v-else
class="text-sm text-neutral-500 dark:text-neutral-400 flex items-center gap-2 pl-6 border-l-2 border-red-100 dark:border-red-900/30 ml-1.5"
>
<p>Telemetry disconnected. Research in progress.</p>
</div>
</UCard>
<UCard v-else>
<div class="flex items-center gap-3">
<USkeleton class="h-3 w-3 rounded-full" /> <div class="space-y-2 flex-1">
<USkeleton class="h-4 w-1/3" />
<USkeleton class="h-3 w-2/3" />
</div>
</div>
</UCard>
</div>
</ClientOnly>
</template>

View File

@@ -1,18 +1,11 @@
<script lang="ts" setup>
const { width } = useWindowSize()
</script>
<template>
<ClientOnly>
<div
v-if="width > 1024"
class="text-[12px] italic flex items-center gap-1 mt-4"
>
<UIcon
class="transform -rotate-12 duration-300 animate-wave"
name="i-ph-hand-pointing-duotone"
/>
<p>Hover the bold texts to find out more about me.</p>
</div>
</ClientOnly>
<div class="hidden lg:flex items-center gap-2 mt-6 px-3 py-2 rounded-lg bg-neutral-50 dark:bg-neutral-800/50 border border-neutral-100 dark:border-neutral-800 w-fit transition-opacity hover:opacity-100 opacity-70">
<UIcon
name="i-ph-hand-pointing-duotone"
class="w-5 h-5 text-primary-500 animate-wave origin-bottom"
/>
<p class="text-xs font-medium text-neutral-500 dark:text-neutral-400">
Tip: Hover over the <span class="font-bold text-neutral-900 dark:text-neutral-200">bold text</span> to reveal more details.
</p>
</div>
</template>

View File

@@ -1,19 +1,32 @@
<template>
<div class="mt-4">
<div class="float-left flex items-center mr-2 mt-1">
<UCard>
<div class="flex items-start sm:items-center gap-4">
<ClientOnly>
<UTooltip text="It's me 👋">
<UTooltip
text="Arthur Danjou • Research & Engineering"
:popper="{ placement: 'top' }"
>
<UAvatar
alt="Avatar"
class="hover:rotate-360 duration-500 transform-gpu rounded-full"
size="xl"
src="/arthur.webp"
alt="Arthur Danjou"
size="xl"
class="ring-2 ring-primary-500/20 dark:ring-primary-400/20 transition-transform duration-700 ease-in-out hover:rotate-360 shadow-sm"
/>
</UTooltip>
</ClientOnly>
<div class="space-y-1">
<h3 class="font-semibold text-neutral-900 dark:text-white flex items-center gap-2">
Let's start a discussion
<UIcon
name="i-ph-chat-circle-dots-duotone"
class="text-primary-500 w-5 h-5"
/>
</h3>
<p class="text-sm text-neutral-600 dark:text-neutral-300 leading-relaxed">
Thanks for stopping by my digital garden! Whether you have a question about a theorem, a suggestion for a project, or just want to say hi, I'd love to hear from you.
</p>
</div>
</div>
<p class="not-prose">
Hello everyone! Thanks for visiting my portfolio. Please leave whatever you like to say, such as suggestions, appreciations, questions or anything!
</p>
</div>
</UCard>
</template>

View File

@@ -20,11 +20,13 @@ const { skills } = await useContent()
:key="skill.id"
>
<div class>
<h3 class="text-xl md:text-2xl font-semibold tracking-tight mb-4">
<h3 class="text-xl md:text-2xl font-semibold tracking-tight">
{{ skill.name }}
</h3>
<div class="flex flex-wrap gap-2">
<p class="text-sm text-neutral-600 dark:text-neutral-400">
{{ skill.description }}
</p>
<div class="flex flex-wrap gap-2 mt-4">
<UBadge
v-for="item in skill.items"
:key="item.name"

View File

@@ -4,38 +4,162 @@ import { usePrecision } from '@vueuse/math'
const { data: stats } = await useAsyncData<Stats>('stats', () => $fetch('/api/stats'))
const time = useTimeAgo(new Date(stats.value?.coding.range.start ?? 0)).value.split(' ')[0]
const date = useDateFormat(new Date(stats.value?.coding.range.start ?? 0), 'DD MMMM YYYY')
const hours = usePrecision((stats.value?.coding.grand_total.total_seconds_including_other_language ?? 0) / 3600, 0)
const startDate = computed(() => stats.value?.coding.range.start ? new Date(stats.value.coding.range.start) : new Date())
const yearsCollected = useTimeAgo(startDate).value
const formattedDate = useDateFormat(startDate, 'MMM DD, YYYY').value
const editors = computed(() => stats.value?.editors.slice(0, 3).map(editor => `${editor.name} (${editor.percent}%)`).join(', '))
const os = computed(() => stats.value?.os.slice(0, 2).map(os => `${os.name} (${os.percent}%)`).join(', '))
const languages = computed(() => stats.value?.languages.slice(0, 3).map(language => `${language.name} (${language.percent}%)`).join(', '))
const totalHours = usePrecision((stats.value?.coding.grand_total.total_seconds_including_other_language ?? 0) / 3600, 0)
const topLanguages = computed(() => stats.value?.languages.slice(0, 4) || [])
const topEditors = computed(() => stats.value?.editors.slice(0, 3) || [])
const topOS = computed(() => stats.value?.os.slice(0, 2) || [])
</script>
<template>
<ClientOnly>
<div
v-if="time && date && hours && stats"
class="space-y-1"
v-if="stats"
class="space-y-6"
>
<p>
I collect some data for {{ time }} years, started the
<HoverText
hover="That was so long ago 🫣"
:text="date"
/>.
I've coded for a total of
<HoverText
hover="That's a lot 😮"
:text="hours"
/>
hours.
</p>
<p>
My best editors are {{ editors || 'N/A' }}. My best OS is {{ os || 'N/A' }}. My top languages are
{{ languages || 'N/A' }}.
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<UCard>
<div class="flex items-center gap-4">
<div class="p-3 bg-primary-50 dark:bg-primary-900/20 rounded-lg text-primary-500">
<UIcon
name="i-ph-clock-duotone"
class="w-8 h-8"
/>
</div>
<div>
<p class="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
Total Coding Time
</p>
<h3 class="text-3xl font-bold font-mono text-neutral-900 dark:text-white">
{{ totalHours }} <span class="text-lg text-neutral-500 font-normal">hours</span>
</h3>
</div>
</div>
</UCard>
<UCard>
<div class="flex items-center gap-4">
<div class="p-3 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg text-emerald-500">
<UIcon
name="i-ph-calendar-check-duotone"
class="w-8 h-8"
/>
</div>
<div>
<p class="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
Data Collected Since
</p>
<div class="flex items-baseline gap-2">
<h3 class="text-xl font-bold text-neutral-900 dark:text-white">
{{ formattedDate }}
</h3>
<UBadge
color="neutral"
variant="soft"
size="xs"
>
{{ yearsCollected }}
</UBadge>
</div>
</div>
</div>
</UCard>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div class="col-span-1 lg:col-span-1 space-y-4">
<h4 class="text-sm font-semibold text-neutral-900 dark:text-white flex items-center gap-2">
<UIcon
name="i-ph-code-block-duotone"
class="text-primary-500 w-5 h-5"
/>
Top Languages
</h4>
<div class="space-y-3">
<div
v-for="lang in topLanguages"
:key="lang.name"
class="space-y-1"
>
<div class="flex justify-between text-xs text-neutral-600 dark:text-neutral-300">
<span>{{ lang.name }}</span>
<span>{{ lang.percent }}%</span>
</div>
<UMeter
:value="lang.percent"
color="primary"
size="sm"
/>
</div>
</div>
</div>
<div class="col-span-1 lg:col-span-1 space-y-4">
<h4 class="text-sm font-semibold text-neutral-900 dark:text-white flex items-center gap-2">
<UIcon
name="i-ph-terminal-window-duotone"
class="text-orange-500 w-5 h-5"
/>
Preferred Editors
</h4>
<div class="space-y-3">
<div
v-for="editor in topEditors"
:key="editor.name"
class="space-y-1"
>
<div class="flex justify-between text-xs text-neutral-600 dark:text-neutral-300">
<span>{{ editor.name }}</span>
<span>{{ editor.percent }}%</span>
</div>
<UMeter
:value="editor.percent"
color="orange"
size="sm"
/>
</div>
</div>
</div>
<div class="col-span-1 lg:col-span-1 space-y-4">
<h4 class="text-sm font-semibold text-neutral-900 dark:text-white flex items-center gap-2">
<UIcon
name="i-ph-desktop-duotone"
class="text-blue-500 w-5 h-5"
/>
Operating Systems
</h4>
<div class="space-y-3">
<div
v-for="osItem in topOS"
:key="osItem.name"
class="space-y-1"
>
<div class="flex justify-between text-xs text-neutral-600 dark:text-neutral-300">
<span>{{ osItem.name }}</span>
<span>{{ osItem.percent }}%</span>
</div>
<UMeter
:value="osItem.percent"
color="blue"
size="sm"
/>
</div>
</div>
</div>
</div>
</div>
<div
v-else
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<USkeleton class="h-24 w-full" />
<USkeleton class="h-24 w-full" />
</div>
</ClientOnly>
</template>

View File

@@ -0,0 +1,64 @@
<script lang="ts" setup>
import type { TimelineItem } from '@nuxt/ui'
const { education } = await useContent()
const { width } = useWindowSize()
const orientation = computed<'vertical' | 'horizontal'>(() =>
width.value >= 768 ? 'horizontal' : 'vertical'
)
const formatDate = (start?: string, end?: string, duration?: string) => {
if (!start) return 'N/A'
const startYear = new Date(start).getFullYear()
const endYear = end ? new Date(end).getFullYear() : 'Present'
const durationText = duration ? `(${duration})` : ''
if (startYear === endYear) {
return `${startYear} ${durationText}`
}
return `${startYear} - ${endYear} ${durationText}`
}
const items = computed<TimelineItem[]>(() => {
if (!education) return []
return [...education]
.sort((a, b) => (a.startDate || '').localeCompare(b.startDate || ''))
.map(item => ({
title: item.title || 'Degree',
description: item.institution || '',
date: formatDate(item.startDate, item.endDate, item.duration),
icon: item.icon || 'i-ph-graduation-cap-duotone' // Context-aware default icon
}))
})
</script>
<template>
<ClientOnly>
<div class="w-full flex justify-center">
<UTimeline
:orientation="orientation"
:items="items"
:default-value="items.length"
active-color="primary"
color="neutral"
class="w-full max-w-5xl"
/>
</div>
<template #fallback>
<div class="flex flex-col gap-8 w-full max-w-5xl mx-auto pl-4 border-l border-neutral-200 dark:border-neutral-800">
<div
v-for="i in 3"
:key="i"
class="space-y-2"
>
<USkeleton class="h-4 w-1/4" />
<USkeleton class="h-4 w-1/2" />
</div>
</div>
</template>
</ClientOnly>
</template>

View File

@@ -0,0 +1,64 @@
<script lang="ts" setup>
import type { TimelineItem } from '@nuxt/ui'
const { experiences } = await useContent()
const { width } = useWindowSize()
const orientation = computed<'vertical' | 'horizontal'>(() =>
width.value >= 768 ? 'horizontal' : 'vertical'
)
const formatDate = (start?: string, end?: string, duration?: string) => {
if (!start) return 'N/A'
const startYear = new Date(start).getFullYear()
const endYear = end ? new Date(end).getFullYear() : 'Present'
const durationText = duration ? `(${duration})` : ''
if (startYear === endYear) {
return `${startYear} ${durationText}`
}
return `${startYear} - ${endYear} ${durationText}`
}
const items = computed<TimelineItem[]>(() => {
if (!experiences) return []
return [...experiences]
.sort((a, b) => (a.startDate || '').localeCompare(b.startDate || ''))
.map(item => ({
title: item.title || 'Role',
description: item.company || 'Freelance',
date: formatDate(item.startDate, item.endDate, item.duration),
icon: item.icon || 'i-ph-briefcase-duotone'
}))
})
</script>
<template>
<ClientOnly>
<div class="w-full flex justify-center">
<UTimeline
:orientation="orientation"
:items="items"
:default-value="items.length"
active-color="primary"
color="neutral"
class="w-full max-w-5xl"
/>
</div>
<template #fallback>
<div class="flex flex-col gap-8 w-full max-w-5xl mx-auto pl-4 border-l border-neutral-200 dark:border-neutral-800">
<div
v-for="i in 3"
:key="i"
class="space-y-2"
>
<USkeleton class="h-4 w-1/4" />
<USkeleton class="h-4 w-1/2" />
</div>
</div>
</template>
</ClientOnly>
</template>

View File

@@ -16,12 +16,5 @@ const { data: page } = await useAsyncData('index', () => {
:value="page"
class="mt-8 md:mt-16"
/>
<HomeSkills />
<HomeEducation />
<HomeExperiences />
<HomeStats />
<HomeActivity />
<HomeQuote />
<HomeCatchPhrase />
</main>
</template>