chore: separate frontend from backend

This commit is contained in:
2025-11-13 23:28:01 +01:00
parent 9f1d4db0de
commit 17fbe1d507
91 changed files with 415 additions and 6985 deletions

23
app/app.vue Normal file
View File

@@ -0,0 +1,23 @@
<template>
<UApp :locale="locales[locale]">
<AppNavbar />
<UMain>
<NuxtPage />
</UMain>
<AppFooter />
</UApp>
</template>
<script setup lang="ts">
import * as locales from '@nuxt/ui/locale';
const { locale } = useI18n();
const lang = computed(() => locales[locale.value].code);
const dir = computed(() => locales[locale.value].dir);
useHead({
htmlAttrs: {
dir,
lang,
},
});
</script>

132
app/assets/css/colors.css Normal file
View File

@@ -0,0 +1,132 @@
:root {
--text-50: oklch(96.68% 0.005 95.1);
--text-100: oklch(93.31% 0.012 96.43);
--text-200: oklch(86.46% 0.023 98.68);
--text-300: oklch(79.55% 0.036 98.17);
--text-400: oklch(72.45% 0.047 99.12);
--text-500: oklch(65.27% 0.06 98.88);
--text-600: oklch(55.54% 0.05 99.33);
--text-700: oklch(45.43% 0.04 98.55);
--text-800: oklch(34.63% 0.028 99.26);
--text-900: oklch(22.99% 0.017 97.01);
--text: oklch(17.69% 0.01 97.92);
--text-950: oklch(16.34% 0.008 95.54);
--background: oklch(97.33% 0.007 88.64);
--background-50: oklch(96.7% 0.008 91.48);
--background-100: oklch(93.46% 0.017 88);
--background-200: oklch(86.85% 0.034 88.07);
--background-300: oklch(80.17% 0.051 88.07);
--background-400: oklch(73.62% 0.069 89.26);
--background-500: oklch(66.8% 0.085 88.59);
--background-600: oklch(56.88% 0.071 88.9);
--background-700: oklch(46.26% 0.056 87.6);
--background-800: oklch(35.24% 0.04 87.71);
--background-900: oklch(23.27% 0.023 87.9);
--background-950: oklch(16.86% 0.012 91.89);
--primary-50: oklch(97.22% 0.012 96.42);
--primary-100: oklch(94.41% 0.025 97.12);
--primary-200: oklch(88.75% 0.05 98.42);
--primary-300: oklch(83.15% 0.074 98.36);
--primary-400: oklch(77.55% 0.097 98.29);
--primary: oklch(74.12% 0.109 98.34);
--primary-500: oklch(72% 0.116 97.93);
--primary-600: oklch(61.14% 0.097 98.09);
--primary-700: oklch(49.77% 0.077 98.34);
--primary-800: oklch(37.71% 0.055 98.79);
--primary-900: oklch(24.68% 0.033 97.74);
--primary-950: oklch(17.23% 0.018 97.53);
--secondary-50: oklch(97.69% 0.019 100.12);
--secondary-100: oklch(95.28% 0.036 96.71);
--secondary-200: oklch(90.57% 0.07 97.74);
--secondary-300: oklch(86.23% 0.103 98.42);
--secondary: oklch(83.86% 0.116 98.04);
--secondary-400: oklch(81.72% 0.129 98.31);
--secondary-500: oklch(77.44% 0.146 97.07);
--secondary-600: oklch(65.69% 0.123 97.5);
--secondary-700: oklch(53.48% 0.099 97.52);
--secondary-800: oklch(40.18% 0.072 97.19);
--secondary-900: oklch(26.04% 0.043 96.76);
--secondary-950: oklch(18.17% 0.026 97.52);
--accent-50: oklch(97.77% 0.019 96.86);
--accent-100: oklch(95.53% 0.039 97.44);
--accent-200: oklch(91.16% 0.076 97.81);
--accent-300: oklch(86.92% 0.11 97.94);
--accent: oklch(82.74% 0.136 98);
--accent-400: oklch(82.74% 0.136 98);
--accent-500: oklch(78.81% 0.152 96.76);
--accent-600: oklch(66.8% 0.128 96.97);
--accent-700: oklch(54.33% 0.103 96.65);
--accent-800: oklch(40.98% 0.076 96.95);
--accent-900: oklch(26.42% 0.045 97.53);
--accent-950: oklch(18.44% 0.029 102.49);
}
.dark {
--text-50: oklch(16.34% 0.008 95.54);
--text: oklch(96.05% 0.007 97.35);
--text-100: oklch(22.99% 0.017 97.01);
--text-200: oklch(34.63% 0.028 99.26);
--text-300: oklch(45.43% 0.04 98.55);
--text-400: oklch(55.54% 0.05 99.33);
--text-500: oklch(65.27% 0.06 98.88);
--text-600: oklch(72.45% 0.047 99.12);
--text-700: oklch(79.55% 0.036 98.17);
--text-800: oklch(86.46% 0.023 98.68);
--text-900: oklch(93.31% 0.012 96.43);
--text-950: oklch(96.68% 0.005 95.1);
--background-50: oklch(16.86% 0.012 91.89);
--background-100: oklch(23.27% 0.023 87.9);
--background-200: oklch(35.24% 0.04 87.71);
--background-300: oklch(46.26% 0.056 87.6);
--background-400: oklch(56.88% 0.071 88.9);
--background-500: oklch(66.8% 0.085 88.59);
--background-600: oklch(73.62% 0.069 89.26);
--background-700: oklch(80.17% 0.051 88.07);
--background-800: oklch(86.85% 0.034 88.07);
--background-900: oklch(93.46% 0.017 88);
--background-950: oklch(96.7% 0.008 91.48);
--background: oklch(15.48% 0.011 89.86);
--primary-50: oklch(17.23% 0.018 97.53);
--primary-100: oklch(24.68% 0.033 97.74);
--primary-200: oklch(37.71% 0.055 98.79);
--primary-300: oklch(49.77% 0.077 98.34);
--primary-400: oklch(61.14% 0.097 98.09);
--primary: oklch(67.74% 0.108 98.2);
--primary-500: oklch(72% 0.116 97.93);
--primary-600: oklch(77.55% 0.097 98.29);
--primary-700: oklch(83.15% 0.074 98.36);
--primary-800: oklch(88.75% 0.05 98.42);
--primary-900: oklch(94.41% 0.025 97.12);
--primary-950: oklch(97.22% 0.012 96.42);
--secondary-50: oklch(18.17% 0.026 97.52);
--secondary-100: oklch(26.04% 0.043 96.76);
--secondary-200: oklch(40.18% 0.072 97.19);
--secondary-300: oklch(53.48% 0.099 97.52);
--secondary: oklch(59.61% 0.111 97.84);
--secondary-400: oklch(65.69% 0.123 97.5);
--secondary-500: oklch(77.44% 0.146 97.07);
--secondary-600: oklch(81.72% 0.129 98.31);
--secondary-700: oklch(86.23% 0.103 98.42);
--secondary-800: oklch(90.57% 0.07 97.74);
--secondary-900: oklch(95.28% 0.036 96.71);
--secondary-950: oklch(97.69% 0.019 100.12);
--accent-50: oklch(18.44% 0.029 102.49);
--accent-100: oklch(26.42% 0.045 97.53);
--accent-200: oklch(40.98% 0.076 96.95);
--accent-300: oklch(54.33% 0.103 96.65);
--accent: oklch(66.8% 0.128 96.97);
--accent-400: oklch(66.8% 0.128 96.97);
--accent-500: oklch(78.81% 0.152 96.76);
--accent-600: oklch(82.74% 0.136 98);
--accent-700: oklch(86.92% 0.11 97.94);
--accent-800: oklch(91.16% 0.076 97.81);
--accent-900: oklch(95.53% 0.039 97.44);
--accent-950: oklch(97.77% 0.019 96.86);
}

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

