mirror of
https://github.com/ArthurDanjou/artsite.git
synced 2026-01-14 15:54:13 +01:00
feat: réorganiser les composants de la page d'accueil et améliorer l'affichage des statistiques
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
64
app/components/home/timeline/Education.vue
Normal file
64
app/components/home/timeline/Education.vue
Normal 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>
|
||||
64
app/components/home/timeline/Experiences.vue
Normal file
64
app/components/home/timeline/Experiences.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user