chore: separate frontend from backend
This commit is contained in:
23
app/app.vue
Normal file
23
app/app.vue
Normal 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
132
app/assets/css/colors.css
Normal 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
6
app/assets/css/main.css
Normal file
@@ -0,0 +1,6 @@
|
||||
@import '@nuxt/ui';
|
||||
@import './colors.css';
|
||||
@import './ui/index.css';
|
||||
@import './tailwind.css';
|
||||
|
||||
@source "../../../content/**/*";
|
||||
85
app/assets/css/tailwind.css
Normal file
85
app/assets/css/tailwind.css
Normal 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';
|
||||
}
|
||||
14
app/assets/css/ui/background.css
Normal file
14
app/assets/css/ui/background.css
Normal 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);
|
||||
}
|
||||
12
app/assets/css/ui/border.css
Normal file
12
app/assets/css/ui/border.css
Normal 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);
|
||||
}
|
||||
16
app/assets/css/ui/colors.css
Normal file
16
app/assets/css/ui/colors.css
Normal 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);
|
||||
}
|
||||
4
app/assets/css/ui/index.css
Normal file
4
app/assets/css/ui/index.css
Normal file
@@ -0,0 +1,4 @@
|
||||
@import './colors.css';
|
||||
@import './text.css';
|
||||
@import './background.css';
|
||||
@import './border.css';
|
||||
16
app/assets/css/ui/text.css
Normal file
16
app/assets/css/ui/text.css
Normal 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);
|
||||
}
|
||||
48
app/components/AppFooter.vue
Normal file
48
app/components/AppFooter.vue
Normal 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 © {{ 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>
|
||||
29
app/components/AppNavbar.vue
Normal file
29
app/components/AppNavbar.vue
Normal 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>
|
||||
13
app/components/Ui/BadgeList.vue
Normal file
13
app/components/Ui/BadgeList.vue
Normal 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>
|
||||
14
app/components/Ui/BadgeListCard.vue
Normal file
14
app/components/Ui/BadgeListCard.vue
Normal 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>
|
||||
35
app/components/VocalSynth/Projects.vue
Normal file
35
app/components/VocalSynth/Projects.vue
Normal 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>
|
||||
8
app/components/VocalSynth/Tools.vue
Normal file
8
app/components/VocalSynth/Tools.vue
Normal 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>
|
||||
26
app/components/navbar/LanguageSwitcher.vue
Normal file
26
app/components/navbar/LanguageSwitcher.vue
Normal 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>
|
||||
27
app/components/navbar/ThemeSwitcher.vue
Normal file
27
app/components/navbar/ThemeSwitcher.vue
Normal 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
32
app/composables/useApi.ts
Normal 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 };
|
||||
};
|
||||
8
app/composables/useBackend.ts
Normal file
8
app/composables/useBackend.ts
Normal 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 };
|
||||
};
|
||||
64
app/composables/useDataJson.ts
Normal file
64
app/composables/useDataJson.ts
Normal 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 };
|
||||
};
|
||||
27
app/composables/useMeta.ts
Normal file
27
app/composables/useMeta.ts
Normal 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
5
app/layouts/centered.vue
Normal 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
5
app/layouts/default.vue
Normal 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
22
app/pages/[...slug].vue
Normal 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'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
47
app/pages/resume.vue
Normal 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
11
app/types/api/contact.ts
Normal 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
4
app/types/api/error.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
success: boolean;
|
||||
}
|
||||
4
app/types/api/meta.ts
Normal file
4
app/types/api/meta.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface MetaResponse {
|
||||
version: string;
|
||||
name: string;
|
||||
}
|
||||
3
app/types/dictionary.ts
Normal file
3
app/types/dictionary.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export interface Dictionary<K, T> {
|
||||
[key: K]: T;
|
||||
}
|
||||
13
app/types/resume.ts
Normal file
13
app/types/resume.ts
Normal 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
0
app/types/tool.ts
Normal file
Reference in New Issue
Block a user