@@ -0,0 +1,6 @@
@import '@nuxt/ui';
@import './colors.css';
@import './ui/index.css';
@import './tailwind.css';
@source "../../../content/**/*";

View File

@@ -0,0 +1,85 @@
@import 'tailwindcss';
@theme {
--color-text-50: var(--text-50);
--color-text: var(--text);
--color-text-100: var(--text-100);
--color-text-200: var(--text-200);
--color-text-300: var(--text-300);
--color-text-400: var(--text-400);
--color-text-500: var(--text-500);
--color-text-600: var(--text-600);
--color-text-700: var(--text-700);
--color-text-800: var(--text-800);
--color-text-900: var(--text-900);
--color-text-950: var(--text-950);
--color-background-50: var(--background-50);
--color-background-100: var(--background-100);
--color-background-200: var(--background-200);
--color-background-300: var(--background-300);
--color-background-400: var(--background-400);
--color-background-500: var(--background-500);
--color-background-600: var(--background-600);
--color-background-700: var(--background-700);
--color-background-800: var(--background-800);
--color-background-900: var(--background-900);
--color-background-950: var(--background-950);
--color-background: var(--background);
--color-primary-50: var(--primary-50);
--color-primary-100: var(--primary-100);
--color-primary-200: var(--primary-200);
--color-primary-300: var(--primary-300);
--color-primary-400: var(--primary-400);
--color-primary: var(--primary);
--color-primary-500: var(--primary-500);
--color-primary-600: var(--primary-600);
--color-primary-700: var(--primary-700);
--color-primary-800: var(--primary-800);
--color-primary-900: var(--primary-900);
--color-primary-950: var(--primary-950);
--color-secondary-50: var(--secondary-50);
--color-secondary-100: var(--secondary-100);
--color-secondary-200: var(--secondary-200);
--color-secondary-300: var(--secondary-300);
--color-secondary: var(--secondary);
--color-secondary-400: var(--secondary-400);
--color-secondary-500: var(--secondary-500);
--color-secondary-600: var(--secondary-600);
--color-secondary-700: var(--secondary-700);
--color-secondary-800: var(--secondary-800);
--color-secondary-900: var(--secondary-900);
--color-secondary-950: var(--secondary-950);
--color-accent-50: var(--accent-50);
--color-accent-100: var(--accent-100);
--color-accent-200: var(--accent-200);
--color-accent-300: var(--accent-300);
--color-accent: var(--accent);
--color-accent-400: var(--accent-400);
--color-accent-500: var(--accent-500);
--color-accent-600: var(--accent-600);
--color-accent-700: var(--accent-700);
--color-accent-800: var(--accent-800);
--color-accent-900: var(--accent-900);
--color-accent-950: var(--accent-950);
--text-sm: 0.75rem;
--text-base: 1rem;
--text-xl: 1.333rem;
--text-2xl: 1.777rem;
--text-3xl: 2.369rem;
--text-4xl: 3.158rem;
--text-5xl: 4.21rem;
--text-weight-normal: 400;
--text-weight-bold: 700;
--font-sans:
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',
'Noto Color Emoji';
}

