Skip to content
8 changes: 5 additions & 3 deletions app/components/layout/SiteHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,17 @@ watch(() => route.path, () => {
<AppContainer class="flex h-16 items-center justify-between">
<!-- desktop: Left logo -->
<NuxtLink class="flex h-full items-center text-xl font-bold text-text" to="/">
todde<span class="text-accent">.</span><span class="text-text-muted">tv</span>
<span>todde</span>
<span class="inline-block translate-y-[-0.11em] text-3xl leading-0 text-accent">.</span>
<span class="text-text-muted">tv</span>
</NuxtLink>

<!-- desktop: right menu -->
<nav aria-label="Main navigation" class="hidden h-full sm:flex">
<NuxtLink
v-for="link in links"
:key="link.to"
class="relative flex h-full items-center px-4 text-sm font-medium text-text-muted hover:text-text"
class="relative flex h-full items-center px-4 text-base font-medium text-text-muted hover:text-text"
:class="{
['text-text! after:absolute after:bottom-0'
+ ' after:left-0 after:right-0 after:h-0.5'
Expand Down Expand Up @@ -68,7 +70,7 @@ watch(() => route.path, () => {
<NuxtLink
v-for="link in links"
:key="link.to"
class="py-2 text-sm font-medium text-text-muted hover:text-text"
class="py-2 text-base font-medium text-text-muted hover:text-text"
:class="{ 'text-text!': isActiveLink(link.to) }"
:to="link.to"
>
Expand Down
38 changes: 38 additions & 0 deletions app/composables/useAllTestimonials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
export interface LinkedTestimonial {
quote: string
author: string
role: string
linkTo: string
}

/**
* Aggregates testimonials from projects and talks, sorted newest first.
* Uses talk date or project endDate/startDate for ordering.
*/
export function useAllTestimonials() {
return useAsyncData('all-testimonials', async () => {
const [projects, talks] = await Promise.all([
queryCollection('projects').all(),
queryCollection('talks').all(),
])

const result: (LinkedTestimonial & { _sortDate: string })[] = []

for (const p of projects) {
const sortDate = p.endDate ?? p.startDate
for (const t of p.testimonials ?? []) {
result.push({ ...t, linkTo: p.path, _sortDate: sortDate })
}
}

for (const talk of talks) {
for (const t of talk.testimonials ?? []) {
result.push({ ...t, linkTo: talk.path, _sortDate: talk.date })
}
}

result.sort((a, b) => b._sortDate.localeCompare(a._sortDate))

return result.map(({ _sortDate: _, ...rest }) => rest)
})
}
32 changes: 32 additions & 0 deletions app/composables/useSortedProjects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Fetches all projects and returns them sorted by recency.
* Sort order: ongoing first, then by endDate, startDate, and stars.
* @param limit - Optional max number of projects to return.
*/
export function useSortedProjects(limit?: number) {
const key = limit ? `sorted-projects-${limit}` : 'sorted-projects'

return useAsyncData(key, async () => {
const projects = await queryCollection('projects').all()
const sorted = [...projects].sort((a, b) => {
// 1. Ongoing (no endDate) before completed
const aOngoing = a.endDate == null
const bOngoing = b.endDate == null
if (aOngoing !== bOngoing) return aOngoing ? -1 : 1

// 2. Within completed: newest endDate first
if (!aOngoing && !bOngoing) {
const endCmp = b.endDate!.localeCompare(a.endDate!)
if (endCmp !== 0) return endCmp
}

// 3. Newest startDate first
const startCmp = b.startDate.localeCompare(a.startDate)
if (startCmp !== 0) return startCmp

// 4. More stars first
return (b.repoStars ?? 0) - (a.repoStars ?? 0)
})
return limit ? sorted.slice(0, limit) : sorted
})
}
44 changes: 44 additions & 0 deletions app/error.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { NuxtError } from '#app'

const props = defineProps<{
error: NuxtError
}>()

const statusCode = computed(() => props.error.statusCode ?? 404)
const statusMessage = computed(() =>
statusCode.value === 404
? 'The page you are looking for does not exist or has been moved.'
: props.error.statusMessage ?? 'An unexpected error occurred.',
)

/** Clear the error and navigate back to the homepage. */
function handleBack() {
clearError({ redirect: '/' })
}
</script>

<template>
<NuxtLayout>
<section class="py-16">
<AppContainer class="flex flex-col items-center gap-6 text-center">
<span class="font-mono text-8xl font-bold text-accent">{{ statusCode }}</span>
<h1>
{{ statusCode === 404 ? 'Page Not Found' : 'An Unexpected Error Occurred' }}
</h1>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<p class="max-w-md text-lg">
{{ statusMessage }}
</p>
<button
class="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-border bg-surface
px-4 py-2.5 text-sm font-medium
text-text transition-colors hover:border-accent hover:bg-surface-hover"
@click="handleBack"
>
<Icon name="ph:arrow-left" :size="16" />
Back to Home
</button>
</AppContainer>
</section>
</NuxtLayout>
</template>
201 changes: 201 additions & 0 deletions app/pages/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
<script setup lang="ts">
useSeoMeta({
title: 'Thorsten Seyschab - toddeTV',
description: 'IT consultant, senior full-stack developer, and conference speaker. Specializing in Vue.js, '
+ 'Nuxt, 3D on the web, and full-stack development.',
})

defineOgImageComponent('Home', {
title: 'Thorsten Seyschab',
description: 'IT consultant, senior full-stack developer, and conference speaker.',
})

/**
* High-level skill areas displayed on the landing page.
* Order: consulting & leadership → core engineering → data & infrastructure →
* quality & process → tooling & community → creative / specialty.
*/
const skills = [
'Technical Consulting',
'System Architecture',
'Code Reviews & Mentoring',
'Full-Stack Web Development',
'Frontend Architecture',
'Backend & APIs',
'Database Design',
'Cloud Infrastructure',
'DevOps & CI/CD',
'Testing & Quality Assurance',
'Performance Optimization',
'Migration & Refactoring',
'Agile & Scrum',
'Build Tooling',
'Technical Writing',
'Open Source',
'3D on the Web',
'App Development',
'Game Development',
]

const [
{ data: socials },
{ data: testimonials },
{ data: recentTalks },
{ data: recentProjects },
] = await Promise.all([
useAsyncData('index-socials-all', () =>
queryCollection('socials').where('active', '=', true).order('sortOrder', 'ASC').all(),
),
useAllTestimonials(),
useAsyncData('recent-talks', () =>
queryCollection('talks').order('date', 'DESC').limit(3).all(),
),
useSortedProjects(2),
])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</script>

<template>
<div>
<!-- Hero -->
<section class="pt-24 pb-16 sm:pt-28 sm:pb-20">
<AppContainer class="flex items-center justify-between gap-8 max-sm:flex-col-reverse max-sm:text-center">
<div class="flex-1">
<p class="mb-2 font-mono text-sm text-accent">
Hi, I'm
</p>
<h1 class="mb-3 text-4xl sm:text-5xl">
Thorsten Seyschab
</h1>
<p class="mb-3 text-lg">
IT consultant, senior full-stack developer, and conference speaker
</p>
<p class="flex items-center gap-1.5 text-sm text-text-dim max-sm:justify-center">
<Icon name="ph:map-pin" :size="16" />
Dresden, Germany
</p>
</div>
<div>
<NuxtImg
alt="Thorsten Seyschab"
class="h-45 w-45 rounded-full border-3 border-accent object-cover max-sm:h-35 max-sm:w-35"
height="180"
src="/avatar-thorsten-seyschab.jpg"
width="180"
/>
</div>
</AppContainer>
</section>

<!-- About -->
<AppSeparator />
<AppSection heading="About Me">
<p class="mb-4">
I'm an IT consultant, senior full-stack developer, and conference speaker from Germany,
<strong>self-employed since 2014</strong>. I help companies ship their projects, provide
technical guidance, and build robust applications - from architecture to deployment.
</p>
<p class="mb-4">
My main stack revolves around <strong>web technologies</strong>, modern build tooling, and
<strong>databases</strong>. Beyond classic web development, I have a particular passion for
bringing <strong>3D experiences to the browser</strong>.
</p>
<p class="mb-4">
I hold a <strong>Master's degree in Computer Science</strong> from TUD Dresden University
of Technology, where I contributed to database research published at IEEE ICDE 2016 and
BTW 2017.
</p>
<p>
When I'm not working with clients, you'll find me contributing to
<strong>open-source projects</strong>, giving talks at international conferences like
Vue.js Amsterdam, NuxtNation, ViteConf, and Vue Fes Japan - or tinkering with my
<strong>3D printers</strong>.
</p>
</AppSection>

<!-- Skills -->
<AppSeparator />
<AppSection heading="What I Work With">
<p class="-mt-2 mb-4 text-sm text-text-dim sm:-mt-4">
A selection of areas I frequently work in - not a complete list.
</p>
<div class="flex flex-wrap gap-2">
<AppTag
v-for="skill in skills"
:key="skill"
:label="skill"
size="md"
/>
</div>
</AppSection>

<!-- Connect -->
<AppSeparator />
<AppSection id="connect" heading="Connect">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<AppCard
v-for="social in socials"
:key="social.url"
:href="social.url"
>
<div class="flex items-center gap-3">
<Icon :name="social.icon" :size="24" />
<div>
<div class="text-sm font-medium">
{{ social.name }}
</div>
<div class="font-mono text-xs text-text-dim">
{{ social.handle }}
</div>
</div>
</div>
</AppCard>
</div>
</AppSection>

<!-- Testimonials -->
<AppSeparator v-if="testimonials?.length" />
<AppSection v-if="testimonials?.length" heading="What People Say">
<TestimonialCarousel :testimonials="testimonials.slice(0, 3)" />
</AppSection>

<!-- Recent Talks -->
<AppSeparator v-if="recentTalks?.length" />
<AppSection v-if="recentTalks?.length">
<div class="mb-6 flex items-center justify-between">
<h2>
Recent Talks
</h2>
<NuxtLink
class="flex items-center gap-1.5 text-sm font-medium whitespace-nowrap"
to="/talks"
>
View all talks
<Icon name="ph:arrow-right" :size="16" />
</NuxtLink>
</div>
<div class="flex flex-col gap-4">
<TalkCard v-for="talk in recentTalks" :key="talk.path" :talk="talk" />
</div>
</AppSection>

<!-- Recent Projects -->
<AppSeparator v-if="recentProjects?.length" />
<AppSection v-if="recentProjects?.length">
<div class="mb-6 flex items-center justify-between">
<h2>
Recent Projects
</h2>
<NuxtLink
class="flex items-center gap-1.5 text-sm font-medium whitespace-nowrap"
to="/projects"
>
View all projects
<Icon name="ph:arrow-right" :size="16" />
</NuxtLink>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<ProjectCard v-for="project in recentProjects" :key="project.path" :project="project" />
</div>
</AppSection>
</div>
</template>
23 changes: 1 addition & 22 deletions app/pages/projects/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,7 @@ defineOgImageComponent('Project', {
description: 'Tools, experiments, and applications for 3D on the web, Vue/Nuxt, and developer tooling.',
})

const { data: projects } = await useAsyncData('projects', async () => {
const projects = await queryCollection('projects').all()
return projects.sort((a, b) => {
// 1. Ongoing (no endDate) before completed
const aOngoing = a.endDate == null
const bOngoing = b.endDate == null
if (aOngoing !== bOngoing) return aOngoing ? -1 : 1

// 2. Within completed: newest endDate first
if (!aOngoing && !bOngoing) {
const endCmp = b.endDate!.localeCompare(a.endDate!)
if (endCmp !== 0) return endCmp
}

// 3. Newest startDate first
const startCmp = b.startDate.localeCompare(a.startDate)
if (startCmp !== 0) return startCmp

// 4. More stars first
return (b.repoStars ?? 0) - (a.repoStars ?? 0)
})
})
const { data: projects } = await useSortedProjects()
</script>

<template>
Expand Down
Loading