Compare commits

..

17 Commits

Author SHA1 Message Date
dadd87a4be feat(CSS): change colors to Nord theme 2026-02-05 13:01:25 +01:00
af84a7fb9f feat(footer): more footer content 2026-02-05 12:44:11 +01:00
33f57f0bd5 feat(SEO): better metadata and SEO tags 2026-02-05 12:44:11 +01:00
c47cfed5ae fix(contact): update metadata of contact page 2026-02-05 12:43:48 +01:00
1969d59186 chore(deps): update dependencies 2026-02-05 12:43:48 +01:00
8187aabbb1 build: configure Cloudflare Pages deployment
- Add wrangler CLI and workerd runtime as dev dependencies
- Configure nitro prerender with autoSubfolderIndex disabled for Pages
- Add Turnstile validation endpoint for server-side CAPTCHA verification
- Add baseline-browser-mapping for browser compatibility
2026-02-05 12:43:48 +01:00
8f395b972c test: add comprehensive test suite for components, composables, and pages
Add 16 new test files covering:
- Composables: useBackend, useMeta, useDataJson
- Type classes: QueryResult, ResumeContent
- UI components: BadgeList, BadgeListCard
- Navbar components: LanguageSwitcher, ThemeSwitcher
- App components: AppNavbar, AppFooter
- VocalSynth components: Projects, Tools
- Pages: contact, resume, [...slug]

Tests focus on pure logic, interfaces, and component rendering where
possible, avoiding complex mocking of Nuxt auto-imported composables.

Total: 174 tests across 17 test files (including existing useApi tests).
2026-02-05 12:43:48 +01:00
0114ddf30b docs(content): add Keine Tashi UTAU project page
Add bilingual (EN/FR) content pages documenting the BSUP01 Keine Tashi
UTAU vocal library project, including character information, vocal
library specifications, download links, and usage licence.

Also add .small-img CSS class for floating images in content pages.
2026-02-05 12:43:17 +01:00
c6648c4075 style(content): normalise italic markdown syntax in languages pages
Change *italic* to _italic_ for consistency across English and French
languages content pages.
2026-02-05 12:19:12 +01:00
3b6578daa2 refactor(tests): remove unnecessary parameters from useApi tests
Simplify POST, PUT, and PATCH test calls by removing redundant empty
object and boolean parameters.
2026-02-05 12:19:12 +01:00
034fdc2afe feat(resume,vocal-synth): add clickable links to tools and technologies
- Add Tool interface with name and optional link properties
- Update BadgeList and BadgeListCard components to render links
- Extract VocalSynthPage types to dedicated module
- Migrate resume.json and vocal-synthesis.json data to use Tool format
- Add links to all tools, frameworks, and technologies in resume
2026-02-05 12:19:12 +01:00
13f423e455 docs(content): add languages & worldbuilding page content
Cover conlanging projects: Eittlandic (Old Norse-derived language
for fictional Nordic country), Proto-Ñyqy (proto-language of the
Ñyqy family), and Zikãti (conlanging experiment)
2026-02-05 12:19:12 +01:00
7ae22106fa fix(tests): properly mock $fetch in useApi tests
Replace vi.mocked($fetch) with a properly stubbed global mock
using vi.stubGlobal() to fix test failures
2026-02-05 12:19:12 +01:00
ea35c524e2 feat(contact): add toast notifications for form feedback
- Add toast notifications for contact form success/error responses
- Add toast notifications for backend errors in AppFooter
- Add accessibility explanation for honeypot field
- Add loading state to contact form submit button
- Add i18n translations for toast messages (en/fr)
- Fix honeypot input missing v-model binding
2026-02-05 12:19:12 +01:00
dfec7bb869 feat(pages): add contact page 2025-12-07 20:02:35 +01:00
0101592145 feat(useApi): better interface 2025-11-20 12:00:28 +01:00
1563ab1f45 fix: incorrect types now fixed 2025-11-20 11:21:26 +01:00
64 changed files with 7729 additions and 2447 deletions

View File

@@ -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
View File