View File

@@ -0,0 +1,14 @@
:root {
--ui-bg: var(--background);
--ui-bg-muted: var(--background-300);
--ui-bg-elevated: var(--background-100);
--ui-bg-accented: var(--backgsound-200);
--ui-bg-inverted: var(--background-900);
}
.dark {
--ui-bg: var(--background);
--ui-bg-muted: var(--background-100);
--ui-bg-elevated: var(--background-200);
--ui-bg-accented: var(--background-300);
--ui-bg-inverted: var(--background-900);
}

View File

@@ -0,0 +1,12 @@
:root {
--ui-border: var(--background-200);
--ui-border-muted: var(--background-200);
--ui-border-accented: var(--background-300);
--ui-border-inverted: var(--background-900);
}
.dark {
--ui-border: var(--background-100);
--ui-border-muted: var(--background-200);
--ui-border-accented: var(--background-200);
--ui-border-inverted: var(--background-900);
}

View File

@@ -0,0 +1,16 @@
:root {
--ui-primary: var(--primary);
--ui-secondary: var(--secondary);
--ui-success: var(--accent);
--ui-info: var(--ui-color-info-500);
--ui-warning: var(--ui-color-warning-500);
--ui-error: var(--ui-color-error-500);
}
.dark {
--ui-primary: var(--primary);
--ui-secondary: var(--secondary);
--ui-success: var(--accent);
--ui-info: var(--ui-color-info-400);
--ui-warning: var(--ui-color-warning-400);
--ui-error: var(--ui-color-error-400);
}

