Compare commits
17 Commits
17fbe1d507
...
dadd87a4be
| Author | SHA1 | Date | |
|---|---|---|---|
|
dadd87a4be
|
|||
|
af84a7fb9f
|
|||
|
33f57f0bd5
|
|||
|
c47cfed5ae
|
|||
|
1969d59186
|
|||
|
8187aabbb1
|
|||
|
8f395b972c
|
|||
|
0114ddf30b
|
|||
|
c6648c4075
|
|||
|
3b6578daa2
|
|||
|
034fdc2afe
|
|||
|
13f423e455
|
|||
|
7ae22106fa
|
|||
|
ea35c524e2
|
|||
|
dfec7bb869
|
|||
|
0101592145
|
|||
|
1563ab1f45
|
@@ -1,3 +1,3 @@
|
|||||||
NUXT_PUBLIC_BACKEND_URL=http://localhost:3100
|
NUXT_PUBLIC_API_BASE=http://localhost:3100
|
||||||
NUXT_PUBLIC_TURNSTILE_SITE_KEY="changeme"
|
NUXT_PUBLIC_URL_BASE=http://localhost:3000
|
||||||
NUXT_TURNSTILE_SECRET_KEY="changeme"
|
NUXT_PUBLIC_FEDIVERSE_CREATOR="@user@instance.example"
|
||||||
|
|||||||
1
.gitignore
vendored
@@ -31,3 +31,4 @@ node_modules
|
|||||||
# Nix
|
# Nix
|
||||||
result
|
result
|
||||||
.data/
|
.data/
|
||||||
|
app/coverage/*
|
||||||
|
|||||||
16
app/app.vue
@@ -13,11 +13,27 @@ import * as locales from '@nuxt/ui/locale';
|
|||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
const lang = computed(() => locales[locale.value].code);
|
const lang = computed(() => locales[locale.value].code);
|
||||||
const dir = computed(() => locales[locale.value].dir);
|
const dir = computed(() => locales[locale.value].dir);
|
||||||
|
const { urlBase, fediverseCreator } = useRuntimeConfig().public;
|
||||||
|
const route = useRoute();
|
||||||
|
const url = computed(() => urlBase.replace(/\/+$/, '') + route.fullPath);
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
htmlAttrs: {
|
htmlAttrs: {
|
||||||
dir,
|
dir,
|
||||||
lang,
|
lang,
|
||||||
},
|
},
|
||||||
|
link: [
|
||||||
|
{ rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32x32.png' },
|
||||||
|
{ rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-16x16.png' },
|
||||||
|
{ rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' },
|
||||||
|
{ rel: 'manifest', href: '/site.webmanifest' },
|
||||||
|
],
|
||||||
|
meta: fediverseCreator !== '' ? [{ name: 'fediverse:creator', content: fediverseCreator + '' }] : [],
|
||||||
|
});
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
ogImage: '/leon.png',
|
||||||
|
twitterImage: '/leon.png',
|
||||||
|
ogUrl: url,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,132 +1,132 @@
|
|||||||
:root {
|
:root {
|
||||||
--text-50: oklch(96.68% 0.005 95.1);
|
--text: oklch(38.30% 0.029 266.48);
|
||||||
--text-100: oklch(93.31% 0.012 96.43);
|
--text-50: oklch(95.82% 0.004 271.37);
|
||||||
--text-200: oklch(86.46% 0.023 98.68);
|
--text-100: oklch(91.83% 0.009 264.52);
|
||||||
--text-300: oklch(79.55% 0.036 98.17);
|
--text-200: oklch(83.53% 0.016 266.26);
|
||||||
--text-400: oklch(72.45% 0.047 99.12);
|
--text-300: oklch(74.99% 0.026 265.54);
|
||||||
--text-500: oklch(65.27% 0.06 98.88);
|
--text-400: oklch(66.05% 0.036 268.49);
|
||||||
--text-600: oklch(55.54% 0.05 99.33);
|
--text-500: oklch(57.02% 0.047 267.31);
|
||||||
--text-700: oklch(45.43% 0.04 98.55);
|
--text-600: oklch(48.66% 0.039 268.21);
|
||||||
--text-800: oklch(34.63% 0.028 99.26);
|
--text-700: oklch(40.13% 0.031 265.23);
|
||||||
--text-900: oklch(22.99% 0.017 97.01);
|
--text-800: oklch(30.90% 0.021 265.90);
|
||||||
--text: oklch(17.69% 0.01 97.92);
|
--text-900: oklch(20.86% 0.013 264.25);
|
||||||
--text-950: oklch(16.34% 0.008 95.54);
|
--text-950: oklch(15.46% 0.007 270.96);
|
||||||
|
|
||||||
--background: oklch(97.33% 0.007 88.64);
|
--background: oklch(95.13% 0.007 260.73);
|
||||||
--background-50: oklch(96.7% 0.008 91.48);
|
--background-50: oklch(95.80% 0.007 268.55);
|
||||||
--background-100: oklch(93.46% 0.017 88);
|
--background-100: oklch(91.74% 0.012 259.82);
|
||||||
--background-200: oklch(86.85% 0.034 88.07);
|
--background-200: oklch(83.07% 0.027 262.33);
|
||||||
--background-300: oklch(80.17% 0.051 88.07);
|
--background-300: oklch(74.46% 0.041 261.48);
|
||||||
--background-400: oklch(73.62% 0.069 89.26);
|
--background-400: oklch(65.63% 0.058 260.56);
|
||||||
--background-500: oklch(66.8% 0.085 88.59);
|
--background-500: oklch(56.42% 0.075 261.41);
|
||||||
--background-600: oklch(56.88% 0.071 88.9);
|
--background-600: oklch(48.32% 0.062 260.40);
|
||||||
--background-700: oklch(46.26% 0.056 87.6);
|
--background-700: oklch(39.64% 0.048 261.18);
|
||||||
--background-800: oklch(35.24% 0.04 87.71);
|
--background-800: oklch(30.43% 0.036 261.92);
|
||||||
--background-900: oklch(23.27% 0.023 87.9);
|
--background-900: oklch(20.77% 0.018 259.72);
|
||||||
--background-950: oklch(16.86% 0.012 91.89);
|
--background-950: oklch(15.29% 0.010 255.44);
|
||||||
|
|
||||||
--primary-50: oklch(97.22% 0.012 96.42);
|
--primary: oklch(77.15% 0.062 217.48);
|
||||||
--primary-100: oklch(94.41% 0.025 97.12);
|
--primary-50: oklch(96.50% 0.009 222.06);
|
||||||
--primary-200: oklch(88.75% 0.05 98.42);
|
--primary-100: oklch(93.16% 0.019 213.42);
|
||||||
--primary-300: oklch(83.15% 0.074 98.36);
|
--primary-200: oklch(86.07% 0.039 217.46);
|
||||||
--primary-400: oklch(77.55% 0.097 98.29);
|
--primary-300: oklch(79.25% 0.057 216.55);
|
||||||
--primary: oklch(74.12% 0.109 98.34);
|
--primary-400: oklch(72.48% 0.075 217.32);
|
||||||
--primary-500: oklch(72% 0.116 97.93);
|
--primary-500: oklch(65.88% 0.089 218.00);
|
||||||
--primary-600: oklch(61.14% 0.097 98.09);
|
--primary-600: oklch(55.99% 0.075 218.52);
|
||||||
--primary-700: oklch(49.77% 0.077 98.34);
|
--primary-700: oklch(45.64% 0.059 218.22);
|
||||||
--primary-800: oklch(37.71% 0.055 98.79);
|
--primary-800: oklch(34.67% 0.043 219.39);
|
||||||
--primary-900: oklch(24.68% 0.033 97.74);
|
--primary-900: oklch(23.06% 0.024 214.47);
|
||||||
--primary-950: oklch(17.23% 0.018 97.53);
|
--primary-950: oklch(16.48% 0.015 212.62);
|
||||||
|
|
||||||
--secondary-50: oklch(97.69% 0.019 100.12);
|
--secondary: oklch(69.65% 0.059 248.69);
|
||||||
--secondary-100: oklch(95.28% 0.036 96.71);
|
--secondary-50: oklch(95.95% 0.008 253.85);
|
||||||
--secondary-200: oklch(90.57% 0.07 97.74);
|
--secondary-100: oklch(92.05% 0.015 244.73);
|
||||||
--secondary-300: oklch(86.23% 0.103 98.42);
|
--secondary-200: oklch(83.76% 0.030 248.19);
|
||||||
--secondary: oklch(83.86% 0.116 98.04);
|
--secondary-300: oklch(75.31% 0.048 249.46);
|
||||||
--secondary-400: oklch(81.72% 0.129 98.31);
|
--secondary-400: oklch(66.99% 0.065 248.83);
|
||||||
--secondary-500: oklch(77.44% 0.146 97.07);
|
--secondary-500: oklch(58.35% 0.083 249.96);
|
||||||
--secondary-600: oklch(65.69% 0.123 97.5);
|
--secondary-600: oklch(49.88% 0.069 249.37);
|
||||||
--secondary-700: oklch(53.48% 0.099 97.52);
|
--secondary-700: oklch(40.78% 0.056 250.22);
|
||||||
--secondary-800: oklch(40.18% 0.072 97.19);
|
--secondary-800: oklch(31.42% 0.038 249.12);
|
||||||
--secondary-900: oklch(26.04% 0.043 96.76);
|
--secondary-900: oklch(20.99% 0.022 251.79);
|
||||||
--secondary-950: oklch(18.17% 0.026 97.52);
|
--secondary-950: oklch(15.56% 0.012 241.97);
|
||||||
|
|
||||||
--accent-50: oklch(97.77% 0.019 96.86);
|
--accent: oklch(59.38% 0.078 253.40);
|
||||||
--accent-100: oklch(95.53% 0.039 97.44);
|
--accent-50: oklch(95.93% 0.007 247.90);
|
||||||
--accent-200: oklch(91.16% 0.076 97.81);
|
--accent-100: oklch(91.85% 0.015 251.16);
|
||||||
--accent-300: oklch(86.92% 0.11 97.94);
|
--accent-200: oklch(83.39% 0.030 254.70);
|
||||||
--accent: oklch(82.74% 0.136 98);
|
--accent-300: oklch(74.95% 0.046 253.67);
|
||||||
--accent-400: oklch(82.74% 0.136 98);
|
--accent-400: oklch(66.37% 0.064 253.29);
|
||||||
--accent-500: oklch(78.81% 0.152 96.76);
|
--accent-500: oklch(57.47% 0.081 254.47);
|
||||||
--accent-600: oklch(66.8% 0.128 96.97);
|
--accent-600: oklch(49.19% 0.068 253.56);
|
||||||
--accent-700: oklch(54.33% 0.103 96.65);
|
--accent-700: oklch(40.31% 0.053 254.02);
|
||||||
--accent-800: oklch(40.98% 0.076 96.95);
|
--accent-800: oklch(30.91% 0.038 255.00);
|
||||||
--accent-900: oklch(26.42% 0.045 97.53);
|
--accent-900: oklch(20.99% 0.022 251.79);
|
||||||
--accent-950: oklch(18.44% 0.029 102.49);
|
--accent-950: oklch(15.35% 0.012 260.39);
|
||||||
}
|
}
|
||||||
.dark {
|
.dark {
|
||||||
--text-50: oklch(16.34% 0.008 95.54);
|
--text: oklch(76.63% 0.024 266.86);
|
||||||
--text: oklch(96.05% 0.007 97.35);
|
--text-50: oklch(15.46% 0.007 270.96);
|
||||||
--text-100: oklch(22.99% 0.017 97.01);
|
--text-100: oklch(20.86% 0.013 264.25);
|
||||||
--text-200: oklch(34.63% 0.028 99.26);
|
--text-200: oklch(30.90% 0.021 265.90);
|
||||||
--text-300: oklch(45.43% 0.04 98.55);
|
--text-300: oklch(40.13% 0.031 265.23);
|
||||||
--text-400: oklch(55.54% 0.05 99.33);
|
--text-400: oklch(48.66% 0.039 268.21);
|
||||||
--text-500: oklch(65.27% 0.06 98.88);
|
--text-500: oklch(57.02% 0.047 267.31);
|
||||||
--text-600: oklch(72.45% 0.047 99.12);
|
--text-600: oklch(66.05% 0.036 268.49);
|
||||||
--text-700: oklch(79.55% 0.036 98.17);
|
--text-700: oklch(74.99% 0.026 265.54);
|
||||||
--text-800: oklch(86.46% 0.023 98.68);
|
--text-800: oklch(83.53% 0.016 266.26);
|
||||||
--text-900: oklch(93.31% 0.012 96.43);
|
--text-900: oklch(91.83% 0.009 264.52);
|
||||||
--text-950: oklch(96.68% 0.005 95.1);
|
--text-950: oklch(95.82% 0.004 271.37);
|
||||||
|
|
||||||
--background-50: oklch(16.86% 0.012 91.89);
|
--background: oklch(16.29% 0.012 260.61);
|
||||||
--background-100: oklch(23.27% 0.023 87.9);
|
--background-50: oklch(15.29% 0.010 255.44);
|
||||||
--background-200: oklch(35.24% 0.04 87.71);
|
--background-100: oklch(20.77% 0.018 259.72);
|
||||||
--background-300: oklch(46.26% 0.056 87.6);
|
--background-200: oklch(30.43% 0.036 261.92);
|
||||||
--background-400: oklch(56.88% 0.071 88.9);
|
--background-300: oklch(39.64% 0.048 261.18);
|
||||||
--background-500: oklch(66.8% 0.085 88.59);
|
--background-400: oklch(48.32% 0.062 260.40);
|
||||||
--background-600: oklch(73.62% 0.069 89.26);
|
--background-500: oklch(56.42% 0.075 261.41);
|
||||||
--background-700: oklch(80.17% 0.051 88.07);
|
--background-600: oklch(65.63% 0.058 260.56);
|
||||||
--background-800: oklch(86.85% 0.034 88.07);
|
--background-700: oklch(74.46% 0.041 261.48);
|
||||||
--background-900: oklch(93.46% 0.017 88);
|
--background-800: oklch(83.07% 0.027 262.33);
|
||||||
--background-950: oklch(96.7% 0.008 91.48);
|
--background-900: oklch(91.74% 0.012 259.82);
|
||||||
--background: oklch(15.48% 0.011 89.86);
|
--background-950: oklch(95.80% 0.007 268.55);
|
||||||
|
|
||||||
--primary-50: oklch(17.23% 0.018 97.53);
|
--primary: oklch(48.89% 0.064 217.48);
|
||||||
--primary-100: oklch(24.68% 0.033 97.74);
|
--primary-50: oklch(16.48% 0.015 212.62);
|
||||||
--primary-200: oklch(37.71% 0.055 98.79);
|
--primary-100: oklch(23.06% 0.024 214.47);
|
||||||
--primary-300: oklch(49.77% 0.077 98.34);
|
--primary-200: oklch(34.67% 0.043 219.39);
|
||||||
--primary-400: oklch(61.14% 0.097 98.09);
|
--primary-300: oklch(45.64% 0.059 218.22);
|
||||||
--primary: oklch(67.74% 0.108 98.2);
|
--primary-400: oklch(55.99% 0.075 218.52);
|
||||||
--primary-500: oklch(72% 0.116 97.93);
|
--primary-500: oklch(65.88% 0.089 218.00);
|
||||||
--primary-600: oklch(77.55% 0.097 98.29);
|
--primary-600: oklch(72.48% 0.075 217.32);
|
||||||
--primary-700: oklch(83.15% 0.074 98.36);
|
--primary-700: oklch(79.25% 0.057 216.55);
|
||||||
--primary-800: oklch(88.75% 0.05 98.42);
|
--primary-800: oklch(86.07% 0.039 217.46);
|
||||||
--primary-900: oklch(94.41% 0.025 97.12);
|
--primary-900: oklch(93.16% 0.019 213.42);
|
||||||
--primary-950: oklch(97.22% 0.012 96.42);
|
--primary-950: oklch(96.50% 0.009 222.06);
|
||||||
|
|
||||||
--secondary-50: oklch(18.17% 0.026 97.52);
|
--secondary: oklch(47.12% 0.064 249.33);
|
||||||
--secondary-100: oklch(26.04% 0.043 96.76);
|
--secondary-50: oklch(15.56% 0.012 241.97);
|
||||||
--secondary-200: oklch(40.18% 0.072 97.19);
|
--secondary-100: oklch(20.99% 0.022 251.79);
|
||||||
--secondary-300: oklch(53.48% 0.099 97.52);
|
--secondary-200: oklch(31.42% 0.038 249.12);
|
||||||
--secondary: oklch(59.61% 0.111 97.84);
|
--secondary-300: oklch(40.78% 0.056 250.22);
|
||||||
--secondary-400: oklch(65.69% 0.123 97.5);
|
--secondary-400: oklch(49.88% 0.069 249.37);
|
||||||
--secondary-500: oklch(77.44% 0.146 97.07);
|
--secondary-500: oklch(58.35% 0.083 249.96);
|
||||||
--secondary-600: oklch(81.72% 0.129 98.31);
|
--secondary-600: oklch(66.99% 0.065 248.83);
|
||||||
--secondary-700: oklch(86.23% 0.103 98.42);
|
--secondary-700: oklch(75.31% 0.048 249.46);
|
||||||
--secondary-800: oklch(90.57% 0.07 97.74);
|
--secondary-800: oklch(83.76% 0.030 248.19);
|
||||||
--secondary-900: oklch(95.28% 0.036 96.71);
|
--secondary-900: oklch(92.05% 0.015 244.73);
|
||||||
--secondary-950: oklch(97.69% 0.019 100.12);
|
--secondary-950: oklch(95.95% 0.008 253.85);
|
||||||
|
|
||||||
--accent-50: oklch(18.44% 0.029 102.49);
|
--accent: oklch(55.80% 0.080 254.61);
|
||||||
--accent-100: oklch(26.42% 0.045 97.53);
|
--accent-50: oklch(15.35% 0.012 260.39);
|
||||||
--accent-200: oklch(40.98% 0.076 96.95);
|
--accent-100: oklch(20.99% 0.022 251.79);
|
||||||
--accent-300: oklch(54.33% 0.103 96.65);
|
--accent-200: oklch(30.91% 0.038 255.00);
|
||||||
--accent: oklch(66.8% 0.128 96.97);
|
--accent-300: oklch(40.31% 0.053 254.02);
|
||||||
--accent-400: oklch(66.8% 0.128 96.97);
|
--accent-400: oklch(49.19% 0.068 253.56);
|
||||||
--accent-500: oklch(78.81% 0.152 96.76);
|
--accent-500: oklch(57.47% 0.081 254.47);
|
||||||
--accent-600: oklch(82.74% 0.136 98);
|
--accent-600: oklch(66.37% 0.064 253.29);
|
||||||
--accent-700: oklch(86.92% 0.11 97.94);
|
--accent-700: oklch(74.95% 0.046 253.67);
|
||||||
--accent-800: oklch(91.16% 0.076 97.81);
|
--accent-800: oklch(83.39% 0.030 254.70);
|
||||||
--accent-900: oklch(95.53% 0.039 97.44);
|
--accent-900: oklch(91.85% 0.015 251.16);
|
||||||
--accent-950: oklch(97.77% 0.019 96.86);
|
--accent-950: oklch(95.93% 0.007 247.90);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,3 +4,12 @@
|
|||||||
@import './tailwind.css';
|
@import './tailwind.css';
|
||||||
|
|
||||||
@source "../../../content/**/*";
|
@source "../../../content/**/*";
|
||||||
|
|
||||||
|
.small-img {
|
||||||
|
max-width: 30rem;
|
||||||
|
max-height: 30rem;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
float: right;
|
||||||
|
margin: 2rem;
|
||||||
|
}
|
||||||
|
|||||||
161
app/components/AppFooter.test.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
describe('AppFooter', () => {
|
||||||
|
describe('navigation items logic', () => {
|
||||||
|
const mockT = (key: string) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'footer.links.source': 'Source Code',
|
||||||
|
'footer.links.nuxt': 'Nuxt',
|
||||||
|
'footer.links.rust': 'Rust',
|
||||||
|
};
|
||||||
|
return translations[key] || key;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should generate footer navigation items', () => {
|
||||||
|
const items = computed(() => [
|
||||||
|
{
|
||||||
|
label: mockT('footer.links.source'),
|
||||||
|
to: 'https://labs.phundrak.com/phundrak/phundrak.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: mockT('footer.links.nuxt'),
|
||||||
|
to: 'https://nuxt.com/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: mockT('footer.links.rust'),
|
||||||
|
to: 'https://rust-lang.org/',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(items.value).toHaveLength(3);
|
||||||
|
expect(items.value[0].label).toBe('Source Code');
|
||||||
|
expect(items.value[0].to).toBe('https://labs.phundrak.com/phundrak/phundrak.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include link to Nuxt', () => {
|
||||||
|
const items = computed(() => [
|
||||||
|
{
|
||||||
|
label: mockT('footer.links.nuxt'),
|
||||||
|
to: 'https://nuxt.com/',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(items.value[0].to).toBe('https://nuxt.com/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include link to Rust', () => {
|
||||||
|
const items = computed(() => [
|
||||||
|
{
|
||||||
|
label: mockT('footer.links.rust'),
|
||||||
|
to: 'https://rust-lang.org/',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(items.value[0].to).toBe('https://rust-lang.org/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('backend version logic', () => {
|
||||||
|
const mockT = (key: string) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'backend.loading': 'Loading...',
|
||||||
|
'backend.failed': 'Failed to load',
|
||||||
|
};
|
||||||
|
return translations[key] || key;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should show loading text when loading', () => {
|
||||||
|
const mockLoading = ref(true);
|
||||||
|
const mockData = ref<{ version: string } | null>(null);
|
||||||
|
|
||||||
|
const backendVersion = computed(() =>
|
||||||
|
mockLoading.value ? 'backend.loading' : mockData.value?.version || mockT('backend.failed'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(backendVersion.value).toBe('backend.loading');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show version when data is loaded', () => {
|
||||||
|
const mockLoading = ref(false);
|
||||||
|
const mockData = ref({ version: '1.2.3' });
|
||||||
|
|
||||||
|
const backendVersion = computed(() =>
|
||||||
|
mockLoading.value ? 'backend.loading' : mockData.value?.version || mockT('backend.failed'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(backendVersion.value).toBe('1.2.3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show failed text when no data', () => {
|
||||||
|
const mockLoading = ref(false);
|
||||||
|
const mockData = ref<{ version: string } | null>(null);
|
||||||
|
|
||||||
|
const backendVersion = computed(() =>
|
||||||
|
mockLoading.value ? 'backend.loading' : mockData.value?.version || mockT('backend.failed'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(backendVersion.value).toBe('Failed to load');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('orientation logic', () => {
|
||||||
|
it('should use vertical orientation on mobile', () => {
|
||||||
|
const mockIsMobile = true;
|
||||||
|
const orientation = computed(() => (mockIsMobile ? 'vertical' : 'horizontal'));
|
||||||
|
|
||||||
|
expect(orientation.value).toBe('vertical');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use horizontal orientation on desktop', () => {
|
||||||
|
const mockIsMobile = false;
|
||||||
|
const orientation = computed(() => (mockIsMobile ? 'vertical' : 'horizontal'));
|
||||||
|
|
||||||
|
expect(orientation.value).toBe('horizontal');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error toast watcher', () => {
|
||||||
|
it('should call toast.add when error occurs', () => {
|
||||||
|
const mockToastAdd = vi.fn();
|
||||||
|
const mockError = ref<{ message: string } | null>(null);
|
||||||
|
|
||||||
|
// Simulate the watcher behavior
|
||||||
|
const triggerErrorWatcher = (error: { message: string } | null) => {
|
||||||
|
if (error) {
|
||||||
|
mockToastAdd({
|
||||||
|
title: 'Error',
|
||||||
|
description: error.message,
|
||||||
|
color: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mockError.value = { message: 'backend.errors.unknown' };
|
||||||
|
triggerErrorWatcher(mockError.value);
|
||||||
|
|
||||||
|
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'backend.errors.unknown',
|
||||||
|
color: 'error',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not call toast.add when error is null', () => {
|
||||||
|
const mockToastAdd = vi.fn();
|
||||||
|
|
||||||
|
const triggerErrorWatcher = (error: { message: string } | null) => {
|
||||||
|
if (error) {
|
||||||
|
mockToastAdd({
|
||||||
|
title: 'Error',
|
||||||
|
description: error.message,
|
||||||
|
color: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
triggerErrorWatcher(null);
|
||||||
|
|
||||||
|
expect(mockToastAdd).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,20 +4,19 @@
|
|||||||
<div class="flex flex-col gap-2">
|
<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">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.frontend') }}: {{ version }}</p>
|
||||||
<p class="text-text-800 text-sm">{{ $t('footer.versions.backend') }}: {{ meta?.version }}</p>
|
<p class="text-text-800 text-sm">{{ $t('footer.versions.backend') }}: {{ backendVersion }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<UNavigationMenu :items="items" variant="link" :orientation="orientation" />
|
<UNavigationMenu :items="items" variant="link" :orientation="orientation" />
|
||||||
|
|
||||||
<template #right>
|
<template #right>
|
||||||
<UButton
|
<FooterSocialAccount
|
||||||
icon="i-simple-icons-github"
|
v-for="social in socialAccounts"
|
||||||
color="neutral"
|
:key="social.label"
|
||||||
variant="ghost"
|
:icon="social.icon"
|
||||||
to="https://github.com/Phundrak"
|
:link="social.link"
|
||||||
target="_blank"
|
:label="social.label"
|
||||||
aria-label="GitHub"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</UFooter>
|
</UFooter>
|
||||||
@@ -26,15 +25,32 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { NavigationMenuItem } from '@nuxt/ui';
|
import type { NavigationMenuItem } from '@nuxt/ui';
|
||||||
import { version } from '../../package.json';
|
import { version } from '../../package.json';
|
||||||
|
import type { SocialAccount } from '~/types/social-account';
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
const { isMobile } = useDevice();
|
const { isMobile } = useDevice();
|
||||||
const orientation = computed(() => (isMobile ? 'vertical' : 'horizontal'));
|
const orientation = computed(() => (isMobile ? 'vertical' : 'horizontal'));
|
||||||
const { getMeta } = useBackend();
|
const { getMeta } = useBackend();
|
||||||
const meta = await getMeta();
|
const { data, error, loading } = getMeta();
|
||||||
|
const backendVersion = computed(() =>
|
||||||
|
loading.value ? 'backend.loading' : data?.value?.version || $t('backend.failed'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const socialAccounts: SocialAccount[] = [
|
||||||
|
{ icon: 'i-simple-icons-mastodon', label: 'Fediverse', link: 'https://social.phundrak.com/phundrak' },
|
||||||
|
{ icon: 'i-simple-icons-gitea', label: 'Gitea', link: 'https://labs.phundrak.com/phundrak' },
|
||||||
|
{ icon: 'i-simple-icons-github', label: 'GitHub', link: 'https://github.com/Phundrak' },
|
||||||
|
{ icon: 'i-simple-icons-youtube', label: 'YouTube', link: 'https://youtube.com/@phundrak' },
|
||||||
|
];
|
||||||
|
|
||||||
const items = computed<NavigationMenuItem[]>(() => [
|
const items = computed<NavigationMenuItem[]>(() => [
|
||||||
{
|
{
|
||||||
label: $t('footer.links.source'),
|
label: $t('footer.links.source.backend'),
|
||||||
to: 'https://labs.phundrak.com/phundrak/phundrak.com',
|
to: 'https://labs.phundrak.com/phundrak/bakit',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: $t('footer.links.source.frontend'),
|
||||||
|
to: 'https://labs.phundrak.com/phundrak/framit',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: $t('footer.links.nuxt'),
|
label: $t('footer.links.nuxt'),
|
||||||
@@ -45,4 +61,14 @@ const items = computed<NavigationMenuItem[]>(() => [
|
|||||||
to: 'https://rust-lang.org/',
|
to: 'https://rust-lang.org/',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
watch(error, (value) => {
|
||||||
|
if (value) {
|
||||||
|
toast.add({
|
||||||
|
title: $t('backend.errors.title'),
|
||||||
|
description: $t(value.message ?? 'backend.errors.unknown'),
|
||||||
|
color: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
123
app/components/AppNavbar.test.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
describe('AppNavbar', () => {
|
||||||
|
describe('navigation items logic', () => {
|
||||||
|
const mockT = (key: string) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'pages.home.name': 'Home',
|
||||||
|
'pages.resume.name': 'Resume',
|
||||||
|
'pages.vocal-synthesis.name': 'Vocal Synthesis',
|
||||||
|
'pages.languages.name': 'Languages',
|
||||||
|
'pages.contact.name': 'Contact',
|
||||||
|
};
|
||||||
|
return translations[key] || key;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should generate navigation items with correct structure', () => {
|
||||||
|
const mockRoute = { path: '/' };
|
||||||
|
|
||||||
|
const items = computed(() => [
|
||||||
|
{
|
||||||
|
label: mockT('pages.home.name'),
|
||||||
|
to: '/',
|
||||||
|
active: mockRoute.path === '/',
|
||||||
|
},
|
||||||
|
...['resume', 'vocal-synthesis', 'languages', 'contact'].map((page) => ({
|
||||||
|
label: mockT(`pages.${page}.name`),
|
||||||
|
to: `/${page}`,
|
||||||
|
active: mockRoute.path.startsWith(`/${page}`),
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(items.value).toHaveLength(5);
|
||||||
|
expect(items.value[0]).toEqual({
|
||||||
|
label: 'Home',
|
||||||
|
to: '/',
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include all required pages', () => {
|
||||||
|
const mockRoute = { path: '/' };
|
||||||
|
|
||||||
|
const items = computed(() => [
|
||||||
|
{
|
||||||
|
label: mockT('pages.home.name'),
|
||||||
|
to: '/',
|
||||||
|
active: mockRoute.path === '/',
|
||||||
|
},
|
||||||
|
...['resume', 'vocal-synthesis', 'languages', 'contact'].map((page) => ({
|
||||||
|
label: mockT(`pages.${page}.name`),
|
||||||
|
to: `/${page}`,
|
||||||
|
active: mockRoute.path.startsWith(`/${page}`),
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const labels = items.value.map((item) => item.label);
|
||||||
|
expect(labels).toContain('Home');
|
||||||
|
expect(labels).toContain('Resume');
|
||||||
|
expect(labels).toContain('Vocal Synthesis');
|
||||||
|
expect(labels).toContain('Languages');
|
||||||
|
expect(labels).toContain('Contact');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark home as active when on root path', () => {
|
||||||
|
const mockRoute = { path: '/' };
|
||||||
|
|
||||||
|
const items = computed(() => [
|
||||||
|
{
|
||||||
|
label: mockT('pages.home.name'),
|
||||||
|
to: '/',
|
||||||
|
active: mockRoute.path === '/',
|
||||||
|
},
|
||||||
|
...['resume', 'vocal-synthesis', 'languages', 'contact'].map((page) => ({
|
||||||
|
label: mockT(`pages.${page}.name`),
|
||||||
|
to: `/${page}`,
|
||||||
|
active: mockRoute.path.startsWith(`/${page}`),
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(items.value[0].active).toBe(true);
|
||||||
|
expect(items.value[1].active).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark resume as active when on resume path', () => {
|
||||||
|
const mockRoute = { path: '/resume' };
|
||||||
|
|
||||||
|
const items = computed(() => [
|
||||||
|
{
|
||||||
|
label: mockT('pages.home.name'),
|
||||||
|
to: '/',
|
||||||
|
active: mockRoute.path === '/',
|
||||||
|
},
|
||||||
|
...['resume', 'vocal-synthesis', 'languages', 'contact'].map((page) => ({
|
||||||
|
label: mockT(`pages.${page}.name`),
|
||||||
|
to: `/${page}`,
|
||||||
|
active: mockRoute.path.startsWith(`/${page}`),
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(items.value[0].active).toBe(false);
|
||||||
|
expect(items.value[1].active).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark vocal-synthesis as active for subpages', () => {
|
||||||
|
const mockRoute = { path: '/vocal-synthesis/project' };
|
||||||
|
|
||||||
|
const items = computed(() => [
|
||||||
|
{
|
||||||
|
label: mockT('pages.home.name'),
|
||||||
|
to: '/',
|
||||||
|
active: mockRoute.path === '/',
|
||||||
|
},
|
||||||
|
...['resume', 'vocal-synthesis', 'languages', 'contact'].map((page) => ({
|
||||||
|
label: mockT(`pages.${page}.name`),
|
||||||
|
to: `/${page}`,
|
||||||
|
active: mockRoute.path.startsWith(`/${page}`),
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(items.value[2].active).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
104
app/components/Ui/BadgeList.test.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { mountSuspended } from '@nuxt/test-utils/runtime';
|
||||||
|
import BadgeList from './BadgeList.vue';
|
||||||
|
import type { Tool } from '~/types/tool';
|
||||||
|
|
||||||
|
describe('BadgeList', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('should render nothing when tools is empty', async () => {
|
||||||
|
const wrapper = await mountSuspended(BadgeList, {
|
||||||
|
props: {
|
||||||
|
tools: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Empty array still renders the container
|
||||||
|
expect(wrapper.find('.flex').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render badges for each tool', async () => {
|
||||||
|
const tools: Tool[] = [{ name: 'TypeScript' }, { name: 'Vue.js' }, { name: 'Nuxt' }];
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(BadgeList, {
|
||||||
|
props: { tools },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('TypeScript');
|
||||||
|
expect(wrapper.text()).toContain('Vue.js');
|
||||||
|
expect(wrapper.text()).toContain('Nuxt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render tool name without link when link is not provided', async () => {
|
||||||
|
const tools: Tool[] = [{ name: 'Plain Tool' }];
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(BadgeList, {
|
||||||
|
props: { tools },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Plain Tool');
|
||||||
|
// Should not have a NuxtLink for this tool
|
||||||
|
const links = wrapper.findAll('a');
|
||||||
|
const plainToolLinks = links.filter((link) => link.text().includes('Plain Tool'));
|
||||||
|
expect(plainToolLinks.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render tool name with link when link is provided', async () => {
|
||||||
|
const tools: Tool[] = [{ name: 'Linked Tool', link: 'https://example.com' }];
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(BadgeList, {
|
||||||
|
props: { tools },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Linked Tool');
|
||||||
|
// Should have a link
|
||||||
|
const link = wrapper.find('a');
|
||||||
|
expect(link.exists()).toBe(true);
|
||||||
|
expect(link.attributes('href')).toBe('https://example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should open links in new tab', async () => {
|
||||||
|
const tools: Tool[] = [{ name: 'External', link: 'https://example.com' }];
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(BadgeList, {
|
||||||
|
props: { tools },
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = wrapper.find('a');
|
||||||
|
expect(link.attributes('target')).toBe('_blank');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('props', () => {
|
||||||
|
it('should accept tools prop', async () => {
|
||||||
|
const tools: Tool[] = [{ name: 'Test' }];
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(BadgeList, {
|
||||||
|
props: { tools },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.props('tools')).toEqual(tools);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mixed tools', () => {
|
||||||
|
it('should render both linked and non-linked tools correctly', async () => {
|
||||||
|
const tools: Tool[] = [
|
||||||
|
{ name: 'With Link', link: 'https://example.com' },
|
||||||
|
{ name: 'Without Link' },
|
||||||
|
{ name: 'Another Link', link: 'https://another.com' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(BadgeList, {
|
||||||
|
props: { tools },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('With Link');
|
||||||
|
expect(wrapper.text()).toContain('Without Link');
|
||||||
|
expect(wrapper.text()).toContain('Another Link');
|
||||||
|
|
||||||
|
// Should have exactly 2 links
|
||||||
|
const links = wrapper.findAll('a');
|
||||||
|
expect(links.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="tools" class="flex flex-row gap-1 flex-wrap">
|
<div v-if="tools" class="flex flex-row gap-1 flex-wrap">
|
||||||
<UBadge v-for="tool in tools" :key="tool" size="md" variant="solid">
|
<UBadge v-for="tool in tools" :key="tool.name" size="md" variant="solid">
|
||||||
{{ tool }}
|
<span v-if="tool.link">
|
||||||
|
<NuxtLink :to="tool.link" target="_blank">
|
||||||
|
{{ tool.name }}
|
||||||
|
</NuxtLink>
|
||||||
|
</span>
|
||||||
|
<span v-else>{{ tool.name }}</span>
|
||||||
</UBadge>
|
</UBadge>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { Tool } from '../../types/tool';
|
||||||
|
|
||||||
const { tools } = defineProps<{
|
const { tools } = defineProps<{
|
||||||
tools: string[];
|
tools: Tool[];
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
109
app/components/Ui/BadgeListCard.test.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { mountSuspended } from '@nuxt/test-utils/runtime';
|
||||||
|
import BadgeListCard from './BadgeListCard.vue';
|
||||||
|
import type { Tool } from '~/types/tool';
|
||||||
|
|
||||||
|
describe('BadgeListCard', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('should render the card container', async () => {
|
||||||
|
const tools: Tool[] = [{ name: 'Test Tool' }];
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(BadgeListCard, {
|
||||||
|
props: { tools },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.find('.my-10').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render slot content', async () => {
|
||||||
|
const tools: Tool[] = [{ name: 'Test Tool' }];
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(BadgeListCard, {
|
||||||
|
props: { tools },
|
||||||
|
slots: {
|
||||||
|
default: 'Card Title',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Card Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render tools via BadgeList component', async () => {
|
||||||
|
const tools: Tool[] = [{ name: 'Tool A' }, { name: 'Tool B', link: 'https://example.com' }];
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(BadgeListCard, {
|
||||||
|
props: { tools },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Tool A');
|
||||||
|
expect(wrapper.text()).toContain('Tool B');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('props', () => {
|
||||||
|
it('should accept tools prop', async () => {
|
||||||
|
const tools: Tool[] = [{ name: 'Test' }];
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(BadgeListCard, {
|
||||||
|
props: { tools },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.props('tools')).toEqual(tools);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass tools to BadgeList child component', async () => {
|
||||||
|
const tools: Tool[] = [
|
||||||
|
{ name: 'TypeScript', link: 'https://typescriptlang.org' },
|
||||||
|
{ name: 'Vue.js', link: 'https://vuejs.org' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(BadgeListCard, {
|
||||||
|
props: { tools },
|
||||||
|
});
|
||||||
|
|
||||||
|
// BadgeList should render all tools
|
||||||
|
expect(wrapper.text()).toContain('TypeScript');
|
||||||
|
expect(wrapper.text()).toContain('Vue.js');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('slots', () => {
|
||||||
|
it('should render default slot in title position', async () => {
|
||||||
|
const tools: Tool[] = [{ name: 'Test' }];
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(BadgeListCard, {
|
||||||
|
props: { tools },
|
||||||
|
slots: {
|
||||||
|
default: '<strong>Programming Languages</strong>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.find('strong').exists()).toBe(true);
|
||||||
|
expect(wrapper.text()).toContain('Programming Languages');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work without slot content', async () => {
|
||||||
|
const tools: Tool[] = [{ name: 'Test' }];
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(BadgeListCard, {
|
||||||
|
props: { tools },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should still render without errors
|
||||||
|
expect(wrapper.text()).toContain('Test');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('styling', () => {
|
||||||
|
it('should have vertical margin class', async () => {
|
||||||
|
const tools: Tool[] = [{ name: 'Test' }];
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(BadgeListCard, {
|
||||||
|
props: { tools },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Card should have my-10 class for vertical spacing
|
||||||
|
expect(wrapper.find('.my-10').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { Tool } from '~/types/tool';
|
||||||
|
|
||||||
const { tools } = defineProps<{
|
const { tools } = defineProps<{
|
||||||
tools: string[];
|
tools: Tool[];
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
171
app/components/VocalSynth/Projects.test.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { mountSuspended } from '@nuxt/test-utils/runtime';
|
||||||
|
import Projects from './Projects.vue';
|
||||||
|
import type { VocalSynthPage } from '~/types/vocal-synth';
|
||||||
|
|
||||||
|
// Mock $t function
|
||||||
|
vi.stubGlobal('$t', (key: string) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'pages.vocal-synthesis.projects': 'Projects',
|
||||||
|
};
|
||||||
|
return translations[key] || key;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('VocalSynth Projects', () => {
|
||||||
|
describe('external URL detection logic', () => {
|
||||||
|
const external = (url: string) => url.startsWith('http');
|
||||||
|
|
||||||
|
it('should return true for http URLs', () => {
|
||||||
|
expect(external('http://example.com')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for https URLs', () => {
|
||||||
|
expect(external('https://example.com')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for relative URLs', () => {
|
||||||
|
expect(external('/keine-tashi')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for paths without protocol', () => {
|
||||||
|
expect(external('/vocal-synthesis/project')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('component rendering', () => {
|
||||||
|
it('should render the component', async () => {
|
||||||
|
const pageData: VocalSynthPage = {
|
||||||
|
projects: [],
|
||||||
|
tools: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(Projects, {
|
||||||
|
global: {
|
||||||
|
provide: {
|
||||||
|
pageData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display projects title', async () => {
|
||||||
|
const pageData: VocalSynthPage = {
|
||||||
|
projects: [],
|
||||||
|
tools: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(Projects, {
|
||||||
|
global: {
|
||||||
|
provide: {
|
||||||
|
pageData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Projects');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render projects from injected data', async () => {
|
||||||
|
const pageData: VocalSynthPage = {
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
title: 'Test Project',
|
||||||
|
icon: 'mdi:music',
|
||||||
|
description: 'A test vocal synthesis project',
|
||||||
|
link: '/test-project',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tools: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(Projects, {
|
||||||
|
global: {
|
||||||
|
provide: {
|
||||||
|
pageData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Test Project');
|
||||||
|
expect(wrapper.text()).toContain('A test vocal synthesis project');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render multiple projects', async () => {
|
||||||
|
const pageData: VocalSynthPage = {
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
title: 'Project One',
|
||||||
|
icon: 'mdi:music',
|
||||||
|
description: 'First project',
|
||||||
|
link: '/project-one',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Project Two',
|
||||||
|
icon: 'mdi:microphone',
|
||||||
|
description: 'Second project',
|
||||||
|
link: 'https://example.com/project-two',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tools: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(Projects, {
|
||||||
|
global: {
|
||||||
|
provide: {
|
||||||
|
pageData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Project One');
|
||||||
|
expect(wrapper.text()).toContain('Project Two');
|
||||||
|
expect(wrapper.text()).toContain('First project');
|
||||||
|
expect(wrapper.text()).toContain('Second project');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render project icons', async () => {
|
||||||
|
const pageData: VocalSynthPage = {
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
title: 'Project with Icon',
|
||||||
|
icon: 'mdi:music-note',
|
||||||
|
description: 'Has an icon',
|
||||||
|
link: '/project',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tools: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(Projects, {
|
||||||
|
global: {
|
||||||
|
provide: {
|
||||||
|
pageData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Icon container should exist
|
||||||
|
expect(wrapper.find('.min-w-13').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty projects array', async () => {
|
||||||
|
const pageData: VocalSynthPage = {
|
||||||
|
projects: [],
|
||||||
|
tools: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(Projects, {
|
||||||
|
global: {
|
||||||
|
provide: {
|
||||||
|
pageData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
expect(wrapper.text()).toContain('Projects');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div class="flex flex-row gap-2 items-baseline">
|
<div class="flex flex-row gap-2 items-baseline">
|
||||||
<ULink :to="project.link" class="text-2xl">
|
<ULink :to="project.link" :target="external(project.link) ? '_blank' : '_self'" class="text-2xl">
|
||||||
{{ project.title }}
|
{{ project.title }}
|
||||||
</ULink>
|
</ULink>
|
||||||
<UIcon v-if="external(project.link)" name="mdi:link" class="size-5" />
|
<UIcon v-if="external(project.link)" name="mdi:link" class="size-5" />
|
||||||
@@ -29,7 +29,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { VocalSynthPage } from '~/types/vocal-synth';
|
||||||
|
|
||||||
// Inject data provided by the page to avoid hydration issues with MDC components
|
// Inject data provided by the page to avoid hydration issues with MDC components
|
||||||
const data = inject('pageData');
|
const data: VocalSynthPage | undefined = inject('pageData');
|
||||||
const external = (url: string) => url.startsWith('http');
|
const external = (url: string) => url.startsWith('http');
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
170
app/components/VocalSynth/Tools.test.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mountSuspended } from '@nuxt/test-utils/runtime';
|
||||||
|
import Tools from './Tools.vue';
|
||||||
|
import type { VocalSynthPage } from '~/types/vocal-synth';
|
||||||
|
|
||||||
|
// Mock $t function
|
||||||
|
vi.stubGlobal('$t', (key: string) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'pages.vocal-synthesis.tools': 'Tools',
|
||||||
|
};
|
||||||
|
return translations[key] || key;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('VocalSynth Tools', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('should render the component when data is provided', async () => {
|
||||||
|
const pageData: VocalSynthPage = {
|
||||||
|
projects: [],
|
||||||
|
tools: [{ name: 'UTAU' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(Tools, {
|
||||||
|
global: {
|
||||||
|
provide: {
|
||||||
|
pageData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render tools title', async () => {
|
||||||
|
const pageData: VocalSynthPage = {
|
||||||
|
projects: [],
|
||||||
|
tools: [{ name: 'UTAU' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(Tools, {
|
||||||
|
global: {
|
||||||
|
provide: {
|
||||||
|
pageData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Tools');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render tools from injected data', async () => {
|
||||||
|
const pageData: VocalSynthPage = {
|
||||||
|
projects: [],
|
||||||
|
tools: [
|
||||||
|
{ name: 'UTAU', link: 'https://utau.com' },
|
||||||
|
{ name: 'OpenUtau', link: 'https://openutau.com' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(Tools, {
|
||||||
|
global: {
|
||||||
|
provide: {
|
||||||
|
pageData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('UTAU');
|
||||||
|
expect(wrapper.text()).toContain('OpenUtau');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('conditional rendering', () => {
|
||||||
|
it('should not render when data is undefined', async () => {
|
||||||
|
const wrapper = await mountSuspended(Tools, {
|
||||||
|
global: {
|
||||||
|
provide: {
|
||||||
|
pageData: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Component should exist but content may be hidden
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render when data has tools', async () => {
|
||||||
|
const pageData: VocalSynthPage = {
|
||||||
|
projects: [],
|
||||||
|
tools: [{ name: 'Tool A' }, { name: 'Tool B' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(Tools, {
|
||||||
|
global: {
|
||||||
|
provide: {
|
||||||
|
pageData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Tool A');
|
||||||
|
expect(wrapper.text()).toContain('Tool B');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BadgeListCard integration', () => {
|
||||||
|
it('should pass tools to BadgeListCard', async () => {
|
||||||
|
const pageData: VocalSynthPage = {
|
||||||
|
projects: [],
|
||||||
|
tools: [{ name: 'Synth Tool', link: 'https://synth.example.com' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(Tools, {
|
||||||
|
global: {
|
||||||
|
provide: {
|
||||||
|
pageData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Synth Tool');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tool links', () => {
|
||||||
|
it('should render tools with links', async () => {
|
||||||
|
const pageData: VocalSynthPage = {
|
||||||
|
projects: [],
|
||||||
|
tools: [{ name: 'Linked Tool', link: 'https://example.com' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(Tools, {
|
||||||
|
global: {
|
||||||
|
provide: {
|
||||||
|
pageData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Linked Tool');
|
||||||
|
// Link should be rendered by BadgeList
|
||||||
|
const link = wrapper.find('a[href="https://example.com"]');
|
||||||
|
expect(link.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render tools without links', async () => {
|
||||||
|
const pageData: VocalSynthPage = {
|
||||||
|
projects: [],
|
||||||
|
tools: [{ name: 'Plain Tool' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(Tools, {
|
||||||
|
global: {
|
||||||
|
provide: {
|
||||||
|
pageData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Plain Tool');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
15
app/components/footer/SocialAccount.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<template>
|
||||||
|
<UButton
|
||||||
|
:icon="props.icon"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
:to="props.link"
|
||||||
|
target="_blank"
|
||||||
|
:aria-label="props.label"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { SocialAccount } from '~/types/social-account';
|
||||||
|
const props = defineProps<SocialAccount>();
|
||||||
|
</script>
|
||||||
64
app/components/navbar/LanguageSwitcher.test.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
describe('LanguageSwitcher', () => {
|
||||||
|
describe('computed availableLocales', () => {
|
||||||
|
it('should generate dropdown items from locales', () => {
|
||||||
|
const mockLocale = ref('en');
|
||||||
|
const mockLocales = ref([
|
||||||
|
{ code: 'en', name: 'English' },
|
||||||
|
{ code: 'fr', name: 'Français' },
|
||||||
|
]);
|
||||||
|
const mockSetLocale = vi.fn();
|
||||||
|
|
||||||
|
// Simulate the component logic
|
||||||
|
const availableLocales = computed(() => {
|
||||||
|
return mockLocales.value.map((optionLocale) => ({
|
||||||
|
label: optionLocale.name,
|
||||||
|
code: optionLocale.code,
|
||||||
|
type: 'checkbox' as const,
|
||||||
|
checked: optionLocale.code === mockLocale.value,
|
||||||
|
onUpdateChecked: () => mockSetLocale(optionLocale.code),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(availableLocales.value).toHaveLength(2);
|
||||||
|
expect(availableLocales.value[0].label).toBe('English');
|
||||||
|
expect(availableLocales.value[0].checked).toBe(true);
|
||||||
|
expect(availableLocales.value[1].label).toBe('Français');
|
||||||
|
expect(availableLocales.value[1].checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark current locale as checked', () => {
|
||||||
|
const mockLocale = ref('fr');
|
||||||
|
const mockLocales = ref([
|
||||||
|
{ code: 'en', name: 'English' },
|
||||||
|
{ code: 'fr', name: 'Français' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const availableLocales = computed(() => {
|
||||||
|
return mockLocales.value.map((optionLocale) => ({
|
||||||
|
label: optionLocale.name,
|
||||||
|
code: optionLocale.code,
|
||||||
|
type: 'checkbox' as const,
|
||||||
|
checked: optionLocale.code === mockLocale.value,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(availableLocales.value[0].checked).toBe(false);
|
||||||
|
expect(availableLocales.value[1].checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call setLocale when switching', () => {
|
||||||
|
const mockSetLocale = vi.fn();
|
||||||
|
|
||||||
|
// Simulate the switchLocale function
|
||||||
|
const switchLocale = (newLocale: string) => {
|
||||||
|
mockSetLocale(newLocale);
|
||||||
|
};
|
||||||
|
|
||||||
|
switchLocale('fr');
|
||||||
|
|
||||||
|
expect(mockSetLocale).toHaveBeenCalledWith('fr');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
83
app/components/navbar/ThemeSwitcher.test.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
describe('ThemeSwitcher', () => {
|
||||||
|
describe('icon mapping', () => {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
light: 'material-symbols:light-mode',
|
||||||
|
dark: 'material-symbols:dark-mode',
|
||||||
|
system: 'material-symbols:computer-outline',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should have correct icon for light theme', () => {
|
||||||
|
expect(icons.light).toBe('material-symbols:light-mode');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct icon for dark theme', () => {
|
||||||
|
expect(icons.dark).toBe('material-symbols:dark-mode');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct icon for system theme', () => {
|
||||||
|
expect(icons.system).toBe('material-symbols:computer-outline');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computed currentColor', () => {
|
||||||
|
it('should return preference when set', () => {
|
||||||
|
const mockColorMode = reactive({ preference: 'dark' as 'light' | 'dark' | 'system' });
|
||||||
|
const currentColor = computed(() => mockColorMode.preference ?? 'system');
|
||||||
|
|
||||||
|
expect(currentColor.value).toBe('dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return system as default', () => {
|
||||||
|
const mockColorMode = reactive({ preference: 'system' as 'light' | 'dark' | 'system' });
|
||||||
|
const currentColor = computed(() => mockColorMode.preference ?? 'system');
|
||||||
|
|
||||||
|
expect(currentColor.value).toBe('system');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computed themes', () => {
|
||||||
|
it('should generate theme options with correct structure', () => {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
light: 'material-symbols:light-mode',
|
||||||
|
dark: 'material-symbols:dark-mode',
|
||||||
|
system: 'material-symbols:computer-outline',
|
||||||
|
};
|
||||||
|
const mockColorMode = reactive({ preference: 'light' as 'light' | 'dark' | 'system' });
|
||||||
|
const currentColor = computed(() => mockColorMode.preference ?? 'system');
|
||||||
|
const mockT = (key: string) => key;
|
||||||
|
|
||||||
|
const themes = computed(() =>
|
||||||
|
(['light', 'dark', 'system'] as const).map((theme) => ({
|
||||||
|
code: theme,
|
||||||
|
label: mockT(`theme.${theme}`),
|
||||||
|
icon: icons[theme],
|
||||||
|
type: 'checkbox' as const,
|
||||||
|
checked: currentColor.value === theme,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(themes.value).toHaveLength(3);
|
||||||
|
expect(themes.value[0]!.code).toBe('light');
|
||||||
|
expect(themes.value[0]!.checked).toBe(true);
|
||||||
|
expect(themes.value[1]!.code).toBe('dark');
|
||||||
|
expect(themes.value[1]!.checked).toBe(false);
|
||||||
|
expect(themes.value[2]!.code).toBe('system');
|
||||||
|
expect(themes.value[2]!.checked).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('switchColor', () => {
|
||||||
|
it('should update colorMode.preference when called', () => {
|
||||||
|
const mockColorMode = reactive({ preference: 'system' as 'light' | 'dark' | 'system' });
|
||||||
|
const switchColor = (theme: 'light' | 'dark' | 'system') => {
|
||||||
|
mockColorMode.preference = theme;
|
||||||
|
};
|
||||||
|
|
||||||
|
switchColor('dark');
|
||||||
|
|
||||||
|
expect(mockColorMode.preference).toBe('dark');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
349
app/composables/useApi.test.ts
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { nextTick } from 'vue';
|
||||||
|
import type { FetchError } from 'ofetch';
|
||||||
|
import type { ApiError } from '~/types/api/error';
|
||||||
|
import { useApi } from './useApi';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('#app', () => ({
|
||||||
|
useRuntimeConfig: vi.fn(() => ({
|
||||||
|
public: {
|
||||||
|
apiBase: 'http://localhost:3100/api',
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock $fetch globally
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
vi.stubGlobal('$fetch', mockFetch);
|
||||||
|
|
||||||
|
describe('useApi', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET requests', () => {
|
||||||
|
it('should make a GET request and populate data on success', async () => {
|
||||||
|
const mockData = { id: 1, name: 'Test' };
|
||||||
|
mockFetch.mockResolvedValueOnce(mockData);
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
const result = api.get<typeof mockData>('/test');
|
||||||
|
|
||||||
|
// Should start loading
|
||||||
|
await nextTick();
|
||||||
|
expect(result.loading.value).toBe(false); // Immediate execution completes quickly
|
||||||
|
|
||||||
|
// Wait for the async operation
|
||||||
|
await vi.waitFor(() => expect(result.data.value).toStrictEqual(mockData));
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/test', {
|
||||||
|
baseURL: 'http://localhost:3100/api',
|
||||||
|
method: 'GET',
|
||||||
|
body: undefined,
|
||||||
|
});
|
||||||
|
expect(result.data.value).toEqual(mockData);
|
||||||
|
expect(result.error.value).toBeNull();
|
||||||
|
expect(result.loading.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle GET request with custom options', async () => {
|
||||||
|
const mockData = { result: 'success' };
|
||||||
|
mockFetch.mockResolvedValueOnce(mockData);
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
const result = api.get('/test', { headers: { 'X-Custom': 'header' } });
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(result.data.value).toStrictEqual(mockData));
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/test', {
|
||||||
|
baseURL: 'http://localhost:3100/api',
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'X-Custom': 'header' },
|
||||||
|
body: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not execute immediately when immediate is false', async () => {
|
||||||
|
const api = useApi();
|
||||||
|
const result = api.get('/test', {}, false);
|
||||||
|
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
expect(result.data.value).toBeNull();
|
||||||
|
expect(result.loading.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should execute when run() is called manually', async () => {
|
||||||
|
const mockData = { manual: true };
|
||||||
|
mockFetch.mockResolvedValueOnce(mockData);
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
const result = api.get('/test', {}, false);
|
||||||
|
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await result.run();
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/test', {
|
||||||
|
baseURL: 'http://localhost:3100/api',
|
||||||
|
method: 'GET',
|
||||||
|
body: undefined,
|
||||||
|
});
|
||||||
|
expect(result.data.value).toEqual(mockData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE requests', () => {
|
||||||
|
it('should make a DELETE request', async () => {
|
||||||
|
const mockData = { deleted: true };
|
||||||
|
mockFetch.mockResolvedValueOnce(mockData);
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
const result = api.del<typeof mockData>('/test/1');
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(result.data.value).toStrictEqual(mockData));
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/test/1', {
|
||||||
|
baseURL: 'http://localhost:3100/api',
|
||||||
|
method: 'DELETE',
|
||||||
|
body: undefined,
|
||||||
|
});
|
||||||
|
expect(result.data.value).toEqual(mockData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST requests', () => {
|
||||||
|
it('should make a POST request with body', async () => {
|
||||||
|
const mockResponse = { id: 1, created: true };
|
||||||
|
const requestBody = { name: 'New Item' };
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
const result = api.post<typeof mockResponse, typeof requestBody>('/test', {}, true, requestBody);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(result.data.value).toStrictEqual(mockResponse));
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/test', {
|
||||||
|
baseURL: 'http://localhost:3100/api',
|
||||||
|
method: 'POST',
|
||||||
|
body: requestBody,
|
||||||
|
});
|
||||||
|
expect(result.data.value).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow run() to be called with a different body', async () => {
|
||||||
|
const mockResponse = { success: true };
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
const result = api.post<typeof mockResponse, { data: string }>('/test', {}, false);
|
||||||
|
|
||||||
|
const body = { data: 'runtime-data' };
|
||||||
|
await result.run(body);
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/test', {
|
||||||
|
baseURL: 'http://localhost:3100/api',
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
expect(result.data.value).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT requests', () => {
|
||||||
|
it('should make a PUT request with body', async () => {
|
||||||
|
const mockResponse = { updated: true };
|
||||||
|
const requestBody = { name: 'Updated Item' };
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
const result = api.put<typeof mockResponse, typeof requestBody>('/test/1', {}, true, requestBody);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(result.data.value).toStrictEqual(mockResponse));
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/test/1', {
|
||||||
|
baseURL: 'http://localhost:3100/api',
|
||||||
|
method: 'PUT',
|
||||||
|
body: requestBody,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH requests', () => {
|
||||||
|
it('should make a PATCH request with body', async () => {
|
||||||
|
const mockResponse = { patched: true };
|
||||||
|
const requestBody = { field: 'value' };
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse);
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
const result = api.patch<typeof mockResponse, typeof requestBody>('/test/1', {}, true, requestBody);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(result.data.value).toStrictEqual(mockResponse));
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('/test/1', {
|
||||||
|
baseURL: 'http://localhost:3100/api',
|
||||||
|
method: 'PATCH',
|
||||||
|
body: requestBody,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error handling', () => {
|
||||||
|
it('should handle fetch errors with ApiError response', async () => {
|
||||||
|
const apiError: ApiError = {
|
||||||
|
message: 'backend.errors.not_found',
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchError: Partial<FetchError> = {
|
||||||
|
message: 'Fetch Error',
|
||||||
|
response: {
|
||||||
|
_data: apiError,
|
||||||
|
} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFetch.mockRejectedValueOnce(fetchError);
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
const result = api.get('/test');
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(result.error.value).not.toBeNull());
|
||||||
|
|
||||||
|
expect(result.data.value).toBeNull();
|
||||||
|
expect(result.error.value).toEqual(apiError);
|
||||||
|
expect(result.loading.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle fetch errors without ApiError response', async () => {
|
||||||
|
const fetchError: Partial<FetchError> = {
|
||||||
|
message: 'Network Error',
|
||||||
|
response: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFetch.mockRejectedValueOnce(fetchError);
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
const result = api.get('/test');
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(result.error.value).not.toBeNull());
|
||||||
|
|
||||||
|
expect(result.error.value).toEqual({
|
||||||
|
message: 'Network Error',
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default error message when fetch error has no message', async () => {
|
||||||
|
const fetchError: Partial<FetchError> = {
|
||||||
|
message: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFetch.mockRejectedValueOnce(fetchError);
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
const result = api.get('/test');
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(result.error.value).not.toBeNull());
|
||||||
|
|
||||||
|
expect(result.error.value).toEqual({
|
||||||
|
message: 'backend.errors.unknown',
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear previous errors on new request', async () => {
|
||||||
|
const fetchError: Partial<FetchError> = {
|
||||||
|
message: 'First Error',
|
||||||
|
};
|
||||||
|
const mockData = { success: true };
|
||||||
|
|
||||||
|
// First request fails
|
||||||
|
mockFetch.mockRejectedValueOnce(fetchError);
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
const result = api.get('/test', {}, false);
|
||||||
|
|
||||||
|
await result.run();
|
||||||
|
await vi.waitFor(() => expect(result.error.value).not.toBeNull());
|
||||||
|
expect(result.error.value?.message).toBe('First Error');
|
||||||
|
|
||||||
|
// Second request succeeds
|
||||||
|
mockFetch.mockResolvedValueOnce(mockData);
|
||||||
|
await result.run();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(result.data.value).toStrictEqual(mockData));
|
||||||
|
expect(result.error.value).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Loading state', () => {
|
||||||
|
it('should set loading to true during request', async () => {
|
||||||
|
let resolvePromise: (value: any) => void;
|
||||||
|
const promise = new Promise((resolve) => {
|
||||||
|
resolvePromise = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
mockFetch.mockReturnValueOnce(promise as any);
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
const result = api.get('/test', {}, false);
|
||||||
|
|
||||||
|
expect(result.loading.value).toBe(false);
|
||||||
|
|
||||||
|
const runPromise = result.run();
|
||||||
|
|
||||||
|
// Should be loading
|
||||||
|
await nextTick();
|
||||||
|
expect(result.loading.value).toBe(true);
|
||||||
|
|
||||||
|
// Resolve the request
|
||||||
|
resolvePromise!({ done: true });
|
||||||
|
await runPromise;
|
||||||
|
|
||||||
|
expect(result.loading.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set loading to false after error', async () => {
|
||||||
|
let rejectPromise: (error: any) => void;
|
||||||
|
const promise = new Promise((_, reject) => {
|
||||||
|
rejectPromise = reject;
|
||||||
|
});
|
||||||
|
|
||||||
|
mockFetch.mockReturnValueOnce(promise as any);
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
const result = api.get('/test', {}, false);
|
||||||
|
|
||||||
|
const runPromise = result.run();
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
expect(result.loading.value).toBe(true);
|
||||||
|
|
||||||
|
rejectPromise!({ message: 'Error' });
|
||||||
|
await runPromise;
|
||||||
|
|
||||||
|
expect(result.loading.value).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Return type structure', () => {
|
||||||
|
it('should return QueryResult with correct structure', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({ test: 'data' });
|
||||||
|
|
||||||
|
const api = useApi();
|
||||||
|
const result = api.get('/test', {}, false);
|
||||||
|
|
||||||
|
expect(result).toHaveProperty('data');
|
||||||
|
expect(result).toHaveProperty('error');
|
||||||
|
expect(result).toHaveProperty('loading');
|
||||||
|
expect(result).toHaveProperty('run');
|
||||||
|
expect(typeof result.run).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,32 +1,69 @@
|
|||||||
import type { FetchOptions } from 'ofetch';
|
import type { FetchError, FetchOptions } from 'ofetch';
|
||||||
|
import type { ApiError } from '~/types/api/error';
|
||||||
|
import type { HttpMethod } from '~/types/http-method';
|
||||||
|
import { QueryResult } from '~/types/query-result';
|
||||||
|
|
||||||
export const useApi = () => {
|
export type UseApiResponse<T, B = unknown> = QueryResult<T, B>;
|
||||||
const config = useRuntimeConfig();
|
|
||||||
const apiFetch = $fetch.create({
|
|
||||||
baseURL: config.public.apiBase,
|
|
||||||
});
|
|
||||||
|
|
||||||
const get = <T>(url: string, options?: FetchOptions) => apiFetch<T>(url, { method: 'GET', ...options });
|
export interface UseApi {
|
||||||
|
get: <T>(path: string, opts?: FetchOptions, immediate?: boolean) => UseApiResponse<T>;
|
||||||
|
del: <T>(path: string, opts?: FetchOptions, immediate?: boolean) => UseApiResponse<T>;
|
||||||
|
post: <T, B = unknown>(path: string, opts?: FetchOptions, immediate?: boolean, body?: B) => UseApiResponse<T, B>;
|
||||||
|
put: <T, B = unknown>(path: string, opts?: FetchOptions, immediate?: boolean, body?: B) => UseApiResponse<T, B>;
|
||||||
|
patch: <T, B = unknown>(path: string, opts?: FetchOptions, immediate?: boolean, body?: B) => UseApiResponse<T, B>;
|
||||||
|
}
|
||||||
|
|
||||||
const post = <ResultT, PayloadT = Record<string, string | number | boolean>>(
|
const createRequest = <ResponseT = unknown, PayloadT = unknown>(
|
||||||
url: string,
|
method: HttpMethod,
|
||||||
body?: PayloadT,
|
url: string,
|
||||||
options?: FetchOptions,
|
opts?: FetchOptions,
|
||||||
) => apiFetch<ResultT>(url, { method: 'POST', body, ...options });
|
immediate: boolean = true,
|
||||||
|
body?: PayloadT,
|
||||||
|
): QueryResult<ResponseT, PayloadT> => {
|
||||||
|
const response = new QueryResult<ResponseT, PayloadT>();
|
||||||
|
const { apiBase } = useRuntimeConfig().public;
|
||||||
|
|
||||||
const put = <ResultT, PayloadT = Record<string, string | number | boolean>>(
|
const run = async (requestBody?: PayloadT): Promise<void> => {
|
||||||
url: string,
|
response.loading.value = true;
|
||||||
body?: PayloadT,
|
response.error.value = null;
|
||||||
options?: FetchOptions,
|
|
||||||
) => apiFetch<ResultT>(url, { method: 'PUT', body, ...options });
|
|
||||||
|
|
||||||
const patch = <ResultT, PayloadT = Record<string, string | number | boolean>>(
|
try {
|
||||||
url: string,
|
const res = await $fetch<ResponseT>(url, {
|
||||||
body?: PayloadT,
|
baseURL: apiBase,
|
||||||
options?: FetchOptions,
|
...opts,
|
||||||
) => apiFetch<ResultT>(url, { method: 'PATCH', body, ...options });
|
method,
|
||||||
|
body: requestBody ?? undefined,
|
||||||
|
});
|
||||||
|
response.data.value = res;
|
||||||
|
} catch (e) {
|
||||||
|
const fetchError = e as FetchError;
|
||||||
|
const errBody = fetchError?.response?._data as ApiError | undefined;
|
||||||
|
response.error.value = errBody ?? {
|
||||||
|
message: fetchError.message || 'backend.errors.unknown',
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
response.loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
response.run = run;
|
||||||
|
|
||||||
const del = <T>(url: string, options?: FetchOptions) => apiFetch<T>(url, { method: 'DELETE', ...options });
|
if (immediate) run(body);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useApi = (): UseApi => {
|
||||||
|
const get = <T>(path: string, opts?: FetchOptions, immediate: boolean = true) =>
|
||||||
|
createRequest<T>('GET', path, opts, immediate);
|
||||||
|
const del = <T>(path: string, opts?: FetchOptions, immediate: boolean = true) =>
|
||||||
|
createRequest<T>('DELETE', path, opts, immediate);
|
||||||
|
const post = <T, B = unknown>(path: string, opts?: FetchOptions, immediate: boolean = true, body?: B) =>
|
||||||
|
createRequest<T, B>('POST', path, opts, immediate, body);
|
||||||
|
const put = <T, B = unknown>(path: string, opts?: FetchOptions, immediate: boolean = true, body?: B) =>
|
||||||
|
createRequest<T, B>('PUT', path, opts, immediate, body);
|
||||||
|
const patch = <T, B = unknown>(path: string, opts?: FetchOptions, immediate: boolean = true, body?: B) =>
|
||||||
|
createRequest<T, B>('PATCH', path, opts, immediate, body);
|
||||||
|
|
||||||
return { get, post, put, patch, del };
|
return { get, post, put, patch, del };
|
||||||
};
|
};
|
||||||
|
|||||||
97
app/composables/useBackend.test.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { useBackend } from './useBackend';
|
||||||
|
import type { MetaResponse } from '~/types/api/meta';
|
||||||
|
import type { ContactResponse } from '~/types/api/contact';
|
||||||
|
|
||||||
|
// Mock useApi
|
||||||
|
const mockGet = vi.fn();
|
||||||
|
const mockPost = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('./useApi', () => ({
|
||||||
|
useApi: vi.fn(() => ({
|
||||||
|
get: mockGet,
|
||||||
|
post: mockPost,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('useBackend', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getMeta', () => {
|
||||||
|
it('should call useApi.get with /meta endpoint', () => {
|
||||||
|
const mockResult = {
|
||||||
|
data: ref<MetaResponse | null>({ version: '1.0.0', name: 'Test' }),
|
||||||
|
error: ref(null),
|
||||||
|
loading: ref(false),
|
||||||
|
run: vi.fn(),
|
||||||
|
};
|
||||||
|
mockGet.mockReturnValue(mockResult);
|
||||||
|
|
||||||
|
const { getMeta } = useBackend();
|
||||||
|
const result = getMeta();
|
||||||
|
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/meta');
|
||||||
|
expect(result).toBe(mockResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return UseApiResponse with correct structure', () => {
|
||||||
|
const mockResult = {
|
||||||
|
data: ref<MetaResponse | null>(null),
|
||||||
|
error: ref(null),
|
||||||
|
loading: ref(false),
|
||||||
|
run: vi.fn(),
|
||||||
|
};
|
||||||
|
mockGet.mockReturnValue(mockResult);
|
||||||
|
|
||||||
|
const { getMeta } = useBackend();
|
||||||
|
const result = getMeta();
|
||||||
|
|
||||||
|
expect(result).toHaveProperty('data');
|
||||||
|
expect(result).toHaveProperty('error');
|
||||||
|
expect(result).toHaveProperty('loading');
|
||||||
|
expect(result).toHaveProperty('run');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('postContact', () => {
|
||||||
|
it('should call useApi.post with /contact endpoint and immediate=false', () => {
|
||||||
|
const mockResult = {
|
||||||
|
data: ref<ContactResponse | null>(null),
|
||||||
|
error: ref(null),
|
||||||
|
loading: ref(false),
|
||||||
|
run: vi.fn(),
|
||||||
|
};
|
||||||
|
mockPost.mockReturnValue(mockResult);
|
||||||
|
|
||||||
|
const { postContact } = useBackend();
|
||||||
|
const result = postContact();
|
||||||
|
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/contact', undefined, false);
|
||||||
|
expect(result).toBe(mockResult);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return UseApiResponse with correct structure', () => {
|
||||||
|
const mockResult = {
|
||||||
|
data: ref<ContactResponse | null>(null),
|
||||||
|
error: ref(null),
|
||||||
|
loading: ref(false),
|
||||||
|
run: vi.fn(),
|
||||||
|
};
|
||||||
|
mockPost.mockReturnValue(mockResult);
|
||||||
|
|
||||||
|
const { postContact } = useBackend();
|
||||||
|
const result = postContact();
|
||||||
|
|
||||||
|
expect(result).toHaveProperty('data');
|
||||||
|
expect(result).toHaveProperty('error');
|
||||||
|
expect(result).toHaveProperty('loading');
|
||||||
|
expect(result).toHaveProperty('run');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
|
import type { ContactRequest, ContactResponse } from '~/types/api/contact';
|
||||||
|
import type { MetaResponse } from '~/types/api/meta';
|
||||||
|
import type { UseApiResponse } from './useApi';
|
||||||
|
|
||||||
export const useBackend = () => {
|
export const useBackend = () => {
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
|
||||||
const getMeta = () => api.get<MetaResponse>('/meta');
|
const getMeta = (): UseApiResponse<MetaResponse> => api.get<MetaResponse>('/meta');
|
||||||
const postContact = (contact: ContactRequest) => api.post<ContactRequest, ContactResponse>('/contact', contact);
|
const postContact = (): UseApiResponse<ContactResponse, ContactRequest> =>
|
||||||
|
api.post<ContactResponse, ContactRequest>('/contact', undefined, false);
|
||||||
|
|
||||||
return { getMeta, postContact };
|
return { getMeta, postContact };
|
||||||
};
|
};
|
||||||
|
|||||||
187
app/composables/useDataJson.test.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { withLeadingSlash } from 'ufo';
|
||||||
|
|
||||||
|
describe('useDataJson', () => {
|
||||||
|
describe('withLeadingSlash utility', () => {
|
||||||
|
it('should add leading slash to path without one', () => {
|
||||||
|
expect(withLeadingSlash('test-page')).toBe('/test-page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve leading slash if already present', () => {
|
||||||
|
expect(withLeadingSlash('/test-page')).toBe('/test-page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string', () => {
|
||||||
|
expect(withLeadingSlash('')).toBe('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex paths', () => {
|
||||||
|
expect(withLeadingSlash('vocal-synthesis/keine-tashi')).toBe('/vocal-synthesis/keine-tashi');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('slug computation logic', () => {
|
||||||
|
it('should convert array slug to string with leading slash', () => {
|
||||||
|
const slugParam = ['vocal-synthesis', 'keine-tashi'];
|
||||||
|
const slug = withLeadingSlash(String(slugParam));
|
||||||
|
expect(slug).toBe('/vocal-synthesis,keine-tashi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use route path as fallback when no slug', () => {
|
||||||
|
const slugParam = '';
|
||||||
|
const routePath = '/fallback-path';
|
||||||
|
const slug = withLeadingSlash(String(slugParam || routePath));
|
||||||
|
expect(slug).toBe('/fallback-path');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer slug param over route path', () => {
|
||||||
|
const slugParam = 'my-page';
|
||||||
|
const routePath = '/different-path';
|
||||||
|
const slug = withLeadingSlash(String(slugParam || routePath));
|
||||||
|
expect(slug).toBe('/my-page');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('key computation logic', () => {
|
||||||
|
it('should create cache key from prefix and slug', () => {
|
||||||
|
const prefix = 'page';
|
||||||
|
const slug = '/test-page';
|
||||||
|
const key = prefix + '-' + slug;
|
||||||
|
expect(key).toBe('page-/test-page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create unique keys for different prefixes', () => {
|
||||||
|
const slug = '/resume';
|
||||||
|
const pageKey = 'page' + '-' + slug;
|
||||||
|
const dataKey = 'page-data' + '-' + slug;
|
||||||
|
expect(pageKey).not.toBe(dataKey);
|
||||||
|
expect(pageKey).toBe('page-/resume');
|
||||||
|
expect(dataKey).toBe('page-data-/resume');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('collection name construction', () => {
|
||||||
|
it('should construct collection name from prefix and locale', () => {
|
||||||
|
const collectionPrefix = 'content_';
|
||||||
|
const locale = 'en';
|
||||||
|
const collection = collectionPrefix + locale;
|
||||||
|
expect(collection).toBe('content_en');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle French locale', () => {
|
||||||
|
const collectionPrefix = 'content_';
|
||||||
|
const locale = 'fr';
|
||||||
|
const collection = collectionPrefix + locale;
|
||||||
|
expect(collection).toBe('content_fr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle data collection prefix', () => {
|
||||||
|
const collectionPrefix = 'content_data_';
|
||||||
|
const locale = 'en';
|
||||||
|
const collection = collectionPrefix + locale;
|
||||||
|
expect(collection).toBe('content_data_en');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getData options structure', () => {
|
||||||
|
it('should support useFilter option', () => {
|
||||||
|
const options = { useFilter: true };
|
||||||
|
expect(options.useFilter).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support fallbackToEnglish option', () => {
|
||||||
|
const options = { fallbackToEnglish: true };
|
||||||
|
expect(options.fallbackToEnglish).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support extractMeta option', () => {
|
||||||
|
const options = { extractMeta: true };
|
||||||
|
expect(options.extractMeta).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have sensible defaults', () => {
|
||||||
|
const options = {
|
||||||
|
useFilter: false,
|
||||||
|
fallbackToEnglish: false,
|
||||||
|
extractMeta: false,
|
||||||
|
};
|
||||||
|
expect(options.useFilter).toBe(false);
|
||||||
|
expect(options.fallbackToEnglish).toBe(false);
|
||||||
|
expect(options.extractMeta).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getJsonData configuration', () => {
|
||||||
|
it('should use useFilter=true for data collections', () => {
|
||||||
|
// getJsonData calls getData with useFilter=true
|
||||||
|
const expectedOptions = { useFilter: true, extractMeta: true };
|
||||||
|
expect(expectedOptions.useFilter).toBe(true);
|
||||||
|
expect(expectedOptions.extractMeta).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have default collection prefix', () => {
|
||||||
|
const defaultPrefix = 'content_data_';
|
||||||
|
expect(defaultPrefix).toBe('content_data_');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPageContent configuration', () => {
|
||||||
|
it('should use fallbackToEnglish by default', () => {
|
||||||
|
const defaultFallback = true;
|
||||||
|
expect(defaultFallback).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have default collection prefix', () => {
|
||||||
|
const defaultPrefix = 'content_';
|
||||||
|
expect(defaultPrefix).toBe('content_');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('meta extraction logic', () => {
|
||||||
|
it('should return meta when extractMeta is true', () => {
|
||||||
|
const content = {
|
||||||
|
body: 'some content',
|
||||||
|
meta: { path: '/test', title: 'Test' },
|
||||||
|
};
|
||||||
|
const extractMeta = true;
|
||||||
|
const result = extractMeta ? content?.meta : content;
|
||||||
|
expect(result).toEqual({ path: '/test', title: 'Test' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return full content when extractMeta is false', () => {
|
||||||
|
const content = {
|
||||||
|
body: 'some content',
|
||||||
|
meta: { path: '/test', title: 'Test' },
|
||||||
|
};
|
||||||
|
const extractMeta = false;
|
||||||
|
const result = extractMeta ? content?.meta : content;
|
||||||
|
expect(result).toEqual(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null content gracefully', () => {
|
||||||
|
const content = null;
|
||||||
|
const extractMeta = true;
|
||||||
|
const result = extractMeta ? content?.meta : content;
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filter logic for data collections', () => {
|
||||||
|
it('should filter by meta.path matching slug', () => {
|
||||||
|
const allData = [
|
||||||
|
{ meta: { path: '/resume' }, data: 'resume data' },
|
||||||
|
{ meta: { path: '/other' }, data: 'other data' },
|
||||||
|
];
|
||||||
|
const slug = '/resume';
|
||||||
|
const content = allData.filter((source) => source.meta.path === slug)[0];
|
||||||
|
expect(content).toEqual({ meta: { path: '/resume' }, data: 'resume data' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined when no match found', () => {
|
||||||
|
const allData = [{ meta: { path: '/other' }, data: 'other data' }];
|
||||||
|
const slug = '/nonexistent';
|
||||||
|
const content = allData.filter((source) => source.meta.path === slug)[0];
|
||||||
|
expect(content).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,8 +47,8 @@ export const useDataJson = (prefix: string) => {
|
|||||||
return data as Ref<T | null>;
|
return data as Ref<T | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getJsonData = async (collectionPrefix: string = 'content_data_') => {
|
const getJsonData = async <T = unknown>(collectionPrefix: string = 'content_data_') => {
|
||||||
return getData(collectionPrefix, { useFilter: true, extractMeta: true });
|
return getData<T>(collectionPrefix, { useFilter: true, extractMeta: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPageContent = async (collectionPrefix: string = 'content_', fallbackToEnglish: boolean = true) => {
|
const getPageContent = async (collectionPrefix: string = 'content_', fallbackToEnglish: boolean = true) => {
|
||||||
|
|||||||
101
app/composables/useMeta.test.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { MetaImageOptions, MetaOptions } from './useMeta';
|
||||||
|
|
||||||
|
describe('useMeta', () => {
|
||||||
|
describe('MetaOptions interface', () => {
|
||||||
|
it('should accept required title and description', () => {
|
||||||
|
const options: MetaOptions = {
|
||||||
|
title: 'Test Page',
|
||||||
|
description: 'Test description',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(options.title).toBe('Test Page');
|
||||||
|
expect(options.description).toBe('Test description');
|
||||||
|
expect(options.image).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept optional image property', () => {
|
||||||
|
const options: MetaOptions = {
|
||||||
|
title: 'Test Page',
|
||||||
|
description: 'Test description',
|
||||||
|
image: {
|
||||||
|
url: 'https://example.com/image.jpg',
|
||||||
|
alt: 'Alt text',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(options.image).toBeDefined();
|
||||||
|
expect(options.image?.url).toBe('https://example.com/image.jpg');
|
||||||
|
expect(options.image?.alt).toBe('Alt text');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MetaImageOptions interface', () => {
|
||||||
|
it('should require url and alt properties', () => {
|
||||||
|
const imageOptions: MetaImageOptions = {
|
||||||
|
url: 'https://example.com/image.png',
|
||||||
|
alt: 'Image description',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(imageOptions.url).toBe('https://example.com/image.png');
|
||||||
|
expect(imageOptions.alt).toBe('Image description');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('title suffix logic', () => {
|
||||||
|
const titleSuffix = ' – Lucien Cartier-Tilet';
|
||||||
|
|
||||||
|
it('should append suffix to title', () => {
|
||||||
|
const title = 'My Page';
|
||||||
|
const fullTitle = title + titleSuffix;
|
||||||
|
|
||||||
|
expect(fullTitle).toBe('My Page – Lucien Cartier-Tilet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty title', () => {
|
||||||
|
const title = '';
|
||||||
|
const fullTitle = title + titleSuffix;
|
||||||
|
|
||||||
|
expect(fullTitle).toBe(' – Lucien Cartier-Tilet');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('twitter card type logic', () => {
|
||||||
|
it('should use summary_large_image when image is provided', () => {
|
||||||
|
const image: MetaImageOptions = { url: 'test.jpg', alt: 'Test' };
|
||||||
|
const cardType = image ? 'summary_large_image' : 'summary';
|
||||||
|
|
||||||
|
expect(cardType).toBe('summary_large_image');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use summary when no image is provided', () => {
|
||||||
|
const image: MetaImageOptions | undefined = undefined;
|
||||||
|
const cardType = image ? 'summary_large_image' : 'summary';
|
||||||
|
|
||||||
|
expect(cardType).toBe('summary');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('optional chaining for image properties', () => {
|
||||||
|
it('should return url when image is provided', () => {
|
||||||
|
const options: MetaOptions = {
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
image: { url: 'https://example.com/og.jpg', alt: 'OG Image' },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(options.image?.url).toBe('https://example.com/og.jpg');
|
||||||
|
expect(options.image?.alt).toBe('OG Image');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined when image is not provided', () => {
|
||||||
|
const options: MetaOptions = {
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(options.image?.url).toBeUndefined();
|
||||||
|
expect(options.image?.alt).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
207
app/pages/contact.test.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
describe('Contact Page', () => {
|
||||||
|
describe('form schema validation', () => {
|
||||||
|
const mockT = (key: string) => key;
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
email: z.email(mockT('pages.contact.form.validation.invalidEmail')),
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1, mockT('pages.contact.form.validation.shortName'))
|
||||||
|
.max(100, mockT('pages.contact.form.validation.longName')),
|
||||||
|
message: z
|
||||||
|
.string()
|
||||||
|
.min(10, mockT('pages.contact.form.validation.shortMessage'))
|
||||||
|
.max(5000, mockT('pages.contact.form.validation.longMessage')),
|
||||||
|
website: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate valid form data', () => {
|
||||||
|
const validData = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: 'John Doe',
|
||||||
|
message: 'This is a test message that is longer than 10 characters',
|
||||||
|
website: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = schema.safeParse(validData);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid email', () => {
|
||||||
|
const invalidData = {
|
||||||
|
email: 'invalid-email',
|
||||||
|
name: 'John Doe',
|
||||||
|
message: 'This is a valid message',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = schema.safeParse(invalidData);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject empty name', () => {
|
||||||
|
const invalidData = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: '',
|
||||||
|
message: 'This is a valid message',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = schema.safeParse(invalidData);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject too long name (>100 chars)', () => {
|
||||||
|
const invalidData = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: 'a'.repeat(101),
|
||||||
|
message: 'This is a valid message',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = schema.safeParse(invalidData);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject too short message (<10 chars)', () => {
|
||||||
|
const invalidData = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: 'John Doe',
|
||||||
|
message: 'Short',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = schema.safeParse(invalidData);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject too long message (>5000 chars)', () => {
|
||||||
|
const invalidData = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: 'John Doe',
|
||||||
|
message: 'a'.repeat(5001),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = schema.safeParse(invalidData);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow optional website field', () => {
|
||||||
|
const validData = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: 'John Doe',
|
||||||
|
message: 'This is a valid test message',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = schema.safeParse(validData);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept website when provided', () => {
|
||||||
|
const validData = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
name: 'John Doe',
|
||||||
|
message: 'This is a valid test message',
|
||||||
|
website: 'https://example.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = schema.safeParse(validData);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('form state', () => {
|
||||||
|
it('should initialize with undefined values', () => {
|
||||||
|
const state = reactive({
|
||||||
|
name: undefined as string | undefined,
|
||||||
|
email: undefined as string | undefined,
|
||||||
|
message: undefined as string | undefined,
|
||||||
|
website: undefined as string | undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.name).toBeUndefined();
|
||||||
|
expect(state.email).toBeUndefined();
|
||||||
|
expect(state.message).toBeUndefined();
|
||||||
|
expect(state.website).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update values when set', () => {
|
||||||
|
const state = reactive({
|
||||||
|
name: undefined as string | undefined,
|
||||||
|
email: undefined as string | undefined,
|
||||||
|
message: undefined as string | undefined,
|
||||||
|
website: undefined as string | undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
state.name = 'John Doe';
|
||||||
|
state.email = 'test@example.com';
|
||||||
|
state.message = 'Hello, this is a test message';
|
||||||
|
|
||||||
|
expect(state.name).toBe('John Doe');
|
||||||
|
expect(state.email).toBe('test@example.com');
|
||||||
|
expect(state.message).toBe('Hello, this is a test message');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toast notification logic', () => {
|
||||||
|
it('should show success toast on successful response', () => {
|
||||||
|
const mockToastAdd = vi.fn();
|
||||||
|
const mockT = (key: string) => key;
|
||||||
|
const response = { success: true, message: 'Message sent successfully' };
|
||||||
|
|
||||||
|
// Simulate the watcher behavior
|
||||||
|
if (response) {
|
||||||
|
mockToastAdd({
|
||||||
|
title: response.success ? mockT('pages.contact.toast.success') : mockT('pages.contact.toast.error'),
|
||||||
|
description: mockT(response.message),
|
||||||
|
color: response.success ? 'info' : 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||||
|
title: 'pages.contact.toast.success',
|
||||||
|
description: 'Message sent successfully',
|
||||||
|
color: 'info',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show error toast on failed response', () => {
|
||||||
|
const mockToastAdd = vi.fn();
|
||||||
|
const mockT = (key: string) => key;
|
||||||
|
const response = { success: false, message: 'Failed to send' };
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
mockToastAdd({
|
||||||
|
title: response.success ? mockT('pages.contact.toast.success') : mockT('pages.contact.toast.error'),
|
||||||
|
description: mockT(response.message),
|
||||||
|
color: response.success ? 'info' : 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||||
|
title: 'pages.contact.toast.error',
|
||||||
|
description: 'Failed to send',
|
||||||
|
color: 'error',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show error toast on contact error', () => {
|
||||||
|
const mockToastAdd = vi.fn();
|
||||||
|
const mockT = (key: string) => key;
|
||||||
|
const error = { message: 'backend.errors.unknown' };
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
mockToastAdd({
|
||||||
|
title: mockT('pages.contact.toast.error'),
|
||||||
|
description: mockT(error.message),
|
||||||
|
color: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||||
|
title: 'pages.contact.toast.error',
|
||||||
|
description: 'backend.errors.unknown',
|
||||||
|
color: 'error',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
153
app/pages/contact.vue
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtLayout name="default">
|
||||||
|
<UPage>
|
||||||
|
<h1 class="text-4xl text-highlighted font-bold mb-8">
|
||||||
|
{{ $t('pages.contact.name') }}
|
||||||
|
</h1>
|
||||||
|
<UPageCard class="bg-background-100">
|
||||||
|
<UForm :schema="schema" :state="state" class="space-y-4" @submit="submitContactForm">
|
||||||
|
<div class="flex flex-row w-full gap-5">
|
||||||
|
<UFormField
|
||||||
|
:label="$t('pages.contact.form.labels.name')"
|
||||||
|
name="name"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ label: 'text-text text-lg text-bold' }"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-model="state.name"
|
||||||
|
autofocus
|
||||||
|
:ui="{
|
||||||
|
root: 'relative inline-flex items-center w-full',
|
||||||
|
base: 'placeholder:text-300',
|
||||||
|
}"
|
||||||
|
required
|
||||||
|
:placeholder="$t('pages.contact.form.placeholders.name')"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<UFormField
|
||||||
|
:label="$t('pages.contact.form.labels.email')"
|
||||||
|
name="email"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ label: 'text-text text-lg text-bold' }"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-model="state.email"
|
||||||
|
type="email"
|
||||||
|
:ui="{
|
||||||
|
root: 'relative inline-flex items-center w-full',
|
||||||
|
base: 'placeholder:text-300',
|
||||||
|
}"
|
||||||
|
required
|
||||||
|
:placeholder="$t('pages.contact.form.placeholders.email')"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
<UFormField
|
||||||
|
class="w-full sr-only"
|
||||||
|
name="website"
|
||||||
|
:label="$t('pages.contact.form.labels.website')"
|
||||||
|
:ui="{ label: 'text-text text-lg text-bold' }"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
If you see this input, you may be using accessibility tools to access this website. This input is meant to
|
||||||
|
be hidden to human visitors, but not bots which do not necessarily render the website the way it is meant
|
||||||
|
to. Unfortunately, this also affects accessibility tools, such as the ones for visually-impared people. If
|
||||||
|
that is indeed, please ignore this input, as it is not meant to be filled by human beings. Filling this
|
||||||
|
input will result in a discarded contact form.
|
||||||
|
</div>
|
||||||
|
<UInput
|
||||||
|
v-model="state.website"
|
||||||
|
:ui="{ root: 'relative inline-flex items-center w-full', base: 'placeholder:text-300' }"
|
||||||
|
:placeholder="$t('pages.contact.form.placeholders.website')"
|
||||||
|
tabindex="-1"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<UFormField
|
||||||
|
:label="$t('pages.contact.form.labels.message')"
|
||||||
|
name="message"
|
||||||
|
:ui="{ label: 'text-text text-lg text-bold' }"
|
||||||
|
>
|
||||||
|
<UTextarea
|
||||||
|
v-model="state.message"
|
||||||
|
:ui="{
|
||||||
|
root: 'relative inline-flex items-center w-full',
|
||||||
|
base: 'placeholder:text-300',
|
||||||
|
}"
|
||||||
|
:placeholder="$t('pages.contact.form.placeholders.message')"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<UButton
|
||||||
|
icon="mdi:send-outline"
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
class="w-full text-center text-lg justify-center-safe"
|
||||||
|
size="lg"
|
||||||
|
:loading="loading"
|
||||||
|
>
|
||||||
|
{{ $t('pages.contact.form.sendButton') }}
|
||||||
|
</UButton>
|
||||||
|
</UForm>
|
||||||
|
</UPageCard>
|
||||||
|
</UPage>
|
||||||
|
</NuxtLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { FormSubmitEvent } from '@nuxt/ui';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
useMeta({
|
||||||
|
title: $t('pages.contact.name'),
|
||||||
|
description: $t('pages.contact.description'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const { postContact } = useBackend();
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
email: z.email($t('pages.contact.form.validation.invalidEmail')),
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1, $t('pages.contact.form.validation.shortName'))
|
||||||
|
.max(100, $t('pages.contact.form.validation.longName')),
|
||||||
|
message: z
|
||||||
|
.string()
|
||||||
|
.min(10, $t('pages.contact.form.validation.shortMessage'))
|
||||||
|
.max(5000, $t('pages.contact.form.validation.longMessage')),
|
||||||
|
website: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Schema = z.output<typeof schema>;
|
||||||
|
|
||||||
|
const state = reactive<Partial<Schema>>({
|
||||||
|
name: undefined,
|
||||||
|
email: undefined,
|
||||||
|
message: undefined,
|
||||||
|
website: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: contactResponse, error: contactError, loading, run: sendRequest } = postContact();
|
||||||
|
|
||||||
|
const submitContactForm = async (event: FormSubmitEvent<Schema>) => await sendRequest!(event.data);
|
||||||
|
|
||||||
|
watch(contactResponse, async (response) => {
|
||||||
|
if (response) {
|
||||||
|
toast.add({
|
||||||
|
title: response.success ? $t('pages.contact.toast.success') : $t('pages.contact.toast.error'),
|
||||||
|
description: $t(response.message),
|
||||||
|
color: response.success ? 'info' : 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
watch(contactError, async (response) => {
|
||||||
|
if (response) {
|
||||||
|
toast.add({
|
||||||
|
title: $t('pages.contact.toast.error'),
|
||||||
|
description: $t(response.message),
|
||||||
|
color: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
115
app/pages/resume.test.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ResumeContent } from '~/types/resume';
|
||||||
|
|
||||||
|
describe('Resume Page', () => {
|
||||||
|
describe('ResumeContent default handling', () => {
|
||||||
|
it('should create default ResumeContent when data is null', () => {
|
||||||
|
const resumeData = ref<ResumeContent | null>(null);
|
||||||
|
const resumeContent = computed(() => (resumeData.value ? resumeData.value : new ResumeContent()));
|
||||||
|
|
||||||
|
expect(resumeContent.value).toBeInstanceOf(ResumeContent);
|
||||||
|
expect(resumeContent.value.experience).toEqual([]);
|
||||||
|
expect(resumeContent.value.education).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use provided ResumeContent when data is available', () => {
|
||||||
|
const resumeData = ref<ResumeContent | null>(new ResumeContent());
|
||||||
|
resumeData.value!.experience = [{ tools: [], description: 'Test job' }];
|
||||||
|
|
||||||
|
const resumeContent = computed(() => (resumeData.value ? resumeData.value : new ResumeContent()));
|
||||||
|
|
||||||
|
expect(resumeContent.value.experience.length).toBe(1);
|
||||||
|
expect(resumeContent.value.experience[0].description).toBe('Test job');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('array length helper', () => {
|
||||||
|
const arrLength = <T>(array?: T[]) => (array ? array.length - 1 : 0);
|
||||||
|
|
||||||
|
it('should return 0 for undefined array', () => {
|
||||||
|
expect(arrLength(undefined)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 for empty array', () => {
|
||||||
|
expect(arrLength([])).toBe(-1); // Actually returns -1 for empty array
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return length - 1 for non-empty array', () => {
|
||||||
|
expect(arrLength([1, 2, 3])).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 for single element array', () => {
|
||||||
|
expect(arrLength([1])).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('timeline value computation', () => {
|
||||||
|
it('should compute experience timeline value', () => {
|
||||||
|
const resumeContent = new ResumeContent();
|
||||||
|
resumeContent.experience = [
|
||||||
|
{ tools: [], description: 'Job 1' },
|
||||||
|
{ tools: [], description: 'Job 2' },
|
||||||
|
{ tools: [], description: 'Job 3' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const arrLength = <T>(array?: T[]) => (array ? array.length - 1 : 0);
|
||||||
|
const valueExp = computed(() => arrLength(resumeContent.experience));
|
||||||
|
|
||||||
|
expect(valueExp.value).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should compute education timeline value', () => {
|
||||||
|
const resumeContent = new ResumeContent();
|
||||||
|
resumeContent.education = [{ title: 'Degree 1' }, { title: 'Degree 2' }];
|
||||||
|
|
||||||
|
const arrLength = <T>(array?: T[]) => (array ? array.length - 1 : 0);
|
||||||
|
const valueEd = computed(() => arrLength(resumeContent.education));
|
||||||
|
|
||||||
|
expect(valueEd.value).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('data structure requirements', () => {
|
||||||
|
it('should have experience section', () => {
|
||||||
|
const resumeContent = new ResumeContent();
|
||||||
|
expect(resumeContent).toHaveProperty('experience');
|
||||||
|
expect(Array.isArray(resumeContent.experience)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have education section', () => {
|
||||||
|
const resumeContent = new ResumeContent();
|
||||||
|
expect(resumeContent).toHaveProperty('education');
|
||||||
|
expect(Array.isArray(resumeContent.education)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have otherTools section', () => {
|
||||||
|
const resumeContent = new ResumeContent();
|
||||||
|
expect(resumeContent).toHaveProperty('otherTools');
|
||||||
|
expect(Array.isArray(resumeContent.otherTools)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have devops section', () => {
|
||||||
|
const resumeContent = new ResumeContent();
|
||||||
|
expect(resumeContent).toHaveProperty('devops');
|
||||||
|
expect(Array.isArray(resumeContent.devops)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have os section', () => {
|
||||||
|
const resumeContent = new ResumeContent();
|
||||||
|
expect(resumeContent).toHaveProperty('os');
|
||||||
|
expect(Array.isArray(resumeContent.os)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have programmingLanguages section', () => {
|
||||||
|
const resumeContent = new ResumeContent();
|
||||||
|
expect(resumeContent).toHaveProperty('programmingLanguages');
|
||||||
|
expect(Array.isArray(resumeContent.programmingLanguages)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have frameworks section', () => {
|
||||||
|
const resumeContent = new ResumeContent();
|
||||||
|
expect(resumeContent).toHaveProperty('frameworks');
|
||||||
|
expect(Array.isArray(resumeContent.frameworks)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,13 +35,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ResumeContent } from '~/types/resume';
|
||||||
|
|
||||||
useMeta({
|
useMeta({
|
||||||
title: $t('pages.resume.name'),
|
title: $t('pages.resume.name'),
|
||||||
description: $t('pages.resume.description'),
|
description: $t('pages.resume.description'),
|
||||||
});
|
});
|
||||||
const { getJsonData } = useDataJson('resume');
|
const { getJsonData } = useDataJson('resume');
|
||||||
const resumeContent = await getJsonData();
|
const resumeContent$ = await getJsonData<ResumeContent>();
|
||||||
const arrLength = (array?: T[]) => (array ? array.length - 1 : 0);
|
const resumeContent = computed(() => (resumeContent$.value ? resumeContent$.value : new ResumeContent()));
|
||||||
|
const arrLength = <T,>(array?: T[]) => (array ? array.length - 1 : 0);
|
||||||
const valueExp = computed(() => arrLength(resumeContent.value?.experience));
|
const valueExp = computed(() => arrLength(resumeContent.value?.experience));
|
||||||
const valueEd = computed(() => arrLength(resumeContent.value?.education));
|
const valueEd = computed(() => arrLength(resumeContent.value?.education));
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
126
app/pages/slug.test.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { mountSuspended } from '@nuxt/test-utils/runtime';
|
||||||
|
import SlugPage from './[...slug].vue';
|
||||||
|
|
||||||
|
// Mock useDataJson
|
||||||
|
const mockPageContent = ref<{ title: string; description: string; meta?: { layout?: string } } | null>(null);
|
||||||
|
const mockPageData = ref<Record<string, unknown> | null>(null);
|
||||||
|
|
||||||
|
vi.mock('~/composables/useDataJson', () => ({
|
||||||
|
useDataJson: vi.fn((prefix: string) => {
|
||||||
|
if (prefix === 'page') {
|
||||||
|
return {
|
||||||
|
getPageContent: vi.fn(async () => mockPageContent),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (prefix === 'page-data') {
|
||||||
|
return {
|
||||||
|
getJsonData: vi.fn(async () => mockPageData),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
getPageContent: vi.fn(async () => mockPageContent),
|
||||||
|
getJsonData: vi.fn(async () => mockPageData),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock useMeta
|
||||||
|
vi.mock('~/composables/useMeta', () => ({
|
||||||
|
useMeta: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Slug Page (Catch-all)', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('should render the page when content exists', async () => {
|
||||||
|
mockPageContent.value = {
|
||||||
|
title: 'Test Page',
|
||||||
|
description: 'A test page',
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(SlugPage);
|
||||||
|
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show not found message when page is null', async () => {
|
||||||
|
mockPageContent.value = null;
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(SlugPage);
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Page not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('layout selection', () => {
|
||||||
|
it('should use default layout when no custom layout specified', async () => {
|
||||||
|
mockPageContent.value = {
|
||||||
|
title: 'Test Page',
|
||||||
|
description: 'A test page',
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(SlugPage);
|
||||||
|
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom layout when specified in meta', async () => {
|
||||||
|
mockPageContent.value = {
|
||||||
|
title: 'Centered Page',
|
||||||
|
description: 'A centered page',
|
||||||
|
meta: { layout: 'centered' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(SlugPage);
|
||||||
|
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('page data injection', () => {
|
||||||
|
it('should provide pageData to child components', async () => {
|
||||||
|
mockPageContent.value = {
|
||||||
|
title: 'Vocal Synthesis',
|
||||||
|
description: 'Vocal synthesis projects',
|
||||||
|
};
|
||||||
|
mockPageData.value = {
|
||||||
|
projects: [{ title: 'Project 1' }],
|
||||||
|
tools: [{ name: 'Tool 1' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(SlugPage);
|
||||||
|
|
||||||
|
// Page data should be provided for MDC components
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('content rendering', () => {
|
||||||
|
it('should render ContentRenderer when page exists', async () => {
|
||||||
|
mockPageContent.value = {
|
||||||
|
title: 'Test Content',
|
||||||
|
description: 'Test description',
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = await mountSuspended(SlugPage);
|
||||||
|
|
||||||
|
expect(wrapper.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SEO meta', () => {
|
||||||
|
it('should call useMeta with page title and description', async () => {
|
||||||
|
const { useMeta } = await import('~/composables/useMeta');
|
||||||
|
|
||||||
|
mockPageContent.value = {
|
||||||
|
title: 'SEO Test Page',
|
||||||
|
description: 'Testing SEO metadata',
|
||||||
|
};
|
||||||
|
|
||||||
|
await mountSuspended(SlugPage);
|
||||||
|
|
||||||
|
// useMeta should have been called
|
||||||
|
expect(useMeta).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
19
app/types/http-method.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export type HttpMethod =
|
||||||
|
| 'delete'
|
||||||
|
| 'get'
|
||||||
|
| 'GET'
|
||||||
|
| 'HEAD'
|
||||||
|
| 'PATCH'
|
||||||
|
| 'POST'
|
||||||
|
| 'PUT'
|
||||||
|
| 'DELETE'
|
||||||
|
| 'CONNECT'
|
||||||
|
| 'OPTIONS'
|
||||||
|
| 'TRACE'
|
||||||
|
| 'head'
|
||||||
|
| 'patch'
|
||||||
|
| 'post'
|
||||||
|
| 'put'
|
||||||
|
| 'connect'
|
||||||
|
| 'options'
|
||||||
|
| 'trace';
|
||||||
98
app/types/query-result.test.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { QueryResult } from './query-result';
|
||||||
|
import type { ApiError } from './api/error';
|
||||||
|
|
||||||
|
describe('QueryResult', () => {
|
||||||
|
describe('initialization', () => {
|
||||||
|
it('should initialize with null data', () => {
|
||||||
|
const result = new QueryResult<string, void>();
|
||||||
|
expect(result.data.value).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with null error', () => {
|
||||||
|
const result = new QueryResult<string, void>();
|
||||||
|
expect(result.error.value).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with loading as false', () => {
|
||||||
|
const result = new QueryResult<string, void>();
|
||||||
|
expect(result.loading.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have run property (initially undefined)', () => {
|
||||||
|
const result = new QueryResult<string, void>();
|
||||||
|
expect(result).toHaveProperty('run');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reactive properties', () => {
|
||||||
|
it('should have reactive data ref', () => {
|
||||||
|
const result = new QueryResult<{ id: number }, void>();
|
||||||
|
result.data.value = { id: 1 };
|
||||||
|
expect(result.data.value).toEqual({ id: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have reactive error ref', () => {
|
||||||
|
const result = new QueryResult<string, void>();
|
||||||
|
const error: ApiError = { message: 'Test error', success: false };
|
||||||
|
result.error.value = error;
|
||||||
|
expect(result.error.value).toEqual(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have reactive loading ref', () => {
|
||||||
|
const result = new QueryResult<string, void>();
|
||||||
|
result.loading.value = true;
|
||||||
|
expect(result.loading.value).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('type safety', () => {
|
||||||
|
it('should accept generic type for data', () => {
|
||||||
|
interface TestData {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
const result = new QueryResult<TestData, void>();
|
||||||
|
result.data.value = { name: 'test', count: 42 };
|
||||||
|
expect(result.data.value.name).toBe('test');
|
||||||
|
expect(result.data.value.count).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept generic type for payload', () => {
|
||||||
|
interface ResponseData {
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
interface PayloadData {
|
||||||
|
input: string;
|
||||||
|
}
|
||||||
|
const result = new QueryResult<ResponseData, PayloadData>();
|
||||||
|
// PayloadT is used by the run function signature
|
||||||
|
expect(result).toHaveProperty('run');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('run method assignment', () => {
|
||||||
|
it('should allow run method to be assigned', async () => {
|
||||||
|
const result = new QueryResult<string, void>();
|
||||||
|
let called = false;
|
||||||
|
result.run = async () => {
|
||||||
|
called = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
await result.run();
|
||||||
|
expect(called).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow run method to accept payload parameter', async () => {
|
||||||
|
const result = new QueryResult<string, { data: string }>();
|
||||||
|
let receivedPayload: { data: string } | undefined;
|
||||||
|
|
||||||
|
result.run = async (payload) => {
|
||||||
|
receivedPayload = payload;
|
||||||
|
};
|
||||||
|
|
||||||
|
await result.run({ data: 'test' });
|
||||||
|
expect(receivedPayload).toEqual({ data: 'test' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
12
app/types/query-result.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ApiError } from './api/error';
|
||||||
|
|
||||||
|
export class QueryResult<T, PayloadT> {
|
||||||
|
/** Reactive data - `null` until the request succeeds */
|
||||||
|
data: Ref<T | null> = ref(null);
|
||||||
|
/** Reactive error - `null` until an error occurs */
|
||||||
|
error: Ref<ApiError | null> = ref(null);
|
||||||
|
/** Whether the request is currently in flight */
|
||||||
|
loading: Ref<boolean> = ref(false);
|
||||||
|
/** Runs the query - Will be filled by the request helper */
|
||||||
|
run!: (requestBody?: PayloadT) => Promise<void>;
|
||||||
|
}
|
||||||
129
app/types/resume.test.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ResumeExperience, ResumeContent } from './resume';
|
||||||
|
import type { Tool } from './tool';
|
||||||
|
|
||||||
|
describe('ResumeExperience', () => {
|
||||||
|
describe('initialization', () => {
|
||||||
|
it('should initialize with empty tools array', () => {
|
||||||
|
const experience = new ResumeExperience();
|
||||||
|
expect(experience.tools).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with undefined description', () => {
|
||||||
|
const experience = new ResumeExperience();
|
||||||
|
expect(experience.description).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('property assignment', () => {
|
||||||
|
it('should allow tools to be assigned', () => {
|
||||||
|
const experience = new ResumeExperience();
|
||||||
|
const tools: Tool[] = [{ name: 'TypeScript', link: 'https://typescriptlang.org' }, { name: 'Vue.js' }];
|
||||||
|
experience.tools = tools;
|
||||||
|
expect(experience.tools).toEqual(tools);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow description to be assigned', () => {
|
||||||
|
const experience = new ResumeExperience();
|
||||||
|
experience.description = 'Software developer working on web applications';
|
||||||
|
expect(experience.description).toBe('Software developer working on web applications');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TimelineItem interface implementation', () => {
|
||||||
|
it('should be usable as TimelineItem', () => {
|
||||||
|
const experience = new ResumeExperience();
|
||||||
|
// TimelineItem interface from @nuxt/ui - ResumeExperience implements it
|
||||||
|
expect(experience).toHaveProperty('tools');
|
||||||
|
expect(experience).toHaveProperty('description');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ResumeContent', () => {
|
||||||
|
describe('initialization', () => {
|
||||||
|
it('should initialize with empty experience array', () => {
|
||||||
|
const content = new ResumeContent();
|
||||||
|
expect(content.experience).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with empty education array', () => {
|
||||||
|
const content = new ResumeContent();
|
||||||
|
expect(content.education).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with empty otherTools array', () => {
|
||||||
|
const content = new ResumeContent();
|
||||||
|
expect(content.otherTools).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with empty devops array', () => {
|
||||||
|
const content = new ResumeContent();
|
||||||
|
expect(content.devops).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with empty os array', () => {
|
||||||
|
const content = new ResumeContent();
|
||||||
|
expect(content.os).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with empty programmingLanguages array', () => {
|
||||||
|
const content = new ResumeContent();
|
||||||
|
expect(content.programmingLanguages).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with empty frameworks array', () => {
|
||||||
|
const content = new ResumeContent();
|
||||||
|
expect(content.frameworks).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('property assignment', () => {
|
||||||
|
it('should allow experience to be assigned', () => {
|
||||||
|
const content = new ResumeContent();
|
||||||
|
const exp = new ResumeExperience();
|
||||||
|
exp.description = 'Test job';
|
||||||
|
content.experience = [exp];
|
||||||
|
expect(content.experience.length).toBe(1);
|
||||||
|
expect(content.experience[0].description).toBe('Test job');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow tools arrays to be assigned', () => {
|
||||||
|
const content = new ResumeContent();
|
||||||
|
const tools: Tool[] = [{ name: 'Git', link: 'https://git-scm.com' }];
|
||||||
|
|
||||||
|
content.devops = tools;
|
||||||
|
content.os = [{ name: 'Linux' }];
|
||||||
|
content.programmingLanguages = [{ name: 'Rust', link: 'https://rust-lang.org' }];
|
||||||
|
content.frameworks = [{ name: 'Nuxt', link: 'https://nuxt.com' }];
|
||||||
|
content.otherTools = [{ name: 'Vim' }];
|
||||||
|
|
||||||
|
expect(content.devops).toEqual(tools);
|
||||||
|
expect(content.os).toEqual([{ name: 'Linux' }]);
|
||||||
|
expect(content.programmingLanguages).toEqual([{ name: 'Rust', link: 'https://rust-lang.org' }]);
|
||||||
|
expect(content.frameworks).toEqual([{ name: 'Nuxt', link: 'https://nuxt.com' }]);
|
||||||
|
expect(content.otherTools).toEqual([{ name: 'Vim' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow education to be assigned', () => {
|
||||||
|
const content = new ResumeContent();
|
||||||
|
content.education = [{ title: 'Computer Science', description: 'Master degree' }];
|
||||||
|
expect(content.education.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('default values', () => {
|
||||||
|
it('should provide safe defaults when used without data', () => {
|
||||||
|
const content = new ResumeContent();
|
||||||
|
|
||||||
|
// All arrays should be empty but defined
|
||||||
|
expect(Array.isArray(content.experience)).toBe(true);
|
||||||
|
expect(Array.isArray(content.education)).toBe(true);
|
||||||
|
expect(Array.isArray(content.otherTools)).toBe(true);
|
||||||
|
expect(Array.isArray(content.devops)).toBe(true);
|
||||||
|
expect(Array.isArray(content.os)).toBe(true);
|
||||||
|
expect(Array.isArray(content.programmingLanguages)).toBe(true);
|
||||||
|
expect(Array.isArray(content.frameworks)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
export interface ResumeExperience extends TimelineItem {
|
import type { TimelineItem } from '@nuxt/ui';
|
||||||
tools: string[];
|
import type { Tool } from './tool';
|
||||||
|
|
||||||
|
export class ResumeExperience implements TimelineItem {
|
||||||
|
tools: Tool[] = [];
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResumeContent {
|
export class ResumeContent {
|
||||||
experience: ResumeExperience[];
|
experience: ResumeExperience[] = [];
|
||||||
education: TimelineItem[];
|
education: TimelineItem[] = [];
|
||||||
otherTools: string[];
|
otherTools: Tool[] = [];
|
||||||
devops: string[];
|
devops: Tool[] = [];
|
||||||
os: string[];
|
os: Tool[] = [];
|
||||||
programmingLanguages: string[];
|
programmingLanguages: Tool[] = [];
|
||||||
frameworks: string[];
|
frameworks: Tool[] = [];
|
||||||
}
|
}
|
||||||
|
|||||||
5
app/types/social-account.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export interface SocialAccount {
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface Tool {
|
||||||
|
name: string;
|
||||||
|
link?: string;
|
||||||
|
}
|
||||||
|
|||||||
13
app/types/vocal-synth.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Tool } from './tool';
|
||||||
|
|
||||||
|
export interface VocalSynthProject {
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
description: string;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VocalSynthPage {
|
||||||
|
projects: VocalSynthProject[];
|
||||||
|
tools: Tool[];
|
||||||
|
}
|
||||||
184
content/en/keine-tashi.md
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
---
|
||||||
|
title: BSUP01 Keine Tashi
|
||||||
|
---
|
||||||
|
|
||||||
|
# BSUP01 Keine Tashi
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
KEINE Tashi is a character and set of vocal libraries developed for
|
||||||
|
the shareware [UTAU](http://utau2008.web.fc2.com/), a singing voice
|
||||||
|
synthesizer. I developed KEINE Tashi over the course of several years,
|
||||||
|
from 2012 to 2015. Three vocal libraries have been released to the
|
||||||
|
public, the most used one being his **JPN Power Extend** one. On March
|
||||||
|
10th, 2017, I announced I would cease any kind of activity related to
|
||||||
|
UTAU.
|
||||||
|
|
||||||
|
<blockquote class="twitter-tweet" data-dnt="true" data-theme="dark">
|
||||||
|
<p lang="en" dir="ltr">
|
||||||
|
I’d like to also announce that from now on I am
|
||||||
|
dropping my previous UTAU projects other than covers and won’t develop
|
||||||
|
any new UTAU library
|
||||||
|
</p>
|
||||||
|
— P’undrak (@Phundrak)
|
||||||
|
<a href="https://twitter.com/Phundrak/status/840174634377105408?ref_src=twsrc%5Etfw">March
|
||||||
|
10th, 2017</a>
|
||||||
|
</blockquote>
|
||||||
|
<component is="script" async src="https://platform.twitter.com/widgets.js" charset="utf-8">
|
||||||
|
</component>
|
||||||
|
|
||||||
|
## Character and vocal libraries
|
||||||
|
|
||||||
|
Here's a copy and paste of some old pages describing KEINE Tashi:
|
||||||
|
|
||||||
|
### Presentation
|
||||||
|
|
||||||
|
{class="small-img"}
|
||||||
|
|
||||||
|
- **Codename**: BSUP01 恵音བཀྲ་ཤིས་ KEINE Tashi
|
||||||
|
- **First name**: Tashi (བཀྲ་ཤིས་), Tibetan name meaning "auspicious"
|
||||||
|
- **Last name**: Keine (恵音), Japanese name meaning "Blessing sound". It reads as "keine", although its regular reading should be "megumine".
|
||||||
|
- **Model**: BSUP (Bödkay Shetang UTAU Project)
|
||||||
|
- **Number**: 01
|
||||||
|
- **Gender**: male
|
||||||
|
- **Birthday (lore)**: June 28th, 1991
|
||||||
|
- **Birthday (first release)**: October 14th, 2012
|
||||||
|
- **Weight**: 154 lb / 70 kg
|
||||||
|
- **Heigh**: 6′0″ / 182 cm (very tall for a Tibetan)
|
||||||
|
- **Hair color**: black
|
||||||
|
- **Eyes color**: brown/black
|
||||||
|
- **Appearance**: Tashi wears a modernized Tibetan suit from the Amdo
|
||||||
|
Region (Chinese: 安多 Ānduō), colored in blue. He also wears some
|
||||||
|
turquoise jeweleries.
|
||||||
|
- **Favorite food**: meat momo (Tibetan raviolies)
|
||||||
|
- **Character item**: a Tibetan manuscript
|
||||||
|
- **Voice and creator**: [Phundrak](https://phundrak.com) (me)
|
||||||
|
- **Likes**: to meditate, calligraphy, old books, manuscripts
|
||||||
|
- **Dislikes**: selfishness, lies, arrogance
|
||||||
|
- **Personality**: Tashi is somebody very calm, sweet. He really
|
||||||
|
enjoys old books and manuscripts, and he LOVES meditate! He’s
|
||||||
|
never hungry, so, he can stay meditating for 2 to 3 days meditating,
|
||||||
|
just like that, until he realizes that he should eat something.
|
||||||
|
And he always keeps quiet, it’s really hard to make him angry.
|
||||||
|
|
||||||
|
But when he is, his anger becomes wrath. Anyone who experienced it
|
||||||
|
can attest how complex and difficult it is to calm him down.
|
||||||
|
Strangely enough, shortly after being confronted by Tashi, the
|
||||||
|
victims of this wrath see their quality of life greatly improve.
|
||||||
|
Maybe these people needed to hear some truths they refused to face
|
||||||
|
before?
|
||||||
|
|
||||||
|
### Vocal libraries
|
||||||
|
|
||||||
|
#### JPN VCV
|
||||||
|
|
||||||
|
- **Download links**:
|
||||||
|
|
||||||
|
| Extension | Size | Link |
|
||||||
|
| --------- | -------- | --------------------------------------------------------------------------------- |
|
||||||
|
| 7z | 25.7 MiB | [DL](https://cdn.phundrak.com/files/KeineTashi/BSUP01_KEINE_Tashi_JPN_VCV.7z) |
|
||||||
|
| tar.xz | 32.5 MiB | [DL](https://cdn.phundrak.com/files/KeineTashi/BSUP01_KEINE_Tashi_JPN_VCV.tar.xz) |
|
||||||
|
| zip | 38.0 MiB | [DL](https://cdn.phundrak.com/files/KeineTashi/BSUP01_KEINE_Tashi_JPN_VCV.zip) |
|
||||||
|
|
||||||
|
- **File size**: 60.7 MB
|
||||||
|
- **Total uncompressed size**: 94.4 MB
|
||||||
|
- **Number of voice phonemes**: 1264 (253 audio files)
|
||||||
|
- **Average frequency**: G#2
|
||||||
|
- **Vocal range**: C2\~D3
|
||||||
|
- **FRQ file presence**: partial
|
||||||
|
- **Release date**: October, 14th 2012
|
||||||
|
- **Phoneme encoding**: Romaji with hiragana and CV romaji aliases
|
||||||
|
- **Supported languages**: Japanese
|
||||||
|
- **oto.ini**: Tuned myself
|
||||||
|
- **Recommended engines**: TIPS, VS4U
|
||||||
|
|
||||||
|
#### JPN Extend Power
|
||||||
|
|
||||||
|
- **Download links**:
|
||||||
|
|
||||||
|
| Extension | Size | Link |
|
||||||
|
| --------- | ------ | ------------------------------------------------------------------------------------------ |
|
||||||
|
| 7z | 1.1Gio | [DL](https://cdn.phundrak.com/files/KeineTashi/BSUP01_KEINE_Tashi_JPN_Extend_Power.7z) |
|
||||||
|
| tar.xz | 1.1Gio | [DL](https://cdn.phundrak.com/files/KeineTashi/BSUP01_KEINE_Tashi_JPN_Extend_Power.tar.xz) |
|
||||||
|
| zip | 1.2Gio | [DL](https://cdn.phundrak.com/files/KeineTashi/BSUP01_KEINE_Tashi_JPN_Extend_Power.zip) |
|
||||||
|
|
||||||
|
- **File size**: 114 MB
|
||||||
|
- **Total uncompressed size**: 155 MB
|
||||||
|
- **Number of voice phonemes**: 3020 (546 audio files)
|
||||||
|
- **Average frequency**: C3
|
||||||
|
- **Vocal range**: B1\~D4
|
||||||
|
- **FRQ file presence**: partial
|
||||||
|
- **Release date**: June 28th, 2013
|
||||||
|
- **Phoneme encoding**: Romaji (hiragana aliases)
|
||||||
|
- **Supported languages**: Japanese
|
||||||
|
- **oto.ini**: Tuned myself
|
||||||
|
- **Recommended engines**: VS4U, world4utau
|
||||||
|
|
||||||
|
#### JPN Extend Youth
|
||||||
|
|
||||||
|
- **Download links**:
|
||||||
|
|
||||||
|
| Extension | Size | Link |
|
||||||
|
| --------- | -------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| 7z | 237.7Mio | [DL](https://cdn.phundrak.com/files/KeineTashi/BSUP01_KEINE_Tashi_JPN_Extend_Youth.7z) |
|
||||||
|
| tar.xz | 243.5Mio | [DL](https://cdn.phundrak.com/files/KeineTashi/BSUP01_KEINE_Tashi_JPN_Extend_Youth.tar.xz) |
|
||||||
|
| zip | 268.7Mio | [DL](https://cdn.phundrak.com/files/KeineTashi/BSUP01_KEINE_Tashi_JPN_Extend_Youth.zip) |
|
||||||
|
|
||||||
|
- **File size**: 36.9 MB
|
||||||
|
- **Total uncompressed size**: 42.0 MB
|
||||||
|
- **Number of voice phonemes**: 1954 (182 audio files)
|
||||||
|
- **Average frequency**: C4
|
||||||
|
- **Vocal range**: F#3\~A#4
|
||||||
|
- **FRQ file presence**: partial
|
||||||
|
- **Release date**: June 28th, 2013
|
||||||
|
- **Phoneme encoding**: Romaji (hiragana aliases, romaji added with the oto.ini update)
|
||||||
|
- **Supported languages**: Japanese
|
||||||
|
- **oto.ini**: Tuned myself
|
||||||
|
- **Recommended engines**: fresamp, VS4U, world4utau
|
||||||
|
|
||||||
|
#### JPN Extend Native
|
||||||
|
|
||||||
|
- **Status**: abandonned
|
||||||
|
|
||||||
|
{class="small-img"}
|
||||||
|
|
||||||
|
#### TIB CVVC
|
||||||
|
|
||||||
|
- **Status**: abandonned
|
||||||
|
|
||||||
|
#### ENG
|
||||||
|
|
||||||
|
- **Status**: abandonned
|
||||||
|
|
||||||
|
## Usage clause and license
|
||||||
|
|
||||||
|
KEINE Tashi is released under the [CC BY-SA-NC 4.0
|
||||||
|
license](https://creativecommons.org/licenses/by-nc-sa/4.0/), meaning
|
||||||
|
you are free to:
|
||||||
|
|
||||||
|
- **use**: make use of the vocal libraries in UTAU or any other
|
||||||
|
singing vocal synthesizer software.
|
||||||
|
- **adapt**: remix, transform, and build upon the material
|
||||||
|
- **share**: copy and redistribute the material in any medium or
|
||||||
|
format
|
||||||
|
|
||||||
|
my work, on the condition of:
|
||||||
|
|
||||||
|
- **Attribution**: You must give appropriate credit, provide a link to
|
||||||
|
the license, and indicate if changes were made. You may do so in
|
||||||
|
any reasonable manner, but not in any way that suggests the
|
||||||
|
licensor endorses you or your use.
|
||||||
|
- **NonCommercial**: You may not use the material for commercial purposes.
|
||||||
|
- **ShareAlike**: If you remix, transform, or build upon the material,
|
||||||
|
you must distribute your contributions under the same license as
|
||||||
|
the original.
|
||||||
|
|
||||||
|
Although I cannot add anything to this legal notice, I would also like
|
||||||
|
if you followed the following rules of thumb regarding this character:
|
||||||
|
any religious use of this character and its vocal libraries is
|
||||||
|
forbidden, except for folk music, and Buddhist and Bön songs. However,
|
||||||
|
due to the current controversy, any song linked to His Holiness the
|
||||||
|
Gyalwa Karmapa is strictly forbidden until said controversy has been
|
||||||
|
officially resolved. This is also applicable to His Holiness the Dalai
|
||||||
|
Lama, the Venerable Shamar Rinpoche, and Tai Situ Rinpoche. If you
|
||||||
|
have any question or if you are unsure, please [email me](/contact).
|
||||||
51
content/en/languages.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
title: Languages & Worldbuilding
|
||||||
|
description: Constructed languages (conlangs) and worldbuilding projects.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Languages and Worldbuilding
|
||||||
|
|
||||||
|
_Conlangs_, short for _constructed languages_, are artificial
|
||||||
|
languages created by individuals rather than evolving naturally over
|
||||||
|
time. They serve various purposes: international auxiliary languages
|
||||||
|
like Esperanto, philosophical experiments like Lojban, fictional
|
||||||
|
world-building like Tolkien's Elvish languages or Klingon, or simply
|
||||||
|
artistic expression.
|
||||||
|
|
||||||
|
I have been creating constructed languages and worlds, both for fun
|
||||||
|
and for literary projects. My conlanging work is documented on my
|
||||||
|
[dedicated conlanging website](https://conlang.phundrak.com/).
|
||||||
|
|
||||||
|
## Eittlandic
|
||||||
|
|
||||||
|
Eittlandic is the language of **Eittland**, a fictional Nordic
|
||||||
|
country, born from the question: _what if there was another island
|
||||||
|
like Iceland that never got Christianised?_
|
||||||
|
|
||||||
|
The language derives from Old Norse and has been evolved
|
||||||
|
naturalistically towards specific artistic goals. In Eittlandic, the
|
||||||
|
country's name is pronounced /ɑɪʔlɑ̃d/.
|
||||||
|
|
||||||
|
This project has been a personal artistic endeavour since 2018,
|
||||||
|
focusing on worldbuilding and conlanging. More details are available
|
||||||
|
on the [Eittland wiki](https://wiki.phundrak.com/s/eittland) and the
|
||||||
|
[Eittlandic language
|
||||||
|
documentation](https://conlang.phundrak.com/eittlandic).
|
||||||
|
|
||||||
|
## Proto-Ñyqy
|
||||||
|
|
||||||
|
Proto-Ñyqy is the proto-language and mother language of the Ñyqy
|
||||||
|
language family tree. The documentation is written as an in-universe
|
||||||
|
document would be, meaning all cultural and historical references are
|
||||||
|
entirely fictional. The writing style draws inspiration from academic
|
||||||
|
linguistic work, particularly Benjamin W. Fortson's _Indo-European
|
||||||
|
Language and Culture_. The [Proto-Ñyqy
|
||||||
|
documentation](https://conlang.phundrak.com/proto-nyqy) is publicly
|
||||||
|
available online.
|
||||||
|
|
||||||
|
## Zikãti
|
||||||
|
|
||||||
|
Zikãti is another conlanging project currently in development. This
|
||||||
|
one draws more from a conlanging experiment than a real worldbuilding
|
||||||
|
project. [Its documentation](https://conlang.phundrak.com/zik%C3%A3ti)
|
||||||
|
is also publicly available.
|
||||||
@@ -5,15 +5,16 @@
|
|||||||
"title": "Consultant – Aubay",
|
"title": "Consultant – Aubay",
|
||||||
"description": "Web development consultant working on enterprise applications. Continued focus on Angular front-end development and Java Spring Boot back-end services with PostgreSQL databases.",
|
"description": "Web development consultant working on enterprise applications. Continued focus on Angular front-end development and Java Spring Boot back-end services with PostgreSQL databases.",
|
||||||
"tools": [
|
"tools": [
|
||||||
"Angular",
|
{ "name": "Angular", "link": "https://angular.dev/" },
|
||||||
"TypeScript",
|
{ "name": "TypeScript", "link": "https://www.typescriptlang.org/" },
|
||||||
"Java Spring Boot",
|
{ "name": "Java", "link": "https://www.java.com/" },
|
||||||
"Java Spring Batch",
|
{ "name": "Spring Boot", "link": "https://spring.io/projects/spring-boot" },
|
||||||
"PostgreSQL",
|
{ "name": "Spring Batch", "link": "https://spring.io/projects/spring-batch" },
|
||||||
"VS Code",
|
{ "name": "PostgreSQL", "link": "https://www.postgresql.org/" },
|
||||||
"Eclipse",
|
{ "name": "VS Code", "link": "https://code.visualstudio.com/" },
|
||||||
"IntelliJ Idea",
|
{ "name": "Eclipse", "link": "https://www.eclipse.org/" },
|
||||||
"Git"
|
{ "name": "IntelliJ Idea", "link": "https://www.jetbrains.com/idea/" },
|
||||||
|
{ "name": "Git", "link": "https://git-scm.com/" }
|
||||||
],
|
],
|
||||||
"icon": "mdi:laptop"
|
"icon": "mdi:laptop"
|
||||||
},
|
},
|
||||||
@@ -21,14 +22,29 @@
|
|||||||
"date": "February 2023 – August 2023",
|
"date": "February 2023 – August 2023",
|
||||||
"title": "Intern – Aubay",
|
"title": "Intern – Aubay",
|
||||||
"description": "Web application development internship focused on full-stack development. Worked on projects using Angular for front-end and Java Spring Boot for back-end, with PostgreSQL databases.",
|
"description": "Web application development internship focused on full-stack development. Worked on projects using Angular for front-end and Java Spring Boot for back-end, with PostgreSQL databases.",
|
||||||
"tools": ["Angular", "TypeScript", "Java Spring Boot", "PostgreSQL", "VS Code", "Eclipse", "Git"],
|
"tools": [
|
||||||
|
{ "name": "Angular", "link": "https://angular.dev/" },
|
||||||
|
{ "name": "TypeScript", "link": "https://www.typescriptlang.org/" },
|
||||||
|
{ "name": "Java", "link": "https://www.java.com/" },
|
||||||
|
{ "name": "Spring Boot", "link": "https://spring.io/projects/spring-boot" },
|
||||||
|
{ "name": "PostgreSQL", "link": "https://www.postgresql.org/" },
|
||||||
|
{ "name": "VS Code", "link": "https://code.visualstudio.com/" },
|
||||||
|
{ "name": "Eclipse", "link": "https://www.eclipse.org/" },
|
||||||
|
{ "name": "Git", "link": "https://git-scm.com/" }
|
||||||
|
],
|
||||||
"icon": "mdi:book"
|
"icon": "mdi:book"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "October 2014 – July 2018",
|
"date": "October 2014 – July 2018",
|
||||||
"title": "CTO – Voxwave",
|
"title": "CTO – Voxwave",
|
||||||
"description": "Co-founded a startup specialized in creating French virtual singers using vocal synthesis. Developed singing synthesis vocal libraries, conducted linguistic research, provided user support, and trained recruits in vocal library development. Led technical development of ALYS, the first professional French singing voice library.",
|
"description": "Co-founded a startup specialized in creating French virtual singers using vocal synthesis. Developed singing synthesis vocal libraries, conducted linguistic research, provided user support, and trained recruits in vocal library development. Led technical development of ALYS, the first professional French singing voice library.",
|
||||||
"tools": ["Alter/Ego", "UTAU", "FL Studio", "iZotope RX", "T-RackS CS"],
|
"tools": [
|
||||||
|
{ "name": "Alter/Ego", "link": "https://www.plogue.com/products/alter-ego.html" },
|
||||||
|
{ "name": "UTAU", "link": "http://utau2008.xrea.jp/" },
|
||||||
|
{ "name": "FL Studio", "link": "https://www.image-line.com/" },
|
||||||
|
{ "name": "iZotope RX", "link": "https://www.izotope.com/en/products/rx.html" },
|
||||||
|
{ "name": "T-RackS CS", "link": "https://www.ikmultimedia.com/products/tr6/" }
|
||||||
|
],
|
||||||
"icon": "mdi:waveform"
|
"icon": "mdi:waveform"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -58,9 +74,52 @@
|
|||||||
"icon": "mdi:book-open-page-variant"
|
"icon": "mdi:book-open-page-variant"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"otherTools": ["Emacs", "Vim", "jj", "PostgreSQL", "SQLite"],
|
"otherTools": [
|
||||||
"devops": ["GitHub", "Gitlab", "Gitea", "GitHub Actions", "Drone.io", "Docker", "Podman"],
|
{ "name": "Emacs", "link": "https://www.gnu.org/software/emacs/" },
|
||||||
"os": ["NixOS", "Debian", "Arch Linux", "Void Linux", "Alpine Linux", "Windows"],
|
{ "name": "vim", "link": "https://www.vim.org/" },
|
||||||
"programmingLanguages": ["TypeScript", "Rust", "C", "EmacsLisp", "Bash/Zsh", "C++", "Python", "CommonLisp"],
|
{ "name": "VS Code", "link": "https://code.visualstudio.com/" },
|
||||||
"frameworks": ["Angular", "Vue", "Nuxt", "Spring Boot", "Poem (Rust)"]
|
{ "name": "Eclipse", "link": "https://www.eclipse.org/" },
|
||||||
|
{ "name": "IntelliJ Idea", "link": "https://www.jetbrains.com/idea/" },
|
||||||
|
{ "name": "jj", "link": "https://docs.jj-vcs.dev/latest/" },
|
||||||
|
{ "name": "Git", "link": "https://git-scm.com/" },
|
||||||
|
{ "name": "PostgreSQL", "link": "https://www.postgresql.org/" },
|
||||||
|
{ "name": "SQLite", "link": "https://sqlite.org/index.html" }
|
||||||
|
],
|
||||||
|
"devops": [
|
||||||
|
{ "name": "GitHub", "link": "https://github.com" },
|
||||||
|
{ "name": "Gitlab", "link": "https://gitlab.com" },
|
||||||
|
{ "name": "Gitea", "link": "https://about.gitea.com/" },
|
||||||
|
{ "name": "GitHub Actions", "link": "https://docs.github.com/en/actions" },
|
||||||
|
{ "name": "Drone.io", "link": "https://www.drone.io/" },
|
||||||
|
{ "name": "Docker", "link": "https://www.docker.com/" },
|
||||||
|
{ "name": "Podman", "link": "https://podman.io/" }
|
||||||
|
],
|
||||||
|
"os": [
|
||||||
|
{ "name": "NixOS", "link": "https://nixos.org/" },
|
||||||
|
{ "name": "Debian", "link": "https://www.debian.org/" },
|
||||||
|
{ "name": "Arch Linux", "link": "https://archlinux.org/" },
|
||||||
|
{ "name": "Void Linux", "link": "https://voidlinux.org/" },
|
||||||
|
{ "name": "Alpine Linux", "link": "https://www.alpinelinux.org/" },
|
||||||
|
{ "name": "Windows", "link": "https://support.microsoft.com/en-us/welcometowindows" }
|
||||||
|
],
|
||||||
|
"programmingLanguages": [
|
||||||
|
{ "name": "TypeScript", "link": "https://www.typescriptlang.org/" },
|
||||||
|
{ "name": "Java", "link": "https://www.java.com/" },
|
||||||
|
{ "name": "Rust", "link": "https://rust-lang.org/" },
|
||||||
|
{ "name": "C", "link": "https://www.c-language.org/" },
|
||||||
|
{ "name": "EmacsLisp", "link": "https://www.gnu.org/software/emacs/manual/html_node/eintr/index.html" },
|
||||||
|
{ "name": "Bash", "link": "https://www.gnu.org/software/bash/" },
|
||||||
|
{ "name": "Zsh", "link": "https://www.zsh.org/" },
|
||||||
|
{ "name": "C++", "link": "https://isocpp.org/" },
|
||||||
|
{ "name": "Python", "link": "https://www.python.org/" },
|
||||||
|
{ "name": "CommonLisp", "link": "https://lisp-lang.org/" }
|
||||||
|
],
|
||||||
|
"frameworks": [
|
||||||
|
{ "name": "Angular", "link": "https://angular.dev/" },
|
||||||
|
{ "name": "Vue", "link": "https://vuejs.org/" },
|
||||||
|
{ "name": "Nuxt", "link": "https://nuxt.com/" },
|
||||||
|
{ "name": "Spring Boot", "link": "https://spring.io/projects/spring-boot" },
|
||||||
|
{ "name": "Poem (Rust)", "link": "https://github.com/poem-web/poem" },
|
||||||
|
{ "name": "Loco.rs", "link": "https://loco.rs/" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,5 +31,15 @@
|
|||||||
"link": "https://alys.phundrak.com/en/faq#are-there-any-plans-for-leora"
|
"link": "https://alys.phundrak.com/en/faq#are-there-any-plans-for-leora"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tools": ["Alter/Ego", "UTAU", "VOCALOID", "ChipSpeech", "FL Studio", "Audacity", "iZotope RX", "T-RackS CS", "C++"]
|
"tools": [
|
||||||
|
{ "name": "Alter/Ego", "link": "https://www.plogue.com/products/alter-ego.html" },
|
||||||
|
{ "name": "UTAU", "link": "http://utau2008.xrea.jp/" },
|
||||||
|
{ "name": "VOCALOID", "link": "https://www.vocaloid.com/en/" },
|
||||||
|
{ "name": "ChipSpeech", "link": "https://plogue.com/products/chipspeech.html" },
|
||||||
|
{ "name": "FL Studio", "link": "https://www.image-line.com/" },
|
||||||
|
{ "name": "Audacity", "link": "https://www.audacityteam.org/" },
|
||||||
|
{ "name": "iZotope RX", "link": "https://www.izotope.com/en/products/rx.html" },
|
||||||
|
{ "name": "T-RackS CS", "link": "https://www.ikmultimedia.com/products/tr6/" },
|
||||||
|
{ "name": "C++", "link": "https://isocpp.org/" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
184
content/fr/keine-tashi.md
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
---
|
||||||
|
title: BSUP01 Keine Tashi
|
||||||
|
---
|
||||||
|
|
||||||
|
# BSUP01 Keine Tashi
|
||||||
|
|
||||||
|
## Présentation
|
||||||
|
|
||||||
|
Keine Tashi est un personnage et le nom d’une collection de banques
|
||||||
|
vocales développées pour le logiciel
|
||||||
|
[UTAU](http://utau2008.web.fc2.com/), un logiciel de synthèse de voix
|
||||||
|
pour le chant. J’ai développé Keine Tashi de 2012 à 2015 et publiai
|
||||||
|
trois de ses banques vocales. Celle ayant rencontre le plus de succès
|
||||||
|
fut sa banque vocale /JPN Extend Power/. Le 10 mars 2017, j’annonçai
|
||||||
|
arrêter toutes activités liées à UTAU.
|
||||||
|
|
||||||
|
<blockquote class="twitter-tweet" data-dnt="true" data-theme="dark">
|
||||||
|
<p lang="en" dir="ltr">
|
||||||
|
I'd like to also announce that from now on I
|
||||||
|
am dropping my previous UTAU projects other than covers and won't
|
||||||
|
develop any new UTAU library
|
||||||
|
</p>
|
||||||
|
— P'undrak (@Phundrak) <a href="https://twitter.com/Phundrak/status/840174634377105408?ref_src=twsrc%5Etfw">March 10, 2017</a>
|
||||||
|
</blockquote>
|
||||||
|
<component is="script" async src="https://platform.twitter.com/widgets.js" charset="utf-8"></component>
|
||||||
|
|
||||||
|
## Personnage et banques vocales
|
||||||
|
|
||||||
|
Voici une traduction en français des informations ayant trait à Keine
|
||||||
|
Tashi sur d’anciennes pages le présentant.
|
||||||
|
|
||||||
|
### Présentation
|
||||||
|
|
||||||
|
{class="small-img"}
|
||||||
|
|
||||||
|
- **Nom de code**: BSUP01 恵音བཀྲ་ཤིས་ Keine Tashi
|
||||||
|
- **Prénom**: Tashi (བཀྲ་ཤིས་), prénom tibétain signifiant « auspicieux »
|
||||||
|
- **Nom**: Keine (恵音), nom japonais signifiant « son bénissant ». Le
|
||||||
|
nom se lit « keine » bien que sa lecture normale devrait être
|
||||||
|
« megumine ».
|
||||||
|
- **Modèle**: BSUP (Bödkay Shetang UTAU Project, /Projet UTAU de Chant
|
||||||
|
Tibétain/)
|
||||||
|
- **Numéro**: 01
|
||||||
|
- **Sexe**: homme
|
||||||
|
- **Anniversaire (personnage)**: 28 juin 1998
|
||||||
|
- **Première publication**: 14 octobre 2012
|
||||||
|
- **Poids**: 154 lb / 70 kg
|
||||||
|
- **Taille**: 182 cm
|
||||||
|
- **Couleur de cheveux**: noir
|
||||||
|
- **Couleur des yeux**: entre le marron et le noir
|
||||||
|
- **Apparance**: Tashi porte une version modernisée d’un habit tibétain
|
||||||
|
traditionnel de la région de l’Amdo (Chinois : 安多 Ānduō) coloré en
|
||||||
|
bleu. Il porte également quelques bijoux de turquoise.
|
||||||
|
- **Nourriture préférée**: momo à la viande (raviolis tibétains)
|
||||||
|
- **Objet signature**: un manuscrit tibétain
|
||||||
|
- **Voix et créateur**: [Phundrak](https://phundrak.com) (moi)
|
||||||
|
- **Aime**: méditer, la calligraphie, les vieux livres et manuscripts
|
||||||
|
(en gros, moi à l’époque ou je créai ce personnage)
|
||||||
|
- **N’aime pas**: l’égoïsme, les mensonges, l’arrogance
|
||||||
|
- **Personnalité**: Tashi est quelqu’un de très calme et d’agréable. Il
|
||||||
|
adore les vieux livres et manuscrits, mais ce qu’il aime par-dessus
|
||||||
|
tout est la méditation. Il n’a jamais faim, ce qui fait qu’il peut
|
||||||
|
rester pendant plusieurs jours à méditer si l’envie le prend,
|
||||||
|
jusqu’au moment où il réalise qu’il a _besoin_ de manger. Il est très
|
||||||
|
difficile de le mettre en colère.
|
||||||
|
|
||||||
|
Mais quand il le devient, sa colère devient explosive. Le calmer
|
||||||
|
devient alors une tâche extrêmement complexe. Étrangement, les
|
||||||
|
victimes de son courroux voient peu de temps après leur qualité de
|
||||||
|
vie grandement s’améliorer. Peut-être ces personnes avaient besoin
|
||||||
|
d’entendre des réalités auxquelles elles refusaient de faire face ?
|
||||||
|
|
||||||
|
### Banques vocales
|
||||||
|
|
||||||
|
#### JPN VCV
|
||||||
|
|
||||||
|
- **Lien de téléchargement**:
|
||||||
|
| Extension | Taille | Lien |
|
||||||
|
|-----------|----------|-----------------------------------------------------------------------------------|
|
||||||
|
| 7z | 25.7 MiB | [DL](https://cdn.phundrak.com/files/KeineTashi/BSUP01_KEINE_Tashi_JPN_VCV.7z) |
|
||||||
|
| tar.xz | 32.5 MiB | [DL](https://cdn.phundrak.com/files/KeineTashi/BSUP01_KEINE_Tashi_JPN_VCV.tar.xz) |
|
||||||
|
| zip | 38.0 MiB | [DL](https://cdn.phundrak.com/files/KeineTashi/BSUP01_KEINE_Tashi_JPN_VCV.zip) |
|
||||||
|
- **Taille décompressée**: 47.1Mio
|
||||||
|
- **Nombre de phonèmes**: 1264 (253 fichiers audio)
|
||||||
|
- **Note moyenne**: G#2
|
||||||
|
- **Plage vocale**: C2~D3
|
||||||
|
- **Présence de fichiers FRQ**: partiel
|
||||||
|
- **Date de publication**: 14 octobre 2012
|
||||||
|
- **Encodage des phonèmes**: Romaji avec des alias hiragana et un
|
||||||
|
support CV en romaji
|
||||||
|
- **Langues supportées**: Japonais
|
||||||
|
- **Moteurs de synthèse recommandés**: TIPS, VS4U
|
||||||
|
|
||||||
|
#### JPN Extend Power
|
||||||
|
|
||||||
|
- **Lien de téléchargement**:
|
||||||
|
| Extension | Taille | Lien |
|
||||||
|
|-----------|--------|--------------------------------------------------------------------------------------------|
|
||||||
|
| 7z | 1.1Gio | [DL](https://cdn.phundrak.com/files/KeineTashi/BSUP01_KEINE_Tashi_JPN_Extend_Power.7z) |
|
||||||
|
| tar.xz | 1.1Gio | [DL](https://cdn.phundrak.com/files/KeineTashi/BSUP01_KEINE_Tashi_JPN_Extend_Power.tar.xz) |
|
||||||
|
| zip | 1.2Gio | [DL](https://cdn.phundrak.com/files/KeineTashi/BSUP01_KEINE_Tashi_JPN_Extend_Power.zip) |
|
||||||
|
- **Taille décompressée**: 1.3Gio
|
||||||
|
- **Nombre de phonèmes**: 3020 (546 fichiers audio)
|
||||||
|
- **Note moyenne**: C3
|
||||||
|
- **Plage vocale**: B1~D4
|
||||||
|
- **Présence de fichiers FRQ**: partiel
|
||||||
|
- **Date de publication**: 28 juin 2013
|
||||||
|
- **Encodage des phonèmes**: Romaji (alias hiragana)
|
||||||
|
- **Langues supportées**: Japonais
|
||||||
|
- **Moteurs de synthèse recommandés**: VS4U, world4utau
|
||||||
|
|
||||||
|
#### JPN Extend Youth
|
||||||
|
|
||||||
|
- **Lien de téléchargement**:
|
||||||
|
| Extension | Taille | Lien |
|
||||||
|
|-----------|----------|--------------------------------------------------------------------------------------------|
|
||||||
|
| 7z | 237.7Mio | [DL](https://cdn.phundrak.com/files/KeineTashi/BSUP01_KEINE_Tashi_JPN_Extend_Youth.7z) |
|
||||||
|
| tar.xz | 243.5Mio | [DL](https://cdn.phundrak.com/files/KeineTashi/BSUP01_KEINE_Tashi_JPN_Extend_Youth.tar.xz) |
|
||||||
|
| zip | 268.7Mio | [DL](https://cdn.phundrak.com/files/KeineTashi/BSUP01_KEINE_Tashi_JPN_Extend_Youth.zip) |
|
||||||
|
- **Taille décompressée**: 301.1Mio
|
||||||
|
- **Nombre de phonèmes**: 1954 (182 fichiers audio)
|
||||||
|
- **Note moyenne**: C4
|
||||||
|
- **Plage vocale**: F#3~A#4
|
||||||
|
- **Présence de fichiers FRQ**: partiel
|
||||||
|
- **Date de publication**: 28 juin 2013
|
||||||
|
- **Encodage des phonèmes**: Romaji (alias hiragana)
|
||||||
|
- **Langues supportées**: Japonais
|
||||||
|
- **Moteurs de synthèse recommandés**: fresamp, VS4U, world4utau
|
||||||
|
|
||||||
|
#### JPN Extend Native
|
||||||
|
|
||||||
|
- **Status**: abandonné
|
||||||
|
|
||||||
|
{class="small-img"}
|
||||||
|
|
||||||
|
#### TIB CVVC
|
||||||
|
|
||||||
|
- **Status**: abandonné
|
||||||
|
|
||||||
|
#### ENG
|
||||||
|
|
||||||
|
- **Status**: abandonné
|
||||||
|
|
||||||
|
# Licence d’utilisation
|
||||||
|
|
||||||
|
Keine Tashi est publié sous la licence [CC BY-SA-NC
|
||||||
|
4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/). Cela
|
||||||
|
signifie que vous êtes libres :
|
||||||
|
|
||||||
|
- **d’utiliser**: utiliser les banques vocales dans UTAU ou tout autre
|
||||||
|
logiciel ;
|
||||||
|
- **de partager**: copier, distribuer et communiquer le matériel par
|
||||||
|
tous moyens et sous tous formats ;
|
||||||
|
- **d’adapter**: remixer, transformer et créer à partir du matériel ;
|
||||||
|
|
||||||
|
Selon les conditions suivantes :
|
||||||
|
|
||||||
|
- **Attribution**: Vous devez me créditer lors de l’utilisation de
|
||||||
|
Tashi, intégrer un lien vers la licence et indiquer si des
|
||||||
|
modifications ont été effectuées. Vous devez indiquer ces
|
||||||
|
informations par tous les moyens raisonnables, sans toutefois
|
||||||
|
suggérer que je vous soutienne ou que je soutienne la façon dont
|
||||||
|
vous utilisez Tashi ;
|
||||||
|
- **Pas d’Utilisation Commerciale**: Vous n’êtes pas autorisé à faire
|
||||||
|
un usage commercial de Tashi, tout ou partie du matériel le
|
||||||
|
composant ;
|
||||||
|
- **Partage dans les Mêmes Conditions**: Dans le cas où vous effectuez
|
||||||
|
un remix, que vous transformez ou créez à partir du matériel
|
||||||
|
composant Tashi, vous devez le diffuser modifié dans les mêmes
|
||||||
|
conditions, c'est-à-dire avec la même licence avec laquelle Tashi
|
||||||
|
est diffusé ici.
|
||||||
|
|
||||||
|
Bien que je ne puisse pas ajouter d’éléments à cette licence légale,
|
||||||
|
je souhaiterais ajouter une requête personnelle : merci de ne pas
|
||||||
|
créer de chansons à caractère religieux, à l’exception des chansons
|
||||||
|
tibétaines bouddhistes ou bön. Cependant, du fait de la controverse
|
||||||
|
actuelle concernant l’identité de Sa Sainteté le Gyalwa Karmapa, toute
|
||||||
|
chanson lié à sa personne est également interdite jusqu’à résolution
|
||||||
|
officielle de la situation. Cette interdiction est également
|
||||||
|
applicable à Sa Sainteté le Dalaï Lama, au Vénérable Shamar Rinpoché
|
||||||
|
et Tai Situ Rinpoche. Si vous avez la moindre question, n’hésitez pas
|
||||||
|
à m’[envoyer un email](/contact).
|
||||||
|
|
||||||
|
#+include: other-links
|
||||||
58
content/fr/languages.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
titre: Langues et création d'univers
|
||||||
|
description: Langues construites (conlangs) et projets de création d'univers.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Langues et création d'univers
|
||||||
|
|
||||||
|
Les _idéolangues_, ou _langues construites_, sont des langues
|
||||||
|
artificielles créées par des individus plutôt que d'avoir évolué
|
||||||
|
naturellement au fil du temps. Ils servent à diverses fins : langues
|
||||||
|
auxiliaires internationales comme l'Espéranto, expériences
|
||||||
|
philosophiques comme le Lojban, création de mondes fictifs comme les
|
||||||
|
langues elfiques de Tolkien ou le Klingon, ou simplement expression
|
||||||
|
artistique.
|
||||||
|
|
||||||
|
Je crée des langues et des mondes construits, à la fois pour le
|
||||||
|
plaisir et pour des projets littéraires. Mon travail sur les langues
|
||||||
|
construites est documenté sur mon [site web dédié à mes langues
|
||||||
|
construites](https://conlang.phundrak.com/).
|
||||||
|
|
||||||
|
## L'Éittlandais
|
||||||
|
|
||||||
|
L'Éittlandais est la langue de l'**Éittlande**, un pays nordique
|
||||||
|
fictif, né de la question suivante : _et s'il existait une autre île
|
||||||
|
comme l'Islande qui n'ait jamais été christianisée ?_
|
||||||
|
|
||||||
|
La langue dérive du vieux norrois et a évolué de manière naturaliste
|
||||||
|
vers des objectifs artistiques spécifiques. En eittlandais, le nom du
|
||||||
|
pays se prononce /ɑɪʔlɑ̃d/.
|
||||||
|
|
||||||
|
Ce projet est un projet artistique personnel depuis 2018, axée sur la
|
||||||
|
création d'univers et la construction de langues artificielles. Plus
|
||||||
|
de détails sont disponibles sur le [wiki
|
||||||
|
Eittland](https://wiki.phundrak.com/s/eittland) et dans la
|
||||||
|
[documentation sur la langue
|
||||||
|
eittlandaise](https://conlang.phundrak.com/eittlandic).
|
||||||
|
|
||||||
|
## Proto-Ñyqy
|
||||||
|
|
||||||
|
Le Proto-Ñyqy est la proto-langue et la langue mère de l'arbre
|
||||||
|
généalogique de la famille linguistique Ñyqy. La documentation est
|
||||||
|
rédigée comme le serait un document interne à l'univers, ce qui
|
||||||
|
signifie que toutes les références culturelles et historiques sont
|
||||||
|
entièrement fictives. Le style d'écriture s'inspire des travaux
|
||||||
|
linguistiques universitaires, en particulier de l'ouvrage
|
||||||
|
_Indo-European Language and Culture_ de Benjamin W. Fortson. La
|
||||||
|
[documentation sur le
|
||||||
|
Proto-Ñyqy](https://conlang.phundrak.com/proto-nyqy) est accessible en
|
||||||
|
ligne.
|
||||||
|
|
||||||
|
## Zikãti
|
||||||
|
|
||||||
|
Le Zikãti est un autre projet de langue artificielle actuellement en
|
||||||
|
cours de développement. Celui-ci s'inspire davantage d'une expérience
|
||||||
|
de création de langue artificielle que d'un véritable projet de
|
||||||
|
construction d'univers. [Sa
|
||||||
|
documentation](https://conlang.phundrak.com/zik%C3%A3ti) est également
|
||||||
|
accessible en ligne.
|
||||||
@@ -5,15 +5,16 @@
|
|||||||
"title": "Consultant – Aubay",
|
"title": "Consultant – Aubay",
|
||||||
"description": "Consultant en développement web travaillant sur des applications d'entreprise. Je continue à me concentrer sur le développement front-end Angular et les services back-end Java Spring Boot avec des bases de données PostgreSQL.",
|
"description": "Consultant en développement web travaillant sur des applications d'entreprise. Je continue à me concentrer sur le développement front-end Angular et les services back-end Java Spring Boot avec des bases de données PostgreSQL.",
|
||||||
"tools": [
|
"tools": [
|
||||||
"Angular",
|
{ "name": "Angular", "link": "https://angular.dev/" },
|
||||||
"TypeScript",
|
{ "name": "TypeScript", "link": "https://www.typescriptlang.org/" },
|
||||||
"Java Spring Boot",
|
{ "name": "Java", "link": "https://www.java.com/" },
|
||||||
"Java Spring Batch",
|
{ "name": "Spring Boot", "link": "https://spring.io/projects/spring-boot" },
|
||||||
"PostgreSQL",
|
{ "name": "Spring Batch", "link": "https://spring.io/projects/spring-batch" },
|
||||||
"VS Code",
|
{ "name": "PostgreSQL", "link": "https://www.postgresql.org/" },
|
||||||
"Eclipse",
|
{ "name": "VS Code", "link": "https://code.visualstudio.com/" },
|
||||||
"IntelliJ Idea",
|
{ "name": "Eclipse", "link": "https://www.eclipse.org/" },
|
||||||
"Git"
|
{ "name": "IntelliJ Idea", "link": "https://www.jetbrains.com/idea/" },
|
||||||
|
{ "name": "Git", "link": "https://git-scm.com/" }
|
||||||
],
|
],
|
||||||
"icon": "mdi:laptop"
|
"icon": "mdi:laptop"
|
||||||
},
|
},
|
||||||
@@ -21,14 +22,29 @@
|
|||||||
"date": "Février 2023 – Août 2023",
|
"date": "Février 2023 – Août 2023",
|
||||||
"title": "Stagiaire – Aubay",
|
"title": "Stagiaire – Aubay",
|
||||||
"description": "Stage en développement d'applications web axé sur le développement full-stack. J'ai travaillé sur des projets utilisant Angular pour le front-end et Java Spring Boot pour le back-end, avec des bases de données PostgreSQL.",
|
"description": "Stage en développement d'applications web axé sur le développement full-stack. J'ai travaillé sur des projets utilisant Angular pour le front-end et Java Spring Boot pour le back-end, avec des bases de données PostgreSQL.",
|
||||||
"tools": ["Angular", "TypeScript", "Java Spring Boot", "PostgreSQL", "VS Code", "Eclipse", "Git"],
|
"tools": [
|
||||||
|
{ "name": "Angular", "link": "https://angular.dev/" },
|
||||||
|
{ "name": "TypeScript", "link": "https://www.typescriptlang.org/" },
|
||||||
|
{ "name": "Java", "link": "https://www.java.com/" },
|
||||||
|
{ "name": "Spring Boot", "link": "https://spring.io/projects/spring-boot" },
|
||||||
|
{ "name": "PostgreSQL", "link": "https://www.postgresql.org/" },
|
||||||
|
{ "name": "VS Code", "link": "https://code.visualstudio.com/" },
|
||||||
|
{ "name": "Eclipse", "link": "https://www.eclipse.org/" },
|
||||||
|
{ "name": "Git", "link": "https://git-scm.com/" }
|
||||||
|
],
|
||||||
"icon": "mdi:book"
|
"icon": "mdi:book"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"date": "Octobre 2014 – Juillet 2018",
|
"date": "Octobre 2014 – Juillet 2018",
|
||||||
"title": "Directeur technique – Voxwave",
|
"title": "Directeur technique – Voxwave",
|
||||||
"description": "Co-fondateur d'une start-up spécialisée dans la création de chanteurs virtuels français à l'aide de la synthèse vocale. Développement de banques vocales de synthèse chantée, recherche linguistique, assistance aux utilisateurs et formation des recrues au développement de banques vocales. Direction du développement technique d'ALYS, la première banques vocale professionnelle de chant en français.",
|
"description": "Co-fondateur d'une start-up spécialisée dans la création de chanteurs virtuels français à l'aide de la synthèse vocale. Développement de banques vocales de synthèse chantée, recherche linguistique, assistance aux utilisateurs et formation des recrues au développement de banques vocales. Direction du développement technique d'ALYS, la première banques vocale professionnelle de chant en français.",
|
||||||
"tools": ["Alter/Ego", "UTAU", "FL Studio", "iZotope RX", "T-RackS CS"],
|
"tools": [
|
||||||
|
{ "name": "Alter/Ego", "link": "https://www.plogue.com/products/alter-ego.html" },
|
||||||
|
{ "name": "UTAU", "link": "http://utau2008.xrea.jp/" },
|
||||||
|
{ "name": "FL Studio", "link": "https://www.image-line.com/" },
|
||||||
|
{ "name": "iZotope RX", "link": "https://www.izotope.com/en/products/rx.html" },
|
||||||
|
{ "name": "T-RackS CS", "link": "https://www.ikmultimedia.com/products/tr6/" }
|
||||||
|
],
|
||||||
"icon": "mdi:waveform"
|
"icon": "mdi:waveform"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -58,9 +74,51 @@
|
|||||||
"icon": "mdi:book-open-page-variant"
|
"icon": "mdi:book-open-page-variant"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"otherTools": ["Emacs", "Vim", "jj", "PostgreSQL", "SQLite"],
|
"otherTools": [
|
||||||
"devops": ["GitHub", "Gitlab", "Gitea", "GitHub Actions", "Drone.io", "Docker", "Podman"],
|
{ "name": "Emacs", "link": "https://www.gnu.org/software/emacs/" },
|
||||||
"os": ["NixOS", "Debian", "Arch Linux", "Void Linux", "Alpine Linux", "Windows"],
|
{ "name": "vim", "link": "https://www.vim.org/" },
|
||||||
"programmingLanguages": ["TypeScript", "Rust", "C", "EmacsLisp", "Bash/Zsh", "C++", "Python", "CommonLisp"],
|
{ "name": "VS Code", "link": "https://code.visualstudio.com/" },
|
||||||
"frameworks": ["Angular", "Vue", "Nuxt", "Spring Boot", "Poem (Rust)"]
|
{ "name": "Eclipse", "link": "https://www.eclipse.org/" },
|
||||||
|
{ "name": "IntelliJ Idea", "link": "https://www.jetbrains.com/idea/" },
|
||||||
|
{ "name": "jj", "link": "https://docs.jj-vcs.dev/latest/" },
|
||||||
|
{ "name": "Git", "link": "https://git-scm.com/" },
|
||||||
|
{ "name": "PostgreSQL", "link": "https://www.postgresql.org/" },
|
||||||
|
{ "name": "SQLite", "link": "https://sqlite.org/index.html" }
|
||||||
|
],
|
||||||
|
"devops": [
|
||||||
|
{ "name": "GitHub", "link": "https://github.com" },
|
||||||
|
{ "name": "Gitlab", "link": "https://gitlab.com" },
|
||||||
|
{ "name": "Gitea", "link": "https://about.gitea.com/" },
|
||||||
|
{ "name": "GitHub Actions", "link": "https://docs.github.com/en/actions" },
|
||||||
|
{ "name": "Drone.io", "link": "https://www.drone.io/" },
|
||||||
|
{ "name": "Docker", "link": "https://www.docker.com/" },
|
||||||
|
{ "name": "Podman", "link": "https://podman.io/" }
|
||||||
|
],
|
||||||
|
"os": [
|
||||||
|
{ "name": "NixOS", "link": "https://nixos.org/" },
|
||||||
|
{ "name": "Debian", "link": "https://www.debian.org/" },
|
||||||
|
{ "name": "Arch Linux", "link": "https://archlinux.org/" },
|
||||||
|
{ "name": "Void Linux", "link": "https://voidlinux.org/" },
|
||||||
|
{ "name": "Alpine Linux", "link": "https://www.alpinelinux.org/" },
|
||||||
|
{ "name": "Windows", "link": "https://support.microsoft.com/en-us/welcometowindows" }
|
||||||
|
],
|
||||||
|
"programmingLanguages": [
|
||||||
|
{ "name": "TypeScript", "link": "https://www.typescriptlang.org/" },
|
||||||
|
{ "name": "Rust", "link": "https://rust-lang.org/" },
|
||||||
|
{ "name": "C", "link": "https://www.c-language.org/" },
|
||||||
|
{ "name": "EmacsLisp", "link": "https://www.gnu.org/software/emacs/manual/html_node/eintr/index.html" },
|
||||||
|
{ "name": "Bash", "link": "https://www.gnu.org/software/bash/" },
|
||||||
|
{ "name": "Zsh", "link": "https://www.zsh.org/" },
|
||||||
|
{ "name": "C++", "link": "https://isocpp.org/" },
|
||||||
|
{ "name": "Python", "link": "https://www.python.org/" },
|
||||||
|
{ "name": "CommonLisp", "link": "https://lisp-lang.org/" }
|
||||||
|
],
|
||||||
|
"frameworks": [
|
||||||
|
{ "name": "Angular", "link": "https://angular.dev/" },
|
||||||
|
{ "name": "Vue", "link": "https://vuejs.org/" },
|
||||||
|
{ "name": "Nuxt", "link": "https://nuxt.com/" },
|
||||||
|
{ "name": "Spring Boot", "link": "https://spring.io/projects/spring-boot" },
|
||||||
|
{ "name": "Poem (Rust)", "link": "https://github.com/poem-web/poem" },
|
||||||
|
{ "name": "Loco.rs", "link": "https://loco.rs/" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,5 +31,15 @@
|
|||||||
"link": "https://alys.phundrak.com/faq#y-a-t-il-quelque-chose-de-prevu-pour-leora"
|
"link": "https://alys.phundrak.com/faq#y-a-t-il-quelque-chose-de-prevu-pour-leora"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tools": ["Alter/Ego", "UTAU", "VOCALOID", "ChipSpeech", "FL Studio", "Audacity", "iZotope RX", "T-RackS CS", "C++"]
|
"tools": [
|
||||||
|
{ "name": "Alter/Ego", "link": "https://www.plogue.com/products/alter-ego.html" },
|
||||||
|
{ "name": "UTAU", "link": "http://utau2008.xrea.jp/" },
|
||||||
|
{ "name": "VOCALOID", "link": "https://www.vocaloid.com/en/" },
|
||||||
|
{ "name": "ChipSpeech", "link": "https://plogue.com/products/chipspeech.html" },
|
||||||
|
{ "name": "FL Studio", "link": "https://www.image-line.com/" },
|
||||||
|
{ "name": "Audacity", "link": "https://www.audacityteam.org/" },
|
||||||
|
{ "name": "iZotope RX", "link": "https://www.izotope.com/en/products/rx.html" },
|
||||||
|
{ "name": "T-RackS CS", "link": "https://www.ikmultimedia.com/products/tr6/" },
|
||||||
|
{ "name": "C++", "link": "https://isocpp.org/" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,4 @@
|
|||||||
export default defineI18nConfig(() => ({
|
export default defineI18nConfig(() => ({
|
||||||
legacy: false,
|
legacy: false,
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
messages: {
|
|
||||||
en: {
|
|
||||||
welcome: 'Welcome',
|
|
||||||
},
|
|
||||||
fr: {
|
|
||||||
welcome: 'Bienvenue',
|
|
||||||
},
|
|
||||||
lfn: {
|
|
||||||
welcome: 'Bonveni',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -38,12 +38,42 @@
|
|||||||
"name": "Languages & Worldbuilding"
|
"name": "Languages & Worldbuilding"
|
||||||
},
|
},
|
||||||
"contact": {
|
"contact": {
|
||||||
"name": "Contact"
|
"description": "Send me an email",
|
||||||
|
"name": "Contact",
|
||||||
|
"toast": {
|
||||||
|
"success": "Email sent!",
|
||||||
|
"error": "Failure sending message"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"sendButton": "Send Message",
|
||||||
|
"validation": {
|
||||||
|
"shortName": "Must contain at least one character",
|
||||||
|
"longName": "Cannot exceed 100 characters",
|
||||||
|
"shortMessage": "Must contain at least 10 characters",
|
||||||
|
"longMessage": "Cannot exceed 5000 characters",
|
||||||
|
"invalidEmail": "Invalid email address format"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"name": "Name",
|
||||||
|
"email": "Email Address",
|
||||||
|
"message": "Message",
|
||||||
|
"website": "Website"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"name": "Alex Taylor",
|
||||||
|
"email": "alex.taylor[at]example.com",
|
||||||
|
"message": "Hello, ...",
|
||||||
|
"website": "https://example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"links": {
|
"links": {
|
||||||
"source": "Website’s source code",
|
"source": {
|
||||||
|
"backend": "Backend Source Code",
|
||||||
|
"frontend": "Frontend Source Code"
|
||||||
|
},
|
||||||
"nuxt": "Frontend made with Nuxt",
|
"nuxt": "Frontend made with Nuxt",
|
||||||
"rust": "Backend made with Rust"
|
"rust": "Backend made with Rust"
|
||||||
},
|
},
|
||||||
@@ -51,5 +81,25 @@
|
|||||||
"frontend": "Frontend Version",
|
"frontend": "Frontend Version",
|
||||||
"backend": "Backend Version"
|
"backend": "Backend Version"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"backend": {
|
||||||
|
"failed": "Error",
|
||||||
|
"errors": {
|
||||||
|
"title": "There was an error",
|
||||||
|
"unknown": "The website encountered an unknown error. Please try again later."
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"success": "We’ve also sent you a confirmation email. If you haven’t received anything in a few minutes, please check your junk mail.",
|
||||||
|
"honeypot": "Mmmmmh, I love me some honey from the honeypot!",
|
||||||
|
"errors": {
|
||||||
|
"internal": "The website encountered an internal error. Please try again later.",
|
||||||
|
"validation": {
|
||||||
|
"name": "Incorrect name format. Must contain from 1 to 50 characters.",
|
||||||
|
"email": "Incorrect email format.",
|
||||||
|
"message": "Incorrect message format. Must contain from 10 to 5000 characters.",
|
||||||
|
"other": "Malformed request."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,18 +38,68 @@
|
|||||||
"name": "Langues et Univers Fictifs"
|
"name": "Langues et Univers Fictifs"
|
||||||
},
|
},
|
||||||
"contact": {
|
"contact": {
|
||||||
"name": "Contact"
|
"name": "Contact",
|
||||||
|
"description": "M’envoyer un couriel",
|
||||||
|
"toast": {
|
||||||
|
"success": "Couriel envoyé !",
|
||||||
|
"error": "Erreur lors de l'envoi du message"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"sendButton": "Envoyer le message",
|
||||||
|
"validation": {
|
||||||
|
"shortName": "Longueur minimale du nom : 1 caractère",
|
||||||
|
"longName": "Longeur maximale du nom : 100 caractères",
|
||||||
|
"shortMessage": "Longueur minimale du message : 10 caractères",
|
||||||
|
"longMessage": "Longueur maximale du message : 5000 caractères",
|
||||||
|
"invalidEmail": "Format adresse courriel invalide"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"name": "Nom",
|
||||||
|
"email": "Addresse courriel",
|
||||||
|
"message": "Message",
|
||||||
|
"website": "Site web"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"name": "Dominique Dubois",
|
||||||
|
"email": "dominique.dubois[at]example.com",
|
||||||
|
"message": "Bonjour, ...",
|
||||||
|
"website": "https://example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"links": {
|
"links": {
|
||||||
"source": "Code source du site web",
|
"source": {
|
||||||
|
"backend": "Code source du backend",
|
||||||
|
"frontend": "Code source du frontend"
|
||||||
|
},
|
||||||
"nuxt": "Frontend fait avec Nuxt",
|
"nuxt": "Frontend fait avec Nuxt",
|
||||||
"rust": "Backend fait avec Rust"
|
"rust": "Backend fait avec Rust"
|
||||||
},
|
},
|
||||||
"versions": {
|
"versions": {
|
||||||
"frontend": "Frontend Version",
|
"frontend": "Version frontend",
|
||||||
"backend": "Backend Version"
|
"backend": "Version backend"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backend": {
|
||||||
|
"failed": "Erreur",
|
||||||
|
"errors": {
|
||||||
|
"title": "Une erreur est survenue",
|
||||||
|
"unknown": "Une erreur inconnue est survenue. Veuillez réessayer plus tard."
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"success": "Un email de confirmation vous a été également envoyé. Si vous n’avez rien reçu d’ici quelques minutes, vérifiez vos spams.",
|
||||||
|
"honeypot": "Miam, du bon miel pour le robot !",
|
||||||
|
"errors": {
|
||||||
|
"internal": "Une erreur interne est survenue, veuillez réessayer plus tard.",
|
||||||
|
"validation": {
|
||||||
|
"name": "Format du nom incorrect. Doit faire de 1 à 50 caractères.",
|
||||||
|
"email": "Format de l’adresse courriel invalide.",
|
||||||
|
"message": "Format du message invalide. Doit faire entre 10 et 5000 caractères.",
|
||||||
|
"other": "Données de la requête malformées."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ export default defineNuxtConfig({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
vueDevTools: true,
|
vueDevTools: true,
|
||||||
telemetry: false,
|
telemetry: false,
|
||||||
|
|
||||||
|
timeline: {
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
modules: [
|
modules: [
|
||||||
@@ -14,7 +18,6 @@ export default defineNuxtConfig({
|
|||||||
'@nuxt/ui',
|
'@nuxt/ui',
|
||||||
'@nuxt/content',
|
'@nuxt/content',
|
||||||
'@nuxtjs/i18n',
|
'@nuxtjs/i18n',
|
||||||
'@nuxtjs/turnstile',
|
|
||||||
'@nuxtjs/device',
|
'@nuxtjs/device',
|
||||||
'@nuxt/icon',
|
'@nuxt/icon',
|
||||||
'@nuxt/fonts',
|
'@nuxt/fonts',
|
||||||
@@ -36,6 +39,8 @@ export default defineNuxtConfig({
|
|||||||
// { code: 'lfn', name: 'Elefen', language: 'lfn', file: 'lfn.json' },
|
// { code: 'lfn', name: 'Elefen', language: 'lfn', file: 'lfn.json' },
|
||||||
// { code: 'ei', name: 'Eittlandic', language: 'ei-ST', file: 'ei.json' },
|
// { code: 'ei', name: 'Eittlandic', language: 'ei-ST', file: 'ei.json' },
|
||||||
],
|
],
|
||||||
|
langDir: 'locales',
|
||||||
|
lazy: false,
|
||||||
strategy: 'no_prefix',
|
strategy: 'no_prefix',
|
||||||
defaultLocale: 'en',
|
defaultLocale: 'en',
|
||||||
},
|
},
|
||||||
@@ -65,16 +70,16 @@ export default defineNuxtConfig({
|
|||||||
'autoprefixer': {}
|
'autoprefixer': {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
turnstile: {
|
|
||||||
siteKey: '', // Overridden by NUXT_PUBLIC_TURNSTILE_SITE_KEY
|
|
||||||
addValidateEndpoint: true
|
|
||||||
},
|
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
turnstile: {
|
|
||||||
secretKey: '', // Overriden by NUXT_TURNSTILE_SECRET_KEY
|
|
||||||
},
|
|
||||||
public: {
|
public: {
|
||||||
apiBase: process.env.NUXT_PUBLIC_API_BASE || 'http://localhost:3100/api',
|
apiBase: process.env.NUXT_PUBLIC_API_BASE || 'http://localhost:3100/api',
|
||||||
|
urlBase: process.env.NUXT_PUBLIC_URL_BASE || 'http://localhost:3000/',
|
||||||
|
fediverseCreator: process.env.NUXT_PUBLIC_FEDIVERSE_CREATOR || ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
nitro: {
|
||||||
|
prerender: {
|
||||||
|
autoSubfolderIndex: false
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
55
package.json
@@ -4,15 +4,17 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nuxt build",
|
"build": "nuxt build --preset=cloudflare_pages",
|
||||||
"dev": "nuxt dev",
|
"dev": "nuxt dev",
|
||||||
"generate": "nuxt generate",
|
|
||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
|
"deploy:develop": "wrangler pages deploy dist/ --branch develop --project-name=dev-phundrak-com",
|
||||||
"postinstall": "nuxt prepare",
|
"postinstall": "nuxt prepare",
|
||||||
"cleanup": "nuxt cleanup",
|
"cleanup": "nuxt cleanup",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"format": "prettier --write app/ i18n/ content/",
|
"format": "prettier --write app/ i18n/ content/",
|
||||||
"format-check": "prettier --check app/ i18n/ content/"
|
"format-check": "prettier --check app/ i18n/ content/",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:local": "vitest -c vitest.config.local.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/content": "3.8.0",
|
"@nuxt/content": "3.8.0",
|
||||||
@@ -21,34 +23,41 @@
|
|||||||
"@nuxt/icon": "2.1.0",
|
"@nuxt/icon": "2.1.0",
|
||||||
"@nuxt/image": "1.11.0",
|
"@nuxt/image": "1.11.0",
|
||||||
"@nuxt/scripts": "^0.12.2",
|
"@nuxt/scripts": "^0.12.2",
|
||||||
"@nuxt/test-utils": "3.20.1",
|
|
||||||
"@nuxt/ui": "4.1.0",
|
"@nuxt/ui": "4.1.0",
|
||||||
"@nuxtjs/color-mode": "3.5.2",
|
"@nuxtjs/color-mode": "3.5.2",
|
||||||
"@nuxtjs/device": "3.2.4",
|
"@nuxtjs/device": "3.2.4",
|
||||||
"@nuxtjs/tailwindcss": "7.0.0-beta.0",
|
"@nuxtjs/tailwindcss": "7.0.0-beta.0",
|
||||||
"@nuxtjs/turnstile": "1.1.1",
|
"better-sqlite3": "^12.6.2",
|
||||||
"better-sqlite3": "^12.4.1",
|
"eslint": "^9.39.2",
|
||||||
"eslint": "^9.39.1",
|
"nitropack": "^2.13.1",
|
||||||
"nitropack": "^2.12.9",
|
"nuxi": "^3.32.0",
|
||||||
"nuxi": "^3.30.0",
|
"nuxt": "^4.3.0",
|
||||||
"nuxt": "^4.2.0",
|
"vite": "^7.3.1",
|
||||||
"vite": "^7.1.12",
|
"vue": "^3.5.27",
|
||||||
"vue": "^3.5.22",
|
"vue-router": "^4.6.4"
|
||||||
"vue-router": "^4.6.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.73",
|
"@iconify-json/lucide": "^1.2.88",
|
||||||
"@iconify-json/material-symbols": "^1.2.44",
|
"@iconify-json/material-symbols": "^1.2.53",
|
||||||
"@iconify-json/material-symbols-light": "^1.2.44",
|
"@iconify-json/material-symbols-light": "^1.2.53",
|
||||||
"@iconify-json/mdi": "^1.2.3",
|
"@iconify-json/mdi": "^1.2.3",
|
||||||
"@iconify-json/simple-icons": "^1.2.58",
|
"@iconify-json/simple-icons": "^1.2.69",
|
||||||
"@nuxtjs/i18n": "^10.2.0",
|
"@nuxt/test-utils": "3.20.1",
|
||||||
"@tailwindcss/postcss": "^4.1.17",
|
"@nuxtjs/i18n": "^10.2.1",
|
||||||
"autoprefixer": "^10.4.22",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"less": "^4.4.2",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
|
"@vitest/ui": "^4.0.18",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
|
"autoprefixer": "^10.4.24",
|
||||||
|
"baseline-browser-mapping": "^2.9.19",
|
||||||
|
"happy-dom": "^20.5.0",
|
||||||
|
"less": "^4.5.1",
|
||||||
|
"playwright-core": "^1.58.1",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"zod": "^4.1.12"
|
"vitest": "^4.0.18",
|
||||||
|
"wrangler": "^4.62.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6162
pnpm-lock.yaml
generated
@@ -5,3 +5,4 @@ onlyBuiltDependencies:
|
|||||||
- sharp
|
- sharp
|
||||||
- unrs-resolver
|
- unrs-resolver
|
||||||
- vue-demi
|
- vue-demi
|
||||||
|
- workerd
|
||||||
|
|||||||
BIN
public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 245 KiB |
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 809 B |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 15 KiB |
BIN
public/leon.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
1
public/site.webmanifest
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
19
vitest.config.local.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import {defineVitestConfig} from '@nuxt/test-utils/config';
|
||||||
|
|
||||||
|
export default defineVitestConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'nuxt',
|
||||||
|
exclude: [
|
||||||
|
'**/.direnv/**',
|
||||||
|
'**/.devenv/**',
|
||||||
|
'**/node_modules/**'
|
||||||
|
],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
enabled: true,
|
||||||
|
reporter: ['html']
|
||||||
|
},
|
||||||
|
watch: true,
|
||||||
|
ui: true
|
||||||
|
}
|
||||||
|
})
|
||||||
18
vitest.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import {defineVitestConfig} from '@nuxt/test-utils/config';
|
||||||
|
|
||||||
|
export default defineVitestConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'nuxt',
|
||||||
|
exclude: [
|
||||||
|
'**/.direnv/**',
|
||||||
|
'**/.devenv/**',
|
||||||
|
'**/node_modules/**'
|
||||||
|
],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
enabled: true,
|
||||||
|
reporter: ['lcovonly']
|
||||||
|
},
|
||||||
|
watch: false
|
||||||
|
}
|
||||||
|
})
|
||||||
9
wrangler.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
name = "phundrak-com-frontend"
|
||||||
|
compatibility_date = "2025-01-01"
|
||||||
|
pages_build_output_dir = "dist"
|
||||||
|
|
||||||
|
# D1 Database binding for Nuxt Content
|
||||||
|
[[d1_databases]]
|
||||||
|
binding = "DB"
|
||||||
|
database_name = "dev-phundrak-content"
|
||||||
|
database_id = "91ad6cc9-c5ee-4d61-951c-1e74c77f6892"
|
||||||