@@ -31,3 +31,4 @@ node_modules
# Nix # Nix
result result
.data/ .data/
app/coverage/*

View File

@@ -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>

View File

@@ -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);
} }

View File

@@ -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;
}

View 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();
});
});
});

View File

@@ -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 &copy; {{ new Date().getFullYear() }}</p> <p class="text-text-800 text-sm">Copyright &copy; {{ new Date().getFullYear() }}</p>
<p class="text-text-800 text-sm">{{ $t('footer.versions.frontend') }}: {{ version }}</p> <p class="text-text-800 text-sm">{{ $t('footer.versions.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>

View 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);
});
});
});

View 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);
});
});
});

View File

@@ -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>

View 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);
});
});
});

View File

@@ -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>

View 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');
});
});
});

View File

@@ -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>

View 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');
});
});
});

View 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>

View 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');
});
});
});

View 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');
});
});
});

View 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');
});
});
});

View File

@@ -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 };
}; };

View 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');
});
});
});

View File

@@ -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 };
}; };

View 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();
});
});
});

View File

@@ -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) => {

View 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
View 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
View 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
View 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);
});
});
});

View File

@@ -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
View 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
View 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';

View 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
View 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
View 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);
});
});
});

View File

@@ -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[] = [];
} }

View File

@@ -0,0 +1,5 @@
export interface SocialAccount {
icon: string;
label: string;
link: string;
}

View File

@@ -0,0 +1,4 @@
export interface Tool {
name: string;
link?: string;
}

13
app/types/vocal-synth.ts Normal file
View 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
View 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">
Id like to also announce that from now on I am
dropping my previous UTAU projects other than covers and wont develop
any new UTAU library
</p>
— Pundrak (@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
![Illustration of Keine Tashi by Umi](https://cdn.phundrak.com/img/UTAU/KEINE_Tashi_1024.webp){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**: 60″ / 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! Hes
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, its 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
![Keine Tashi English](https://cdn.phundrak.com/img/UTAU/KEINE_Tashi_EN_673.webp){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
View 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.

View File

@@ -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/" }
]
} }

View File

@@ -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
View File

@@ -0,0 +1,184 @@
---
title: BSUP01 Keine Tashi
---
# BSUP01 Keine Tashi
## Présentation
Keine Tashi est un personnage et le nom dune 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. Jai 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, jannonç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&#39;d like to also announce that from now on I
am dropping my previous UTAU projects other than covers and won&#39;t
develop any new UTAU library
</p>
&mdash; P&#39;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 danciennes pages le présentant.
### Présentation
![Illustration de Keine Tashi par Umi](https://cdn.phundrak.com/img/UTAU/KEINE_Tashi_1024.webp){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**: 154lb / 70kg
- **Taille**: 182cm
- **Couleur de cheveux**: noir
- **Couleur des yeux**: entre le marron et le noir
- **Apparance**: Tashi porte une version modernisée dun habit tibétain
traditionnel de la région de lAmdo (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)
- **Naime pas**: légoïsme, les mensonges, larrogance
- **Personnalité**: Tashi est quelquun de très calme et dagréable. Il
adore les vieux livres et manuscrits, mais ce quil aime par-dessus
tout est la méditation. Il na jamais faim, ce qui fait quil peut
rester pendant plusieurs jours à méditer si lenvie le prend,
jusquau moment où il réalise quil 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 saméliorer. Peut-être ces personnes avaient besoin
dentendre 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é
![Keine Tashi English](https://cdn.phundrak.com/img/UTAU/KEINE_Tashi_EN_673.webp){class="small-img"}
#### TIB CVVC
- **Status**: abandonné
#### ENG
- **Status**: abandonné
# Licence dutilisation
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 :
- **dutiliser**: 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 ;
- **dadapter**: remixer, transformer et créer à partir du matériel ;
Selon les conditions suivantes :
- **Attribution**: Vous devez me créditer lors de lutilisation 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 dUtilisation 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, à lexception des chansons
tibétaines bouddhistes ou bön. Cependant, du fait de la controverse
actuelle concernant lidentité 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, nhésitez pas
à m[envoyer un email](/contact).
#+include: other-links

58
content/fr/languages.md Normal file
View 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.

View File

@@ -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/" }
]
} }

View File

@@ -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/" }
]
} }

View File

@@ -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',
},
},
})); }));

View File

@@ -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": "Websites 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": "Weve also sent you a confirmation email. If you havent 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."
}
}
}
} }
} }

View File

@@ -38,18 +38,68 @@
"name": "Langues et Univers Fictifs" "name": "Langues et Univers Fictifs"
}, },
"contact": { "contact": {
"name": "Contact" "name": "Contact",
"description": "Menvoyer 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 navez rien reçu dici 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 ladresse courriel invalide.",
"message": "Format du message invalide. Doit faire entre 10 et 5000 caractères.",
"other": "Données de la requête malformées."
}
}
} }
} }
} }

View File

@@ -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
}
}
}); });

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -5,3 +5,4 @@ onlyBuiltDependencies:
- sharp - sharp
- unrs-resolver - unrs-resolver
- vue-demi - vue-demi
- workerd

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/leon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

1
public/site.webmanifest Normal file
View 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
View 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
View 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
View 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"