View File

@@ -0,0 +1,4 @@
@import './colors.css';
@import './text.css';
@import './background.css';
@import './border.css';

View File

@@ -0,0 +1,16 @@
:root {
--ui-text-dimmed: var(--text-800);
--ui-text-muted: var(--text-700);
--ui-text-toned: var(--text-600);
--ui-text: var(--text);
--ui-text-highlighted: var(--text-900);
--ui-text-inverted: var(--text-50);
}
.dark {
--ui-text-dimmed: var(--text-800);
--ui-text-muted: var(--text-700);
--ui-text-toned: var(--text-600);
--ui-text: var(--text);
--ui-text-highlighted: var(--text);
--ui-text-inverted: var(--text-50);
}

View File

@@ -0,0 +1,48 @@
<template>
<UFooter class="bg-background-200">
<template #left>
<div class="flex flex-col gap-2">
<p class="text-text-800 text-sm">Copyright &copy; {{ new Date().getFullYear() }}</p>
<p class="text-text-800 text-sm">{{ $t('footer.versions.frontend') }}: {{ version }}</p>
<p class="text-text-800 text-sm">{{ $t('footer.versions.backend') }}: {{ meta?.version }}</p>
</div>
</template>
<UNavigationMenu :items="items" variant="link" :orientation="orientation" />
<template #right>
<UButton
icon="i-simple-icons-github"
color="neutral"
variant="ghost"
to="https://github.com/Phundrak"
target="_blank"
aria-label="GitHub"
/>
</template>
</UFooter>
</template>
<script setup lang="ts">
import type { NavigationMenuItem } from '@nuxt/ui';
import { version } from '../../package.json';
const { isMobile } = useDevice();
const orientation = computed(() => (isMobile ? 'vertical' : 'horizontal'));
const { getMeta } = useBackend();
const meta = await getMeta();
const items = computed<NavigationMenuItem[]>(() => [
{
label: $t('footer.links.source'),
to: 'https://labs.phundrak.com/phundrak/phundrak.com',
},
{
label: $t('footer.links.nuxt'),
to: 'https://nuxt.com/',
},
{
label: $t('footer.links.rust'),
to: 'https://rust-lang.org/',
},
]);
</script>

View File

@@ -0,0 +1,29 @@
<template>
<UHeader toggle-side="right" mode="drawer">
<template #title> Phundrak </template>
<UNavigationMenu :items="items" />
<template #right>
<NavbarLanguageSwitcher />
<NavbarThemeSwitcher />
</template>
<template #body>
<UNavigationMenu :items="items" orientation="vertical" class="-mx-2.5" />
</template>
</UHeader>
</template>
<script setup lang="ts">
const route = useRoute();
const items = computed<NavigationMenuItem[]>(() => [
{
label: $t('pages.home.name'),
to: '/',
active: route.path == '/',
},
...['resume', 'vocal-synthesis', 'languages', 'contact'].map((page) => ({
label: $t(`pages.${page}.name`),
to: `/${page}`,
active: route.path.startsWith(`/${page}`),
})),
]);
</script>

View File

@@ -0,0 +1,13 @@
<template>
<div v-if="tools" class="flex flex-row gap-1 flex-wrap">
<UBadge v-for="tool in tools" :key="tool" size="md" variant="solid">
{{ tool }}
</UBadge>
</div>
</template>
<script setup lang="ts">
const { tools } = defineProps<{
tools: string[];
}>();
</script>

