feat: add projects page and rework home page

This commit is contained in:
2026-07-09 16:24:51 +02:00
parent 8aa5aca650
commit 834d7ed00f
32 changed files with 720 additions and 77 deletions
+23 -23
View File
@@ -1,5 +1,5 @@
:root {
--text: oklch(38.30% 0.029 266.48);
--text: oklch(38.3% 0.029 266.48);
--text-50: oklch(95.82% 0.004 271.37);
--text-100: oklch(91.83% 0.009 264.52);
--text-200: oklch(83.53% 0.016 266.26);
@@ -8,30 +8,30 @@
--text-500: oklch(57.02% 0.047 267.31);
--text-600: oklch(48.66% 0.039 268.21);
--text-700: oklch(40.13% 0.031 265.23);
--text-800: oklch(30.90% 0.021 265.90);
--text-800: oklch(30.9% 0.021 265.9);
--text-900: oklch(20.86% 0.013 264.25);
--text-950: oklch(15.46% 0.007 270.96);
--background: oklch(95.13% 0.007 260.73);
--background-50: oklch(95.80% 0.007 268.55);
--background-50: oklch(95.8% 0.007 268.55);
--background-100: oklch(91.74% 0.012 259.82);
--background-200: oklch(83.07% 0.027 262.33);
--background-300: oklch(74.46% 0.041 261.48);
--background-400: oklch(65.63% 0.058 260.56);
--background-500: oklch(56.42% 0.075 261.41);
--background-600: oklch(48.32% 0.062 260.40);
--background-600: oklch(48.32% 0.062 260.4);
--background-700: oklch(39.64% 0.048 261.18);
--background-800: oklch(30.43% 0.036 261.92);
--background-900: oklch(20.77% 0.018 259.72);
--background-950: oklch(15.29% 0.010 255.44);
--background-950: oklch(15.29% 0.01 255.44);
--primary: oklch(77.15% 0.062 217.48);
--primary-50: oklch(96.50% 0.009 222.06);
--primary-50: oklch(96.5% 0.009 222.06);
--primary-100: oklch(93.16% 0.019 213.42);
--primary-200: oklch(86.07% 0.039 217.46);
--primary-300: oklch(79.25% 0.057 216.55);
--primary-400: oklch(72.48% 0.075 217.32);
--primary-500: oklch(65.88% 0.089 218.00);
--primary-500: oklch(65.88% 0.089 218);
--primary-600: oklch(55.99% 0.075 218.52);
--primary-700: oklch(45.64% 0.059 218.22);
--primary-800: oklch(34.67% 0.043 219.39);
@@ -41,7 +41,7 @@
--secondary: oklch(69.65% 0.059 248.69);
--secondary-50: oklch(95.95% 0.008 253.85);
--secondary-100: oklch(92.05% 0.015 244.73);
--secondary-200: oklch(83.76% 0.030 248.19);
--secondary-200: oklch(83.76% 0.03 248.19);
--secondary-300: oklch(75.31% 0.048 249.46);
--secondary-400: oklch(66.99% 0.065 248.83);
--secondary-500: oklch(58.35% 0.083 249.96);
@@ -51,16 +51,16 @@
--secondary-900: oklch(20.99% 0.022 251.79);
--secondary-950: oklch(15.56% 0.012 241.97);
--accent: oklch(59.38% 0.078 253.40);
--accent-50: oklch(95.93% 0.007 247.90);
--accent: oklch(59.38% 0.078 253.4);
--accent-50: oklch(95.93% 0.007 247.9);
--accent-100: oklch(91.85% 0.015 251.16);
--accent-200: oklch(83.39% 0.030 254.70);
--accent-200: oklch(83.39% 0.03 254.7);
--accent-300: oklch(74.95% 0.046 253.67);
--accent-400: oklch(66.37% 0.064 253.29);
--accent-500: oklch(57.47% 0.081 254.47);
--accent-600: oklch(49.19% 0.068 253.56);
--accent-700: oklch(40.31% 0.053 254.02);
--accent-800: oklch(30.91% 0.038 255.00);
--accent-800: oklch(30.91% 0.038 255);
--accent-900: oklch(20.99% 0.022 251.79);
--accent-950: oklch(15.35% 0.012 260.39);
}
@@ -68,7 +68,7 @@
--text: oklch(76.63% 0.024 266.86);
--text-50: oklch(15.46% 0.007 270.96);
--text-100: oklch(20.86% 0.013 264.25);
--text-200: oklch(30.90% 0.021 265.90);
--text-200: oklch(30.9% 0.021 265.9);
--text-300: oklch(40.13% 0.031 265.23);
--text-400: oklch(48.66% 0.039 268.21);
--text-500: oklch(57.02% 0.047 267.31);
@@ -79,17 +79,17 @@
--text-950: oklch(95.82% 0.004 271.37);
--background: oklch(16.29% 0.012 260.61);
--background-50: oklch(15.29% 0.010 255.44);
--background-50: oklch(15.29% 0.01 255.44);
--background-100: oklch(20.77% 0.018 259.72);
--background-200: oklch(30.43% 0.036 261.92);
--background-300: oklch(39.64% 0.048 261.18);
--background-400: oklch(48.32% 0.062 260.40);
--background-400: oklch(48.32% 0.062 260.4);
--background-500: oklch(56.42% 0.075 261.41);
--background-600: oklch(65.63% 0.058 260.56);
--background-700: oklch(74.46% 0.041 261.48);
--background-800: oklch(83.07% 0.027 262.33);
--background-900: oklch(91.74% 0.012 259.82);
--background-950: oklch(95.80% 0.007 268.55);
--background-950: oklch(95.8% 0.007 268.55);
--primary: oklch(48.89% 0.064 217.48);
--primary-50: oklch(16.48% 0.015 212.62);
@@ -97,12 +97,12 @@
--primary-200: oklch(34.67% 0.043 219.39);
--primary-300: oklch(45.64% 0.059 218.22);
--primary-400: oklch(55.99% 0.075 218.52);
--primary-500: oklch(65.88% 0.089 218.00);
--primary-500: oklch(65.88% 0.089 218);
--primary-600: oklch(72.48% 0.075 217.32);
--primary-700: oklch(79.25% 0.057 216.55);
--primary-800: oklch(86.07% 0.039 217.46);
--primary-900: oklch(93.16% 0.019 213.42);
--primary-950: oklch(96.50% 0.009 222.06);
--primary-950: oklch(96.5% 0.009 222.06);
--secondary: oklch(47.12% 0.064 249.33);
--secondary-50: oklch(15.56% 0.012 241.97);
@@ -113,20 +113,20 @@
--secondary-500: oklch(58.35% 0.083 249.96);
--secondary-600: oklch(66.99% 0.065 248.83);
--secondary-700: oklch(75.31% 0.048 249.46);
--secondary-800: oklch(83.76% 0.030 248.19);
--secondary-800: oklch(83.76% 0.03 248.19);
--secondary-900: oklch(92.05% 0.015 244.73);
--secondary-950: oklch(95.95% 0.008 253.85);
--accent: oklch(55.80% 0.080 254.61);
--accent: oklch(55.8% 0.08 254.61);
--accent-50: oklch(15.35% 0.012 260.39);
--accent-100: oklch(20.99% 0.022 251.79);
--accent-200: oklch(30.91% 0.038 255.00);
--accent-200: oklch(30.91% 0.038 255);
--accent-300: oklch(40.31% 0.053 254.02);
--accent-400: oklch(49.19% 0.068 253.56);
--accent-500: oklch(57.47% 0.081 254.47);
--accent-600: oklch(66.37% 0.064 253.29);
--accent-700: oklch(74.95% 0.046 253.67);
--accent-800: oklch(83.39% 0.030 254.70);
--accent-800: oklch(83.39% 0.03 254.7);
--accent-900: oklch(91.85% 0.015 251.16);
--accent-950: oklch(95.93% 0.007 247.90);
--accent-950: oklch(95.93% 0.007 247.9);
}
+1
View File
@@ -2,6 +2,7 @@
@import './colors.css';
@import './ui/index.css';
@import './tailwind.css';
@import './typography.css';
@source "../../../content/**/*";
+1 -1
View File
@@ -80,6 +80,6 @@
Noto Sans, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--font-title:
Wittgenstein, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
Montserrat, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
}
+19
View File
@@ -0,0 +1,19 @@
@layer base {
body {
font-family:
'Noto Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
font-weight: 400;
}
h1,
h2,
h3,
h4,
h5 {
font-family:
'Montserrat', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
font-weight: 700;
}
}
+9
View File
@@ -0,0 +1,9 @@
.hero-image {
max-width: 7rem;
height: auto;
display: block;
}
.centered-image {
margin: 0 auto;
}
+1
View File
@@ -2,3 +2,4 @@
@import './text.css';
@import './background.css';
@import './border.css';
@import './images.css';
+3 -1
View File
@@ -13,6 +13,8 @@
</template>
<script setup lang="ts">
import type { NavigationMenuItem } from '@nuxt/ui';
const route = useRoute();
const items = computed<NavigationMenuItem[]>(() => [
{
@@ -20,7 +22,7 @@ const items = computed<NavigationMenuItem[]>(() => [
to: '/',
active: route.path == '/',
},
...['resume', 'vocal-synthesis', 'languages', 'contact'].map((page) => ({
...['resume', 'projects', 'vocal-synthesis', 'languages', 'contact'].map((page) => ({
label: $t(`pages.${page}.name`),
to: `/${page}`,
active: route.path.startsWith(`/${page}`),
+32
View File
@@ -0,0 +1,32 @@
<template>
<div class="flex">
<UButton :icon="icon" target="_blank" :to="link.url">
<span>{{ link.display }}</span>
<span v-if="link.type === 'cargo'"><UIcon name="i-mdi-download" /> {{ crateDownloads }}</span>
</UButton>
</div>
</template>
<script setup lang="ts">
import type { ProjectLink, ProjectLinkType } from '~/types/project';
const crates = useCrates();
const iconDict: Record<ProjectLinkType, string> = {
github: 'mdi:github',
gitea: 'simple-icons:gitea',
cargo: 'simple-icons:rust',
melpa: 'file-icons:melpa',
};
const icon = computed(() => iconDict[link.type]);
const { link } = defineProps<{
link: ProjectLink;
}>();
const cratesData = computed(() => (link.type === 'cargo' ? crates.getCrateInfo(link.display) : null));
const crateDownloads = computed(
() => cratesData.value?.data?.value?.crate.downloads ?? (cratesData.value?.loading ? 'loading' : 'error'),
);
</script>
+23
View File
@@ -0,0 +1,23 @@
<template>
<UPageCard class="w-md bg-background-100">
<div class="text-2xl font-title">{{ project.name }}</div>
<p>
{{ project.description }}
</p>
<div class="flex flex-row flex-wrap gap-2 items-baseline">
<UiProjectLink v-for="link in project.links" :key="link.url" :link="link" />
</div>
<div class="flex flex-row gap-4 items-baseline">
<div>Tools</div>
<UiBadgeList :tools="project.tools" />
</div>
</UPageCard>
</template>
<script lang="ts" setup>
import type { Project } from '~/types/project';
const { project } = defineProps<{
project: Project;
}>();
</script>
+1 -1
View File
@@ -4,7 +4,7 @@
{{ $t('pages.vocal-synthesis.projects') }}
</p>
<div class="flex flex-col max-w gap-10">
<div v-for="project in data?.projects" :key="project.title" class="flex flex-row max-w gap-5">
<div v-for="project in data?.projects" :key="project.title" class="flex flex-row max-w gap-5 font-title">
<div>
<div
class="bg-primary text-text-50 dark:bg-primary p-1 rounded-md min-w-13 w-13 h-13 min-h-13 flex justify-center my-2"
+1 -1
View File
@@ -6,7 +6,7 @@
<script setup lang="ts">
type Theme = 'light' | 'dark' | 'system';
const icons: Dictionary<Theme, string> = {
const icons: Record<Theme, string> = {
light: 'material-symbols:light-mode',
dark: 'material-symbols:dark-mode',
system: 'material-symbols:computer-outline',
+21 -16
View File
@@ -13,12 +13,17 @@ export interface UseApi {
patch: <T, B = unknown>(path: string, opts?: FetchOptions, immediate?: boolean, body?: B) => UseApiResponse<T, B>;
}
export interface CreateRequestOptions<PayloadT> {
host?: string;
path: string;
immediate?: boolean;
method: HttpMethod;
body?: PayloadT;
opts?: FetchOptions;
}
const createRequest = <ResponseT = unknown, PayloadT = unknown>(
method: HttpMethod,
url: string,
opts?: FetchOptions,
immediate: boolean = true,
body?: PayloadT,
options: CreateRequestOptions<PayloadT>,
): QueryResult<ResponseT, PayloadT> => {
const response = new QueryResult<ResponseT, PayloadT>();
const { apiBase } = useRuntimeConfig().public;
@@ -28,10 +33,10 @@ const createRequest = <ResponseT = unknown, PayloadT = unknown>(
response.error.value = null;
try {
const res = await $fetch<ResponseT>(url, {
baseURL: apiBase,
...opts,
method,
const res = await $fetch<ResponseT>(options.path, {
...options.opts,
baseURL: options.host ?? apiBase,
method: options.method,
body: requestBody ?? undefined,
});
response.data.value = res;
@@ -48,22 +53,22 @@ const createRequest = <ResponseT = unknown, PayloadT = unknown>(
};
response.run = run;
if (immediate) run(body);
if (options.immediate) run(options.body);
return response;
};
export const useApi = (): UseApi => {
export const useApi = (host?: string): UseApi => {
const get = <T>(path: string, opts?: FetchOptions, immediate: boolean = true) =>
createRequest<T>('GET', path, opts, immediate);
createRequest<T>({ method: 'GET', path, opts, immediate, host });
const del = <T>(path: string, opts?: FetchOptions, immediate: boolean = true) =>
createRequest<T>('DELETE', path, opts, immediate);
createRequest<T>({ method: 'DELETE', path, opts, immediate, host });
const post = <T, B = unknown>(path: string, opts?: FetchOptions, immediate: boolean = true, body?: B) =>
createRequest<T, B>('POST', path, opts, immediate, body);
createRequest<T, B>({ method: 'POST', path, opts, immediate, body, host });
const put = <T, B = unknown>(path: string, opts?: FetchOptions, immediate: boolean = true, body?: B) =>
createRequest<T, B>('PUT', path, opts, immediate, body);
createRequest<T, B>({ method: 'PUT', path, opts, immediate, body, host });
const patch = <T, B = unknown>(path: string, opts?: FetchOptions, immediate: boolean = true, body?: B) =>
createRequest<T, B>('PATCH', path, opts, immediate, body);
createRequest<T, B>({ method: 'PATCH', path, opts, immediate, body, host });
return { get, post, put, patch, del };
};
+8
View File
@@ -0,0 +1,8 @@
import type { CrateMeta } from '~/types/crate';
export const useCrates = () => {
const api = useApi('https://crates.io/api/v1/');
const getCrateInfo = (name: string): UseApiResponse<CrateMeta> => api.get<CrateMeta>(`crates/${name}`);
return { getCrateInfo };
};
+10
View File
@@ -0,0 +1,10 @@
import type { GithubRepository } from '~/types/github-repository';
export const useGithub = () => {
const api = useApi('https://api.github.com');
const getRepoMeta = (owner: string, name: string): UseApiResponse<GithubRepository> =>
api.get<GithubRepository>(`repos/${owner}/${name}`);
return { getRepoMeta };
};
+18
View File
@@ -0,0 +1,18 @@
<template>
<NuxtLayout name="default">
<h1 class="text-4xl text-highlighted font-bold mb-8">
{{ $t('pages.projects.name') }}
</h1>
<div class="flex flex-wrap gap-4 justify-center">
<UiSingleProject v-for="project in projects" :key="project.name" :project="project" />
</div>
</NuxtLayout>
</template>
<script lang="ts" setup>
import type { Project, ProjectsWrapper } from '~/types/project';
const { getJsonData } = useDataJson('projects');
const projects$ = await getJsonData<ProjectsWrapper>();
const projects: ComputedRef<Project[]> = computed(() => (projects$?.value ? projects$.value.projects : []));
</script>
+5 -3
View File
@@ -7,7 +7,7 @@
<p>
{{ $t('pages.resume.experience') }}
</p>
<UTimeline v-model="valueExp" reverse :items="resumeContent?.experience" class="w-full">
<UTimeline v-model="valueExp" reverse :items="experiences" class="w-full">
<template #description="{ item }">
<div class="flex flex-col gap-2">
<p>
@@ -22,7 +22,7 @@
<p>
{{ $t('pages.resume.education') }}
</p>
<UTimeline v-model="valueEd" reverse :items="resumeContent?.education" class="w-full" />
<UTimeline v-model="valueEd" reverse :items="education" class="w-full" />
</UPageCard>
<UiBadgeListCard :tools="resumeContent?.otherTools">{{ $t('pages.resume.tools') }}</UiBadgeListCard>
<UiBadgeListCard :tools="resumeContent?.devops">{{ $t('pages.resume.devops') }}</UiBadgeListCard>
@@ -35,7 +35,7 @@
</template>
<script setup lang="ts">
import { ResumeContent } from '~/types/resume';
import { EducationTimelineItem, ResumeContent, ResumeExperienceTimelineItem } from '~/types/resume';
useMeta({
title: $t('pages.resume.name'),
@@ -44,6 +44,8 @@ useMeta({
const { getJsonData } = useDataJson('resume');
const resumeContent$ = await getJsonData<ResumeContent>();
const resumeContent = computed(() => (resumeContent$.value ? resumeContent$.value : new ResumeContent()));
const experiences = computed(() => resumeContent.value.experience.map((e) => new ResumeExperienceTimelineItem(e)));
const education = computed(() => resumeContent.value.education.map((e) => new EducationTimelineItem(e)));
const arrLength = <T,>(array?: T[]) => (array ? array.length - 1 : 0);
const valueExp = computed(() => arrLength(resumeContent.value?.experience));
const valueEd = computed(() => arrLength(resumeContent.value?.education));
+25
View File
@@ -0,0 +1,25 @@
export interface CrateMeta {
crate: Crate;
}
export interface Crate {
id: string;
name: string;
updated_at: string;
versions: number[];
keywords: string[];
created_at: string;
downloads: number;
recent_downloads: number;
default_version: string;
num_versions: number;
yanked: boolean;
max_version: string;
newest_version: string;
max_stable_version: string;
description: string;
homepage: string;
repository: string;
exact_match: boolean;
trustpub_only: boolean;
}
+108
View File
@@ -0,0 +1,108 @@
export interface GithubRepository {
id: number;
node_id: string;
name: string;
full_name: string;
private: boolean;
owner: Owner;
html_url: string;
description: string;
fork: boolean;
url: string;
forks_url: string;
keys_url: string;
collaborators_url: string;
teams_url: string;
hooks_url: string;
issue_events_url: string;
events_url: string;
assignees_url: string;
branches_url: string;
tags_url: string;
blobs_url: string;
git_tags_url: string;
git_refs_url: string;
trees_url: string;
statuses_url: string;
languages_url: string;
stargazers_url: string;
contributors_url: string;
subscribers_url: string;
subscription_url: string;
commits_url: string;
git_commits_url: string;
comments_url: string;
issue_comment_url: string;
contents_url: string;
compare_url: string;
merges_url: string;
archive_url: string;
downloads_url: string;
issues_url: string;
pulls_url: string;
milestones_url: string;
notifications_url: string;
labels_url: string;
releases_url: string;
deployments_url: string;
created_at: string;
updated_at: string;
pushed_at: string;
git_url: string;
ssh_url: string;
clone_url: string;
svn_url: string;
homepage: string;
size: number;
stargazers_count: number;
watchers_count: number;
language: string;
has_issues: boolean;
has_projects: boolean;
has_downloads: boolean;
has_wiki: boolean;
has_pages: boolean;
has_discussions: boolean;
forks_count: number;
mirror_url: any;
archived: boolean;
disabled: boolean;
open_issues_count: number;
license: any;
allow_forking: boolean;
is_template: boolean;
web_commit_signoff_required: boolean;
has_pull_requests: boolean;
pull_request_creation_policy: string;
topics: any[];
visibility: string;
forks: number;
open_issues: number;
watchers: number;
default_branch: string;
temp_clone_token: any;
network_count: number;
subscribers_count: number;
}
export interface Owner {
login: string;
id: number;
node_id: string;
avatar_url: string;
gravatar_id: string;
url: string;
html_url: string;
followers_url: string;
following_url: string;
gists_url: string;
starred_url: string;
subscriptions_url: string;
organizations_url: string;
repos_url: string;
events_url: string;
received_events_url: string;
type: string;
user_view_type: string;
site_admin: boolean;
}
+23
View File
@@ -0,0 +1,23 @@
import type { TimePeriod } from './time';
import type { Tool } from './tool';
export type ProjectLinkType = 'github' | 'gitea' | 'cargo' | 'melpa';
export interface ProjectLink {
url: string;
display: string;
type: ProjectLinkType;
}
export interface Project {
name: string;
description: string;
role: string;
dates: TimePeriod;
links: ProjectLink[];
tools: Tool[];
}
export interface ProjectsWrapper {
projects: Project[];
}
+47 -3
View File
@@ -1,14 +1,58 @@
import type { TimelineItem } from '@nuxt/ui';
import type { Tool } from './tool';
import { timeToString, type TimePeriod } from './time';
export class ResumeExperience implements TimelineItem {
tools: Tool[] = [];
export interface ResumeExperience {
dates: TimePeriod;
title: string;
company: string;
description: string;
tools: Tool[];
icon: string;
}
export class ResumeExperienceTimelineItem implements TimelineItem {
tools: Tool[];
description?: string;
date: string;
title: string;
icon: string;
constructor(from: ResumeExperience) {
this.date = timeToString(from.dates);
this.title = `${from.title}${from.company}`;
this.description = from.description;
this.icon = from.icon;
this.tools = from.tools;
}
}
export interface Education {
dates: TimePeriod;
degree: string;
institution: string;
location: string;
description?: string;
icon: string;
}
export class EducationTimelineItem implements TimelineItem {
date: string;
description?: string;
title: string;
icon: string;
constructor(from: Education) {
this.date = timeToString(from.dates);
this.description = from.description;
this.title = `${from.degree}${from.institution}, ${from.location}`;
this.icon = from.icon;
}
}
export class ResumeContent {
experience: ResumeExperience[] = [];
education: TimelineItem[] = [];
education: Education[] = [];
otherTools: Tool[] = [];
devops: Tool[] = [];
os: Tool[] = [];
+9
View File
@@ -0,0 +1,9 @@
import { t } from '../utils/i18n';
export interface TimePeriod {
start: string;
end?: string;
}
export const timeToString = (time: TimePeriod): string =>
time.end ? `${time.start}${time.end}` : `${t('misc.since')} ${time.start}`;
+21
View File
@@ -0,0 +1,21 @@
import { tryUseNuxtApp } from '#app';
/**
* Synchronous translation helper usable outside of Vue components.
*
* It resuses the request-scoped `@nuxtjs/i18n` instance exposed on the Nuxt app
* as `$i18n`, so translations stay in sync with the active locale (including
* server-side rendering and language switches). Falls back to the raw key when
* no Nuxt context is available (e.g. in unit tests).
*
* @param key Translation key
* @param named Named/list arguments forwarded to vue-i18n
* @returns The translated string, or `key` when no i18n instance is found
*/
export const t = <T extends string = string>(key: T, named?: Record<string, unknown> | unknown[]): string => {
const i18n = tryUseNuxtApp()?.$i18n;
if (!i18n) {
return key;
}
return i18n.t(key, named as never);
};