View File

@@ -0,0 +1,14 @@
<template>
<UPageCard class="bg-background-100 my-10">
<p class="text-xl">
<slot />
</p>
<UiBadgeList :tools="tools" />
</UPageCard>
</template>
<script setup lang="ts">
const { tools } = defineProps<{
tools: string[];
}>();
</script>

View File

@@ -0,0 +1,35 @@
<template>
<UPageCard class="bg-background-100 my-10">
<p class="text-xl">
{{ $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>
<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"
>
<UIcon :name="project.icon" class="size-11" />
</div>
</div>
<div class="flex flex-col">
<div class="flex flex-row gap-2 items-baseline">
<ULink :to="project.link" class="text-2xl">
{{ project.title }}
</ULink>
<UIcon v-if="external(project.link)" name="mdi:link" class="size-5" />
</div>
<div>
{{ project.description }}
</div>
</div>
</div>
</div>
</UPageCard>
</template>
<script setup lang="ts">
// Inject data provided by the page to avoid hydration issues with MDC components
const data = inject('pageData');
const external = (url: string) => url.startsWith('http');
</script>

View File

@@ -0,0 +1,8 @@
<template>
<UiBadgeListCard v-if="data" :tools="data.tools">{{ $t('pages.vocal-synthesis.tools') }}</UiBadgeListCard>
</template>
<script setup lang="ts">
// Inject data provided by the page to avoid hydration issues with MDC components
const data = inject('pageData');
</script>

View File

@@ -0,0 +1,26 @@
<template>
<UDropdownMenu :key="locale" :items="availableLocales" :content="{ align: 'start' }">
<UButton color="neutral" variant="outline" icon="material-symbols:globe" :aria-label="$t('menu.language')" />
</UDropdownMenu>
</template>
<script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui';
const { locale, locales, setLocale } = useI18n();
const availableLocales = computed(() => {
return locales.value.map(
(optionLocale) =>
({
label: optionLocale.name,
code: optionLocale.code,
type: 'checkbox' as const,
checked: optionLocale.code === locale.value,
onUpdateChecked: () => switchLocale(optionLocale.code),
}) as DropdownMenuItem,
);
});
const switchLocale = (newLocale: string) => {
setLocale(newLocale);
};
</script>

View File

@@ -0,0 +1,27 @@
<template>
<UDropdownMenu :key="colorMode.preference" :items="themes" :content="{ align: 'start' }">
<UButton color="neutral" variant="outline" :icon="icons[currentColor]" :aria-label="$t('menu.theme')" />
</UDropdownMenu>
</template>
<script setup lang="ts">
type Theme = 'light' | 'dark' | 'system';
const icons: Dictionary<Theme, string> = {
light: 'material-symbols:light-mode',
dark: 'material-symbols:dark-mode',
system: 'material-symbols:computer-outline',
};
const colorMode = useColorMode();
const currentColor = computed<Theme>(() => colorMode.preference ?? 'system');
const themes = computed<DropdownValue[]>(() =>
['light', 'dark', 'system'].map((theme) => ({
code: theme,
label: $t(`theme.${theme}`),
icon: icons[theme],
type: 'checkbox' as const,
checked: currentColor.value === theme,
onUpdateChecked: () => switchColor(theme),
})),
);
const switchColor = (theme: Theme) => (colorMode.preference = theme);
</script>

32
app/composables/useApi.ts Normal file
View File

@@ -0,0 +1,32 @@
import type { FetchOptions } from 'ofetch';
export const useApi = () => {
const config = useRuntimeConfig();
const apiFetch = $fetch.create({
baseURL: config.public.apiBase,
});
const get = <T>(url: string, options?: FetchOptions) => apiFetch<T>(url, { method: 'GET', ...options });
const post = <ResultT, PayloadT = Record<string, string | number | boolean>>(
url: string,
body?: PayloadT,
options?: FetchOptions,
) => apiFetch<ResultT>(url, { method: 'POST', body, ...options });
const put = <ResultT, PayloadT = Record<string, string | number | boolean>>(
url: string,
body?: PayloadT,
options?: FetchOptions,
) => apiFetch<ResultT>(url, { method: 'PUT', body, ...options });
const patch = <ResultT, PayloadT = Record<string, string | number | boolean>>(
url: string,
body?: PayloadT,
options?: FetchOptions,
) => apiFetch<ResultT>(url, { method: 'PATCH', body, ...options });
const del = <T>(url: string, options?: FetchOptions) => apiFetch<T>(url, { method: 'DELETE', ...options });
return { get, post, put, patch, del };
};

View File

@@ -0,0 +1,8 @@
export const useBackend = () => {
const api = useApi();
const getMeta = () => api.get<MetaResponse>('/meta');
const postContact = (contact: ContactRequest) => api.post<ContactRequest, ContactResponse>('/contact', contact);
return { getMeta, postContact };
};

View File

@@ -0,0 +1,64 @@
import { withLeadingSlash } from 'ufo';
import type { Collections } from '@nuxt/content';
export const useDataJson = (prefix: string) => {
const route = useRoute();
const { locale } = useI18n();
const slug = computed(() => {
// Use route.params.slug for dynamic routes, or route.path for static routes
const slugValue = route.params.slug || route.path;
return withLeadingSlash(String(slugValue));
});
const key = computed(() => prefix + '-' + slug.value);
const getData = async <T>(
collectionPrefix: string,
options: {
useFilter?: boolean;
fallbackToEnglish?: boolean;
extractMeta?: boolean;
} = {},
) => {
const { useFilter = false, fallbackToEnglish = false, extractMeta = false } = options;
const { data } = await useAsyncData(
key.value,
async () => {
const collection = (collectionPrefix + locale.value) as keyof Collections;
let content;
if (useFilter) {
// For data collections, use .all() and filter
const allData = await queryCollection(collection).all();
content = allData.filter((source) => source.meta.path == slug.value)[0];
} else {
// For page collections, use .path().first()
content = await queryCollection(collection).path(slug.value).first();
if (!content && fallbackToEnglish && locale.value !== 'en') {
content = await queryCollection('content_en').path(slug.value).first();
}
}
return extractMeta ? content?.meta : content;
},
{
watch: [locale], // Automatically refresh when locale changes
},
);
return data as Ref<T | null>;
};
const getJsonData = async (collectionPrefix: string = 'content_data_') => {
return getData(collectionPrefix, { useFilter: true, extractMeta: true });
};
const getPageContent = async (collectionPrefix: string = 'content_', fallbackToEnglish: boolean = true) => {
return getData(collectionPrefix, { fallbackToEnglish });
};
const getCachedData = () => {
const { data } = useNuxtData(key.value);
return data;
};
return { getJsonData, getPageContent, getCachedData };
};

View File

@@ -0,0 +1,27 @@
export interface MetaImageOptions {
url: string;
alt: string;
}
export interface MetaOptions {
title: string;
description: string;
image?: MetaImageOptions;
}
export const useMeta = (options: MetaOptions) => {
const titleSuffix = ' Lucien Cartier-Tilet';
useSeoMeta({
title: () => options.title + titleSuffix,
ogTitle: () => options.title + titleSuffix,
twitterTitle: () => options.title + titleSuffix,
description: () => options.description,
ogDescription: () => options.description,
twitterDescription: () => options.description,
twitterCard: options.image ? 'summary_large_image' : 'summary',
ogImage: () => options.image?.url,
ogImageAlt: () => options.image?.alt,
twitterImage: () => options.image?.url,
twitterImageAlt: () => options.image?.alt,
});
};

5
app/layouts/centered.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<div class="text-center prose prose-lg mx-auto max-w-prose">
<slot />
</div>
</template>

5
app/layouts/default.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<div class="min-h-screen mx-auto px-4 py-8 max-w-6xl">
<slot />
</div>
</template>

22
app/pages/[...slug].vue Normal file
View File

@@ -0,0 +1,22 @@
<template>
<NuxtLayout v-if="page" :name="page.meta?.layout ?? 'default'">
<ContentRenderer :value="page" />
</NuxtLayout>
<div v-else>
<h1>Page not found</h1>
<p>This page doesn&apos;t exist in {{ locale }} language.</p>
</div>
</template>
<script setup lang="ts">
const { getPageContent } = useDataJson('page');
const page = await getPageContent();
// Pre-fetch JSON data for MDC components to avoid hydration issues
const { getJsonData } = useDataJson('page-data');
const pageData = await getJsonData();
// Provide data to child MDC components
provide('pageData', pageData);
useMeta({ title: page.value?.title, description: page.value?.description });
</script>

47
app/pages/resume.vue Normal file
View File

@@ -0,0 +1,47 @@
<template>
<NuxtLayout name="default">
<h1 class="text-4xl text-highlighted font-bold mb-8">
{{ $t('pages.resume.name') }}
</h1>
<UPageCard class="bg-background-100 my-10">
<p>
{{ $t('pages.resume.experience') }}
</p>
<UTimeline v-model="valueExp" reverse :items="resumeContent?.experience" class="w-full">
<template #description="{ item }">
<div class="flex flex-col gap-2">
<p>
{{ item.description }}
</p>
<UiBadgeList :tools="item.tools" />
</div>
</template>
</UTimeline>
</UPageCard>
<UPageCard class="bg-background-100 my-10">
<p>
{{ $t('pages.resume.education') }}
</p>
<UTimeline v-model="valueEd" reverse :items="resumeContent?.education" class="w-full" />
</UPageCard>
<UiBadgeListCard :tools="resumeContent?.otherTools">{{ $t('pages.resume.tools') }}</UiBadgeListCard>
<UiBadgeListCard :tools="resumeContent?.devops">{{ $t('pages.resume.devops') }}</UiBadgeListCard>
<UiBadgeListCard :tools="resumeContent?.os">{{ $t('pages.resume.os') }}</UiBadgeListCard>
<UiBadgeListCard :tools="resumeContent?.programmingLanguages">{{
$t('pages.resume.programmingLanguages')
}}</UiBadgeListCard>
<UiBadgeListCard :tools="resumeContent?.frameworks">{{ $t('pages.resume.frameworks') }}</UiBadgeListCard>
</NuxtLayout>
</template>
<script setup lang="ts">
useMeta({
title: $t('pages.resume.name'),
description: $t('pages.resume.description'),
});
const { getJsonData } = useDataJson('resume');
const resumeContent = await getJsonData();
const arrLength = (array?: T[]) => (array ? array.length - 1 : 0);
const valueExp = computed(() => arrLength(resumeContent.value?.experience));
const valueEd = computed(() => arrLength(resumeContent.value?.education));
</script>

11
app/types/api/contact.ts Normal file
View File

@@ -0,0 +1,11 @@
export interface ContactRequest {
name: string;
email: string;
message: string;
website?: string | null;
}
export interface ContactResponse {
success: boolean;
message: string;
}

4
app/types/api/error.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface ApiError {
message: string;
success: boolean;
}

4
app/types/api/meta.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface MetaResponse {
version: string;
name: string;
}

3
app/types/dictionary.ts Normal file
View File

@@ -0,0 +1,3 @@
export interface Dictionary<K, T> {
[key: K]: T;
}

13
app/types/resume.ts Normal file
View File

@@ -0,0 +1,13 @@
export interface ResumeExperience extends TimelineItem {
tools: string[];
}
export interface ResumeContent {
experience: ResumeExperience[];
education: TimelineItem[];
otherTools: string[];
devops: string[];
os: string[];
programmingLanguages: string[];
frameworks: string[];
}

0
app/types/tool.ts Normal file
View File