Compare commits

..

18 Commits

Author SHA1 Message Date
6dcf03a38a feat(fonts): add more glyphs to nds7
All checks were successful
Build and Push Docker Image / build (push) Successful in 2m34s
2026-02-27 01:04:23 +01:00
c03c87c1f6 fix(i18n): add newlines in french achievements 2026-02-27 01:01:26 +01:00
d11aae080a chore: format 2026-02-27 00:55:25 +01:00
54958437c4 feat(projects): add translations 2026-02-27 00:54:22 +01:00
712cb087d2 feat(fonts): nds39 ttf -> woff2 2026-02-26 23:26:26 +01:00
edaf11d2bf feat(fonts): add more glyphs 2026-02-26 23:19:01 +01:00
7224dd29d7 fix(settings/options/language): key not found because language wasn't loaded yet 2026-02-26 20:39:16 +01:00
3dfed22a28 feat(settings): update notifications texts to match reality 2026-02-26 20:38:47 +01:00
35833a3fb4 feat(i18n): remove other languages for now, i'll translate everything later 2026-02-26 20:18:27 +01:00
4141580ac1 fix(nds): use fillTextHCentered instead of fillTextCentered (caused alignment issues in different langs) 2026-02-26 20:01:09 +01:00
ec75f4777b feat(i18n): remove title in contact and i18nize the page titles 2026-02-26 19:55:24 +01:00
05d34811d3 feat(i18): detect browser language and add French 2026-02-26 19:44:43 +01:00
4a7a1028b7 fix(i18n): typos 2026-02-26 15:11:42 +01:00
eba1b8d84c feat(nds): tweak animations durations 2026-02-26 14:58:46 +01:00
3216d6e79e chore(gallery): remove a photo 2026-02-26 13:21:54 +01:00
7e416c2b02 fix(gallery): sometimes, in 2d mode, scale wasn't animated in the transition 2026-02-26 12:27:54 +01:00
8d18e1ddbb feat(gallery): tweak padding on smaller screens 2026-02-26 12:17:04 +01:00
1827659d3d fix(3d-nds): lag modal was stealing the keys 2026-02-26 12:05:13 +01:00
45 changed files with 588 additions and 195 deletions

View File

@@ -4,8 +4,7 @@ const { locale } = useI18n();
const app = useAppStore();
const pageTitle = computed(() => {
if (!app.booted) return "Pihkaal";
const name = app.screen.charAt(0).toUpperCase() + app.screen.slice(1);
return `${name} - Pihkaal`;
return `${$t(`screens.${app.screen}`)} - Pihkaal`;
});
useHead({

View File

@@ -26,7 +26,7 @@
@font-face {
font-family: "NDS39";
src: url("/assets/fonts/nds-39px.ttf") format("truetype");
src: url("/assets/fonts/nds-39px.woff2") format("woff2");
font-weight: normal;
font-style: normal;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -42,7 +42,11 @@ onRender((ctx) => {
ctx.textBaseline = "top";
ctx.fillStyle = "#ffffff";
const lines = confirmationModal.text.split("\n").slice(0, MAX_LINES);
const rawText =
typeof confirmationModal.text === "function"
? confirmationModal.text()
: confirmationModal.text;
const lines = rawText.split("\n").slice(0, MAX_LINES);
const totalTextHeight = lines.length * LINE_HEIGHT;
const textStartY = BG_Y + Math.floor((BG_HEIGHT - totalTextHeight) / 2) - 2;

View File

@@ -70,15 +70,33 @@ onRender((ctx) => {
ctx.fillStyle = "#aaaaaa";
ctx.font = "7px NDS7";
fillTextHCentered(ctx, $t(`creditsScreen.${id}.label`), 0, y, LOGICAL_WIDTH);
fillTextHCentered(
ctx,
$t(`creditsScreen.${id}.label`),
0,
y,
LOGICAL_WIDTH,
);
ctx.fillStyle = "#ffffff";
ctx.font = "10px NDS10";
fillTextHCentered(ctx, $t(`creditsScreen.${id}.author`), 0, y + CREDITS_LINE_HEIGHT, LOGICAL_WIDTH);
fillTextHCentered(
ctx,
$t(`creditsScreen.${id}.author`),
0,
y + CREDITS_LINE_HEIGHT,
LOGICAL_WIDTH,
);
ctx.fillStyle = "#888888";
ctx.font = "7px NDS7";
fillTextHCentered(ctx, $t(`creditsScreen.${id}.url`), 0, y + CREDITS_LINE_HEIGHT * 2, LOGICAL_WIDTH);
fillTextHCentered(
ctx,
$t(`creditsScreen.${id}.url`),
0,
y + CREDITS_LINE_HEIGHT * 2,
LOGICAL_WIDTH,
);
}
ctx.globalAlpha = store.isIntro

View File

@@ -49,15 +49,33 @@ onRender((ctx) => {
ctx.fillStyle = "#aaaaaa";
ctx.font = "7px NDS7";
fillTextHCentered(ctx, $t(`creditsScreen.${id}.label`), 0, y, LOGICAL_WIDTH);
fillTextHCentered(
ctx,
$t(`creditsScreen.${id}.label`),
0,
y,
LOGICAL_WIDTH,
);
ctx.fillStyle = "#ffffff";
ctx.font = "10px NDS10";
fillTextHCentered(ctx, $t(`creditsScreen.${id}.author`), 0, y + CREDITS_LINE_HEIGHT, LOGICAL_WIDTH);
fillTextHCentered(
ctx,
$t(`creditsScreen.${id}.author`),
0,
y + CREDITS_LINE_HEIGHT,
LOGICAL_WIDTH,
);
ctx.fillStyle = "#888888";
ctx.font = "7px NDS7";
fillTextHCentered(ctx, $t(`creditsScreen.${id}.url`), 0, y + CREDITS_LINE_HEIGHT * 2, LOGICAL_WIDTH);
fillTextHCentered(
ctx,
$t(`creditsScreen.${id}.url`),
0,
y + CREDITS_LINE_HEIGHT * 2,
LOGICAL_WIDTH,
);
}
});

View File

@@ -4,6 +4,7 @@ const store = useGalleryStore();
const { assets } = useAssets();
onMounted(() => {
store.cleanup();
if (store.shouldAnimateOutro) {
store.shouldAnimateOutro = false;
store.animateOutro();

View File

@@ -87,11 +87,11 @@ onRender((ctx) => {
// gallery
ctx.font = "7px NDS7";
ctx.fillStyle = pressed.value === "gallery" ? "#2c2c2c" : "#282828";
fillTextCentered(
fillTextHCentered(
ctx,
$t("home.photoGallery"),
132,
78 + getButtonOffset("gallery"),
85 + getButtonOffset("gallery"),
87,
);
@@ -127,12 +127,12 @@ onRender((ctx) => {
140,
);
}
ctx.textBaseline = "alphabetic";
ctx.textBaseline = "top";
// gba thing
ctx.font = "10px NDS10";
ctx.fillStyle = "#a2a2a2";
fillTextCentered(ctx, $t("home.greeting"), 79, 135, 140);
fillTextHCentered(ctx, $t("home.greeting"), 79, 137, 140);
});
</script>

View File

@@ -15,14 +15,28 @@ const switch2d = () => {
</script>
<template>
<UModal :open="true" :dismissible="false" :title="$t('lagModal.title')" :ui="{ footer: 'justify-end' }">
<UModal
:open="true"
:dismissible="false"
:title="$t('lagModal.title')"
:ui="{ footer: 'justify-end' }"
>
<template #body>
{{ $t('lagModal.body') }}
{{ $t("lagModal.body") }}
</template>
<template #footer>
<UButton variant="ghost" color="neutral" :label="$t('lagModal.keep3d')" @click="keep3d" />
<UButton color="neutral" :label="$t('lagModal.switch2d')" @click="switch2d" />
<UButton
variant="ghost"
color="neutral"
:label="$t('lagModal.keep3d')"
@click="keep3d"
/>
<UButton
color="neutral"
:label="$t('lagModal.switch2d')"
@click="switch2d"
/>
</template>
</UModal>
</template>

View File

@@ -74,8 +74,6 @@ const TOP_SCREEN_OFFSET = 170;
const zoomStyle = computed(() => {
const scale = ndsScale.value * gallery.zoom.scale;
if (scale === 1) return { scale: ndsScale.value };
const y = TOP_SCREEN_OFFSET * ndsScale.value * (gallery.zoom.scale - 1);
return { transform: `translateY(${y}px) scale(${scale})` };
});

View File

@@ -19,7 +19,7 @@ let timeline: gsap.core.Timeline | null = null;
const text = computed(() => {
const project = store.projects[store.currentProject]!;
return $t("projects.linkConformationPopup.text", {
return $t("projects.linkConfirmationPopup.text", {
url: project.link.replace(/^https?:\/\//, ""),
});
});
@@ -155,13 +155,13 @@ onRender((ctx) => {
drawTextWithShadow(
ctx,
$t("projects.linkConformationPopup.yes").toUpperCase(),
$t("projects.linkConfirmationPopup.yes").toUpperCase(),
207,
YES_Y - 3,
);
drawTextWithShadow(
ctx,
$t("projects.linkConformationPopup.no").toUpperCase(),
$t("projects.linkConfirmationPopup.no").toUpperCase(),
207,
NO_Y - 3,
);

View File

@@ -119,7 +119,7 @@ onRender((ctx) => {
ctx,
"black",
project.summary,
Math.floor(185 - textWidth / 2),
Math.floor(181 - textWidth / 2),
17,
);

View File

@@ -193,7 +193,9 @@ onMounted(() => {
canvas.value.addEventListener("click", handleCanvasClick);
canvas.value.addEventListener("mousedown", handleCanvasMouseDown);
canvas.value.addEventListener("wheel", handleCanvasWheel, { passive: true });
canvas.value.addEventListener("touchstart", handleTouchStart, { passive: false });
canvas.value.addEventListener("touchstart", handleTouchStart, {
passive: false,
});
canvas.value.addEventListener("touchend", handleTouchEnd, { passive: true });
canvas.value.addEventListener("mousedown", handleSwipeMouseDown);
canvas.value.addEventListener("mouseup", handleSwipeMouseUp);

View File

@@ -4,7 +4,6 @@ import gsap from "gsap";
const { locales, locale, setLocale } = useI18n();
const store = useSettingsStore();
const confirmationModal = useConfirmationModal();
const achievements = useAchievementsStore();
const { assets } = useAssets();
const { onRender } = useScreen();
@@ -160,25 +159,24 @@ const handleActivateB = async () => {
store.closeSubMenu();
};
const AVAILABLE_LOCALES = ["en", "fr"];
const handleActivateA = () => {
if (isAnimating.value) return;
const selectedLocale = locales.value[BUTTON_KEYS.indexOf(selected.value)]!;
setLocale(selectedLocale.code);
if (!achievements.advancement.languages.includes(selectedLocale.code)) {
achievements.advancement.languages.push(selectedLocale.code);
if (achievements.advancement.languages.length === locales.value.length) {
achievements.unlock("settings_language_try_all");
}
if (!AVAILABLE_LOCALES.includes(selectedLocale.code)) {
confirmationModal.open({
text: $t("settings.options.language.unavailable"),
timeout: 2000,
});
return;
}
setLocale(selectedLocale.code);
confirmationModal.open({
text: $t(
"settings.options.language.confirmation",
{},
{ locale: selectedLocale.code },
),
text: () => $t("settings.options.language.confirmation"),
onClosed: async () => {
await animateOutro();
store.closeSubMenu(true);

View File

@@ -156,16 +156,28 @@ useKeyDown(({ key }) => {
switch (key) {
case "NDS_UP":
if (selectedRow > 0) { assets.audio.tinyClick.play(0.8); select(selectedCol, selectedRow - 1); }
if (selectedRow > 0) {
assets.audio.tinyClick.play(0.8);
select(selectedCol, selectedRow - 1);
}
break;
case "NDS_RIGHT":
if (selectedCol < GRID_SIZE - 1) { assets.audio.tinyClick.play(0.8); select(selectedCol + 1, selectedRow); }
if (selectedCol < GRID_SIZE - 1) {
assets.audio.tinyClick.play(0.8);
select(selectedCol + 1, selectedRow);
}
break;
case "NDS_DOWN":
if (selectedRow < GRID_SIZE - 1) { assets.audio.tinyClick.play(0.8); select(selectedCol, selectedRow + 1); }
if (selectedRow < GRID_SIZE - 1) {
assets.audio.tinyClick.play(0.8);
select(selectedCol, selectedRow + 1);
}
break;
case "NDS_LEFT":
if (selectedCol > 0) { assets.audio.tinyClick.play(0.8); select(selectedCol - 1, selectedRow); }
if (selectedCol > 0) {
assets.audio.tinyClick.play(0.8);
select(selectedCol - 1, selectedRow);
}
break;
}
});

View File

@@ -201,13 +201,11 @@ onRender((ctx) => {
ctx.font = "10px NDS10";
ctx.fillStyle = "#000000";
ctx.letterSpacing = "0px";
fillTextCentered(
fillTextHCentered(
ctx,
props.title,
props.x + 1,
// TODO: -10 is needed because fillTextCentered isn't using top baseline
// i will change that in the future (maybe)
Y + ARROW_IMAGE_HEIGHT * 2 + SQUARE_HEIGHT - 6,
Y + ARROW_IMAGE_HEIGHT * 2 + SQUARE_HEIGHT + 3,
downImage.value.rect.width,
);
}, 10);

View File

@@ -10,10 +10,14 @@ export const useKeyDown = (callback: KeyDownCallback) => {
const app = useAppStore();
const handleKeyDown = (event: KeyboardEvent) => {
if (app.lagDetected) return;
if (app.lagModalOpen) return;
const ndsButton = mapCodeToNDS(event.code);
if (ndsButton && document.activeElement && document.activeElement !== document.body) {
if (
ndsButton &&
document.activeElement &&
document.activeElement !== document.body
) {
(document.activeElement as HTMLElement).blur();
}

View File

@@ -4,8 +4,9 @@ import type { InternalApi } from "nitropack/types";
import { promiseTimeout, useElementSize } from "@vueuse/core";
import gsap from "gsap";
const { t } = useI18n();
useHead({
title: "Gallery - Pihkaal",
title: computed(() => `${t("screens.gallery")} - Pihkaal`),
});
const { data: images } = await useAsyncData(
@@ -178,7 +179,7 @@ onMounted(() => {
gap: 24,
estimateSize: 510,
}"
class="p-6 lg:p-8 xl:p-10 2xl:p-12 h-screen bg-[#0a0a0a] text-white font-[JetBrains_Mono] focus:outline-none"
class="p-4 md:p-6 lg:p-8 xl:p-10 2xl:p-12 h-screen bg-[#0a0a0a] text-white font-[JetBrains_Mono] focus:outline-none"
:class="{ 'no-scroll': isAnimating }"
tabindex="0"
>

View File

@@ -12,8 +12,12 @@ const lagModal = overlay.create(LazyLagModal);
watch(
() => app.lagDetected,
(detected) => {
if (detected) lagModal.open();
async (detected) => {
if (detected) {
app.lagModalOpen = true;
await lagModal.open();
app.lagModalOpen = false;
}
},
);
@@ -28,18 +32,18 @@ const isTouchDevice = () =>
const windowSize = useWindowSize();
const isLargeEnough = computed(
() => windowSize.width.value / windowSize.height.value > 614 / 667,
watch(
[windowSize.width, windowSize.height],
([width, height]) => {
if (width / height > 614 / 667) {
if (app.booted) app.allowHints();
} else {
app.disallowHints();
}
},
{ immediate: true },
);
watch([windowSize.width, windowSize.height], ([width, height]) => {
if (width / height > 614 / 667) {
if (app.booted) app.allowHints();
} else {
app.disallowHints();
}
}, { immediate: true });
const helpButton = useTemplateRef("helpButton");
let helpAnimation: gsap.core.Timeline | null = null;

View File

@@ -23,7 +23,6 @@ export const ACHIEVEMENTS = [
{ id: "taptap_score_20", secret: false },
// secrets
{ id: "settings_color_try_all", secret: true },
{ id: "settings_language_try_all", secret: true },
{ id: "settings_visit_all", secret: true },
{ id: "contact_36_notifications", secret: true },
] as const;
@@ -32,7 +31,6 @@ export type Achievement = (typeof ACHIEVEMENTS)[number]["id"];
export const useAchievementsStore = defineStore("achievements", () => {
const app = useAppStore();
const { locale } = useI18n();
const storage = useLocalStorage(
STORAGE_ID,
@@ -40,7 +38,6 @@ export const useAchievementsStore = defineStore("achievements", () => {
unlocked: [] as Achievement[],
advancement: {
colors: [app.color.hex],
languages: [locale.value],
visitedSettings: [] as string[],
},
},
@@ -50,9 +47,6 @@ export const useAchievementsStore = defineStore("achievements", () => {
if (!storage.value.advancement.colors.includes(app.color.hex)) {
storage.value.advancement.colors.push(app.color.hex);
}
if (!storage.value.advancement.languages.includes(locale.value)) {
storage.value.advancement.languages.push(locale.value);
}
const confetti = useConfetti();
@@ -80,7 +74,6 @@ export const useAchievementsStore = defineStore("achievements", () => {
unlocked: [],
advancement: {
colors: [app.color.hex],
languages: [locale.value],
visitedSettings: [],
},
};

View File

@@ -57,6 +57,7 @@ export const useAppStore = defineStore("app", {
hintsVisible: false,
hintsAllowed: false,
lagDetected: false,
lagModalOpen: false,
};
},

View File

@@ -8,7 +8,7 @@ const MODAL_TIME = 0.25;
export const useConfirmationModal = defineStore("confirmationModal", {
state: () => ({
isOpen: false,
text: "",
text: "" as string | (() => string),
aLabel: null as string | null,
bLabel: null as string | null,
onActivateA: null as (() => void) | null,
@@ -24,7 +24,7 @@ export const useConfirmationModal = defineStore("confirmationModal", {
actions: {
open(options: {
text: string;
text: string | (() => string);
aLabel?: string;
bLabel?: string;
onActivateA?: () => void;

View File

@@ -43,7 +43,7 @@ export const useContactStore = defineStore("contact", {
duration: 0.1,
ease: "none",
},
2,
1,
)
.fromTo(
this.intro,
@@ -53,7 +53,7 @@ export const useContactStore = defineStore("contact", {
duration: 0.1,
ease: "none",
},
2.15,
1.15,
)
.fromTo(
this.intro,
@@ -67,7 +67,7 @@ export const useContactStore = defineStore("contact", {
duration: 0.1,
ease: "none",
},
2.3,
1.3,
)
.call(() => {
this.isIntro = false;
@@ -104,7 +104,7 @@ export const useContactStore = defineStore("contact", {
this.isOutro = false;
const app = useAppStore();
app.navigateTo("home");
}, 2000);
}, 1000);
},
});

View File

@@ -11,12 +11,14 @@ const ANIMATION = {
NDS_CAMERA_ROTATION: new THREE.Euler(THREE.MathUtils.degToRad(-55), 0, 0),
GALLERY_CAMERA_POSITION: new THREE.Vector3(0, 4.5, -3),
GALLERY_CAMERA_ROTATION: new THREE.Euler(THREE.MathUtils.degToRad(-62), 0, 0),
CAMERA_DURATION: 3,
CAMERA_DURATION_INTRO: 2.115,
CAMERA_DURATION_OUTRO: 2.037,
CAMERA_ROTATION_OVERLAP: 0.1,
// 2D zoom
ZOOM_SCALE: 6,
ZOOM_DURATION: 3,
ZOOM_DURATION_INTRO: 2.115,
ZOOM_DURATION_OUTRO: 2.037,
ZOOM_EASE: "power2.inOut",
};
@@ -40,6 +42,12 @@ export const useGalleryStore = defineStore("gallery", {
}),
actions: {
cleanup() {
gsap.killTweensOf(this.zoom);
gsap.killTweensOf(this.intro);
gsap.killTweensOf(this.outro);
},
animateIntro() {
this.isIntro = true;
this.isOutro = false;
@@ -73,7 +81,7 @@ export const useGalleryStore = defineStore("gallery", {
x: ANIMATION.GALLERY_CAMERA_POSITION.x,
y: ANIMATION.GALLERY_CAMERA_POSITION.y,
z: ANIMATION.GALLERY_CAMERA_POSITION.z,
duration: ANIMATION.CAMERA_DURATION,
duration: ANIMATION.CAMERA_DURATION_INTRO,
delay: zoomDelay,
ease: ANIMATION.ZOOM_EASE,
},
@@ -91,7 +99,7 @@ export const useGalleryStore = defineStore("gallery", {
y: ANIMATION.GALLERY_CAMERA_ROTATION.y,
z: ANIMATION.GALLERY_CAMERA_ROTATION.z,
duration:
ANIMATION.CAMERA_DURATION *
ANIMATION.CAMERA_DURATION_INTRO *
(1 - ANIMATION.CAMERA_ROTATION_OVERLAP),
delay: zoomDelay,
ease: ANIMATION.ZOOM_EASE,
@@ -103,7 +111,7 @@ export const useGalleryStore = defineStore("gallery", {
{ scale: 1 },
{
scale: ANIMATION.ZOOM_SCALE,
duration: ANIMATION.ZOOM_DURATION,
duration: ANIMATION.ZOOM_DURATION_INTRO,
delay: zoomDelay,
ease: ANIMATION.ZOOM_EASE,
},
@@ -118,7 +126,7 @@ export const useGalleryStore = defineStore("gallery", {
setTimeout(() => {
const { assets } = useAssets();
assets.audio.whooshSmall.play(0.6);
}, 500);
}, 100);
},
animateOutro() {
@@ -129,7 +137,7 @@ export const useGalleryStore = defineStore("gallery", {
// Outro: Camera/zoom starts first (at 0), fade starts after with overlap
const fadeDelay =
ANIMATION.CAMERA_DURATION - ANIMATION.FADE_CAMERA_OVERLAP;
ANIMATION.CAMERA_DURATION_OUTRO - ANIMATION.FADE_CAMERA_OVERLAP;
if (app.camera) {
gsap.fromTo(
@@ -143,7 +151,7 @@ export const useGalleryStore = defineStore("gallery", {
x: ANIMATION.NDS_CAMERA_POSITION.x,
y: ANIMATION.NDS_CAMERA_POSITION.y,
z: ANIMATION.NDS_CAMERA_POSITION.z,
duration: ANIMATION.CAMERA_DURATION,
duration: ANIMATION.CAMERA_DURATION_OUTRO,
delay: 0,
ease: ANIMATION.ZOOM_EASE,
},
@@ -161,7 +169,7 @@ export const useGalleryStore = defineStore("gallery", {
y: ANIMATION.NDS_CAMERA_ROTATION.y,
z: ANIMATION.NDS_CAMERA_ROTATION.z,
duration:
ANIMATION.CAMERA_DURATION *
ANIMATION.CAMERA_DURATION_OUTRO *
(1 - ANIMATION.CAMERA_ROTATION_OVERLAP),
delay: 0,
ease: ANIMATION.ZOOM_EASE,
@@ -173,7 +181,7 @@ export const useGalleryStore = defineStore("gallery", {
{ scale: ANIMATION.ZOOM_SCALE },
{
scale: 1,
duration: ANIMATION.ZOOM_DURATION,
duration: ANIMATION.ZOOM_DURATION_OUTRO,
delay: 0,
ease: ANIMATION.ZOOM_EASE,
},

View File

@@ -67,7 +67,7 @@ export const useHomeStore = defineStore("home", {
{ stage1Opacity: 0 },
{
stage1Opacity: 1,
duration: 0.5,
duration: 0.35,
ease: "none",
},
0,
@@ -80,7 +80,7 @@ export const useHomeStore = defineStore("home", {
{ topScreenOpacity: 0 },
{
topScreenOpacity: 1,
duration: 0.5,
duration: 0.35,
ease: "none",
},
0,
@@ -90,10 +90,10 @@ export const useHomeStore = defineStore("home", {
{ statusBarY: -20 },
{
statusBarY: 0,
duration: 0.15,
duration: 0.1,
ease: "none",
},
0.35,
0.15,
);
} else {
this.intro.topScreenOpacity = 1;
@@ -139,7 +139,7 @@ export const useHomeStore = defineStore("home", {
{ stage2Opacity: 1 },
{
stage2Opacity: 0,
duration: 0.16,
duration: 0.12,
ease: "none",
},
0,
@@ -153,10 +153,10 @@ export const useHomeStore = defineStore("home", {
{
buttonOffsetY: -200,
stage1Opacity: 0,
duration: 0.4,
duration: 0.3,
ease: "none",
},
0.08,
0.06,
);
},
},

View File

@@ -36,7 +36,10 @@ export const useIntroStore = defineStore("intro", {
() => {
const now = new Date();
const isBirthday = now.getMonth() === 3 && now.getDate() === 25;
(isBirthday ? assets.audio.birthdayStartup : assets.audio.startUp).play();
(isBirthday
? assets.audio.birthdayStartup
: assets.audio.startUp
).play();
},
undefined,
delay,
@@ -83,7 +86,7 @@ export const useIntroStore = defineStore("intro", {
.to(this.outro, {
backgroundOpacity: 1,
duration: 0.5,
delay: 0.5,
delay: 0.2,
ease: "none",
})
.call(() => {

View File

@@ -4,12 +4,21 @@ import type {
} from "@nuxt/content";
import gsap from "gsap";
type ProjectItem = Omit<
ProjectsCollectionItem,
keyof DataCollectionItemBase
> & {
id: string;
};
export type Project = Omit<ProjectItem, "en" | "fr"> & {
description: string;
summary: string;
tasks: string[];
};
export const useProjectsStore = defineStore("projects", {
state: () => ({
projects: [] as (Omit<
ProjectsCollectionItem,
keyof DataCollectionItemBase
> & { id: string })[],
projects: [] as Project[],
currentProject: 0,
loading: true,
offsetX: 0,
@@ -29,30 +38,34 @@ export const useProjectsStore = defineStore("projects", {
actions: {
async loadProjects() {
const { locale } = useI18n();
this.loading = true;
const projects = await queryCollection("projects")
const items = await queryCollection("projects")
.order("order", "ASC")
.select(
"id",
"order",
"scope",
"title",
"link",
"description",
"summary",
"technologies",
"tasks",
)
.all();
if (!projects) throw "Cannot load projects";
this.projects = projects.map((project) => ({
...project,
id: project.id
.split("/")[2]!
.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()),
}));
if (!items) throw "Cannot load projects";
this.projects = items.map((item) => {
const slug = item.id.split("/")[2]!;
const localeData = item[locale.value as "en"] ?? item.en;
if (!localeData) {
console.warn(`Missing '${locale.value}' for ${slug}`);
}
const { en: _en, fr: _fr, ...meta } = item;
return {
...meta,
id: slug.replace(/-([a-z])/g, (_: string, letter: string) =>
letter.toUpperCase(),
),
description: localeData.description ?? "",
summary: localeData.summary ?? "",
tasks: localeData.tasks ?? [],
};
});
this.loading = false;
},

View File

@@ -185,21 +185,21 @@ export const useSettingsStore = defineStore("settings", {
.fromTo(
this.menuOffsets,
{ 1: -48 },
{ 1: 0, duration: 0.1, ease: "none" },
{ 1: 0, duration: 0.143, ease: "none" },
0.2,
)
.call(() => assets.audio.settingsMenuIntro.play(), undefined, 0.2)
.fromTo(
this.menuOffsets,
{ 2: -48 },
{ 2: 0, duration: 0.1, ease: "none" },
0.3,
{ 2: 0, duration: 0.143, ease: "none" },
0.343,
)
.fromTo(
this.menuOffsets,
{ 3: -48 },
{ 3: 0, duration: 0.1, ease: "none" },
0.4,
{ 3: 0, duration: 0.143, ease: "none" },
0.486,
)
.call(() => {
this.isIntro = false;
@@ -232,27 +232,27 @@ export const useSettingsStore = defineStore("settings", {
.fromTo(
this.menuOffsets,
{ 3: 0 },
{ 3: -48, duration: 0.1, ease: "none" },
{ 3: -48, duration: 0.143, ease: "none" },
0,
)
.fromTo(
this.menuOffsets,
{ 2: 0 },
{ 2: -48, duration: 0.1, ease: "none" },
0.1,
{ 2: -48, duration: 0.143, ease: "none" },
0.143,
)
.fromTo(
this.menuOffsets,
{ 1: 0 },
{ 1: -48, duration: 0.1, ease: "none" },
0.2,
{ 1: -48, duration: 0.143, ease: "none" },
0.286,
)
// menus slide down
.fromTo(
this,
{ menuYOffset: 0 },
{ menuYOffset: 72, duration: 0.2, ease: "none" },
0.3,
0.429,
)
.call(() => {
const app = useAppStore();

View File

@@ -1,6 +1,12 @@
import { defineContentConfig, defineCollection } from "@nuxt/content";
import { z } from "zod";
const localeSchema = z.object({
description: z.string(),
summary: z.string(),
tasks: z.array(z.string()),
});
export default defineContentConfig({
collections: {
projects: defineCollection({
@@ -11,10 +17,9 @@ export default defineContentConfig({
scope: z.enum(["hobby", "work"]),
title: z.string(),
link: z.url(),
description: z.string(),
summary: z.string(),
technologies: z.array(z.string()),
tasks: z.array(z.string()),
en: localeSchema,
fr: localeSchema,
}),
}),
},

View File

@@ -3,12 +3,21 @@ scope: work
title: Biobleud
link: https://biobleud.fr/
description: Agri-food company
summary: Temporary assignments
technologies:
- VBA
tasks:
- Developing Excel macros\nin VBA for ERP system\nimplementation
- Understanding client\nneeds
- Documentation
en:
description: Agri-food company
summary: Temporary assignments
tasks:
- "Developing Excel macros\\nin VBA for ERP system\\nimplementation"
- "Understanding client\\nneeds"
- "Documentation"
fr:
description: Entreprise agroalimentaire
summary: Missions temporaires
tasks:
- "Développement de macros\\nExcel en VBA pour\\nun ERP"
- "Compréhension des\\nbesoins du client"
- "Documentation"

View File

@@ -3,13 +3,22 @@ scope: hobby
title: LBF Bot
link: https://git.pihkaal.me/lbf-bot
description: For a gaming group
summary: Custom Discord bot
technologies:
- Node
- TypeScript
tasks:
- Made for a gaming group
- Deployed on VPS
- Understanding client\nneeds
en:
description: For a gaming group
summary: Custom Discord bot
tasks:
- "Made for a gaming group"
- "Deployed on VPS"
- "Understanding client\\nneeds"
fr:
description: Pour un groupe de gaming
summary: Bot Discord personnalisé
tasks:
- "Créé pour un groupe\\nautour d'un jeu"
- "Déployé sur VPS"
- "Compréhension des\\nbesoins du client"

View File

@@ -3,13 +3,20 @@ scope: hobby
title: lilou.cat
link: https://lilou.cat
description: Lilou <3
summary: Lilou's website
technologies:
- HTML
- Go
tasks:
- Originally made for fun\nto celebrate my cat Lilou
- Now preserved in her\nmemory
en:
description: Lilou <3
summary: Lilou's website
tasks:
- "Originally made for fun\\nto celebrate my cat Lilou"
- "Now preserved in her\\nmemory"
fr:
description: Lilou <3
summary: Le site de Lilou
tasks:
- "Créé pour célébrer\\nmon chat Lilou"
- "Désormais préservé\\nen sa mémoire"

View File

@@ -3,13 +3,20 @@ scope: hobby
title: pihkaal.me
link: https://pihkaal.me
description: Portfolio and contact
summary: My personnal website
technologies:
- Nuxt
- TypeScript
tasks:
- The website you are\ncurrently on!
- Recreation of the Nintendo\nDS because it was my first\never console
en:
description: Portfolio and contact
summary: My personal website
tasks:
- "The website you are\\ncurrently on!"
- "Recreation of the Nintendo\\nDS because it was my first\\never console"
fr:
description: Portfolio et contact
summary: Mon site personnel
tasks:
- "Le site sur lequel\\nvous êtes !"
- "Recréation de la Nintendo\\nDS car c'était ma première\\nconsole"

View File

@@ -3,8 +3,6 @@ scope: hobby
title: Raylib Spdrns
link: https://git.pihkaal.me/raylib-speedruns
description: Awesome video game library
summary: Raylib Speedruns
technologies:
- C
- C#
@@ -13,6 +11,16 @@ technologies:
- Rust
- Asm x86_64
tasks:
- Simple Raylib setups in\nmultiple languages
- Inspired by Tsoding
en:
description: Awesome video game library
summary: Raylib Speedruns
tasks:
- "Simple Raylib setups in\\nmultiple languages"
- "Inspired by Tsoding"
fr:
description: Super bibliothèque
summary: Raylib Speedruns
tasks:
- "Exemples d'utilisation de\\nRaylib en plusieurs\\nlangages"
- "Inspiré par Tsoding"

View File

@@ -3,15 +3,25 @@ scope: work
title: S3PWeb
link: https://s3pweb.com
description: The Transport Data Aggregator
summary: Apprenticeship
technologies:
- Node
- StencilJS
- TypeScript
tasks:
- Automatized incidents\naggregation to Jira
- Web based map editor
- Chrome extension to\nvisualize Eramba assets
- Documentation
en:
description: The Transport Data Aggregator
summary: Apprenticeship
tasks:
- "Automatized incidents\\naggregation to Jira"
- "Web based map editor"
- "Chrome extension to\\nvisualize Eramba assets"
- "Documentation"
fr:
description: L'agrégateur des dataa transport
summary: Alternance
tasks:
- "Agrégation automatisée\\nd'incidents vers Jira"
- "Éditeur de carte web"
- "Extension Chrome pour\\nles assets Eramba"
- "Documentation"

View File

@@ -3,13 +3,22 @@ scope: hobby
title: Simple QR
link: https://simple-qr.com
description: Concise website and API
summary: QR code generator
technologies:
- Nuxt
- TypeScript
tasks:
- Easy to use
- Large choice of logos
- Straightforward API
en:
description: Concise website and API
summary: QR code generator
tasks:
- "Easy to use"
- "Large choice of logos"
- "Straightforward API"
fr:
description: Site web et API concis
summary: Générateur de QR code
tasks:
- "Facile à utiliser"
- "Grand choix de logos"
- "API simple d'utilisation"

View File

@@ -3,14 +3,23 @@ scope: hobby
title: tlock
link: https://git.pihkaal.me/tlock
description: For Hyprland ricing
summary: Terminal based clock
technologies:
- Rust
tasks:
- Fully customizable
- Animated
- Cross-platform
- |
Multiple modes: clock,\nchronometer and timer
en:
description: For Hyprland ricing
summary: Terminal based clock
tasks:
- "Fully customizable"
- "Animated"
- "Cross-platform"
- "Multiple modes: clock,\\nchronometer and timer"
fr:
description: Pour le ricing Hyprland
summary: Horloge pour le terminal
tasks:
- "Entièrement\\npersonnalisable"
- "Animée"
- "Multi-plateforme"
- "Plusieurs modes : horloge,\\nchronomètre et minuteur"

View File

@@ -1,4 +1,13 @@
{
"screens": {
"home": "Home",
"contact": "Contact",
"projects": "Projects",
"settings": "Settings",
"gallery": "Gallery",
"achievements": "Achievements",
"credits": "Credits"
},
"lagModal": {
"title": "Performance issues detected",
"body": "Your device seems to be struggling with 3D rendering. Switch to 2D mode for a smoother experience.",
@@ -56,7 +65,6 @@
"2048_score_512": "Reach the 512 tile\nin 2048",
"taptap_score_20": "Score 20 points\nin TapTap",
"settings_color_try_all": "Try all colors",
"settings_language_try_all": "Try all languages",
"settings_visit_all": "Visit all settings\nsubmenus",
"contact_36_notifications": "Trigger 36\nnotifications"
},
@@ -75,7 +83,7 @@
"description": "Change system settings here. Select\nthe settings you'd like to change.",
"options": {
"title": "Options",
"description": "Change other settings.",
"description": "Change rendering mode,\nlanguage, and play 2048.",
"renderingMode": {
"title": "Rendering",
"description": "Change the app rendering mode\nbetween 2D and 3D.",
@@ -89,7 +97,8 @@
"language": {
"title": "Language",
"description": "Select the language to use.",
"confirmation": "Language set to English."
"confirmation": "Language set to English.",
"unavailable": "This language is not\navailable yet."
},
"2048": {
"title": "2048",
@@ -129,7 +138,7 @@
},
"user": {
"title": "User",
"description": "Enter user informations.",
"description": "Change color, view my birthday\nand name, and play Snake.",
"color": {
"title": "Color",
"description": "Select your favorite color.",
@@ -180,7 +189,7 @@
}
},
"contact": {
"title": "Choose a Chat Room to join.",
"title": "",
"actions": {
"open": "Open",
"copy": "Copy",
@@ -199,7 +208,7 @@
"backToHome": "Back to Home"
},
"projects": {
"linkConformationPopup": {
"linkConfirmationPopup": {
"yes": "yes",
"no": "no",
"text": "Open {url}?"

View File

@@ -1 +1,221 @@
{}
{
"screens": {
"home": "Accueil",
"contact": "Contact",
"projects": "Projets",
"settings": "Paramètres",
"gallery": "Galerie",
"achievements": "Succès",
"credits": "Crédits"
},
"lagModal": {
"title": "Problèmes de performances détectés",
"body": "Votre appareil semble avoir du mal avec le rendu 3D. Passez en mode 2D pour une expérience plus fluide.",
"keep3d": "Garder la 3D",
"switch2d": "Passer en 2D"
},
"common": {
"cancel": "Annuler",
"confirm": "Confirmer",
"quit": "Quitter",
"start": "Démarrer",
"restart": "Relancer",
"reset": "Réinitialiser",
"select": "Sélectionner",
"goBack": "Retour",
"yes": "Oui",
"no": "Non"
},
"achievementsScreen": {
"title": "Succès"
},
"creditsScreen": {
"title": "Crédits",
"model3d": {
"label": "Modèle 3D par",
"author": "Cianon",
"url": "skfb.ly/6ZDvQ - CC BY 4.0"
},
"css2d": {
"label": "CSS 2D par",
"author": "A. Radevich",
"url": "codepen.io/aradevich/pen/mdRYzyJ"
},
"nintendo": {
"label": "UI & effets sonores par",
"author": "Nintendo",
"url": "nintendo.com - © Nintendo"
},
"defectds": {
"label": "Sons système NDS extraits par",
"author": "defectDS",
"url": "adiumxtras.com"
}
},
"achievements": {
"boot": "Démarrer le système",
"projects_visit": "Visiter la section\nProjets",
"projects_view_5": "Voir 5 projets",
"projects_open_link": "Ouvrir le lien\nd'un projet",
"gallery_visit": "Visiter la galerie\nphoto",
"contact_visit": "Visiter la section\nContact",
"contact_git_visit": "Visiter mon profil Git",
"settings_color_change": "Changer la couleur\ndu système",
"snake_score_25": "Marquer 25 points\nà Snake",
"2048_score_512": "Atteindre la tuile 512\nà 2048",
"taptap_score_20": "Marquer 20 points\nà TapTap",
"settings_color_try_all": "Essayer toutes les\ncouleurs",
"settings_visit_all": "Visiter tous les\nparamètres",
"contact_36_notifications": "Déclencher 36\nnotifications"
},
"intro": {
"copyright": "AVERTISSEMENT - COPYRIGHT",
"text": "CECI EST UNE RECRÉATION NON COMMERCIALE\nFAITE PAR UN FAN. NON AFFILIÉ NI\nAPPROUVÉ PAR NINTENDO.\nNINTENDO DS EST UNE MARQUE DÉPOSÉE\nDE NINTENDO CO., LTD.",
"hint": "Touchez l'écran tactile pour continuer."
},
"home": {
"projectsAndExperiences": "Projets et\nExpériences",
"greeting": "Bienvenue sur mon site !",
"photoGallery": "Galerie Photo"
},
"settings": {
"title": "Paramètres",
"description": "Changer les paramètres. Choisissez\nle paramètre à modifier.",
"options": {
"title": "Options",
"description": "Changer le mode de rendu,\nla langue, et jouer au 2048.",
"renderingMode": {
"title": "Rendu",
"description": "Changer le mode de rendu\nentre 2D et 3D.",
"3dMode": "Mode 3D",
"2dMode": "Mode 2D",
"3dDescription": "Expérience 3D complète avec\nle modèle interactif.\nIdéal pour les appareils puissants.",
"2dDescription": "Expérience légère et épurée.\nPlus rapide et moins demandante.\nRecommandé si le mode 3D rame.",
"confirmation3d": "Mode de rendu réglé sur 3D",
"confirmation2d": "Mode de rendu réglé sur 2D"
},
"language": {
"title": "Langue",
"description": "Sélectionner la langue que\nvous voulez utiliser.",
"confirmation": "La langue est réglée sur Français.",
"unavailable": "Cette langue n'est pas\nencore disponible."
},
"2048": {
"title": "2048",
"description": "Glissez ou utilisez les flèches\npour fusionner les tuiles.\nAtteignez 2048 !",
"quitConfirmation": "Quitter la partie ?\nVotre score est sauvegardé.",
"restartConfirmation": "Recommencer la partie ?",
"gameOver": "Perdu !\nRecommencer ?",
"score": "Score",
"highScore": "Meilleur"
}
},
"clock": {
"title": "Horloge",
"description": "Changer la date, l'heure et les\nparamètres de succès.",
"achievements": {
"title": "Succès",
"description": "Gérer vos succès.",
"resetButton": "Réinitialiser les succès",
"resetConfirmation": "Réinitialiser tous les succès ?",
"viewAll": "Tout voir",
"obtained": "Obtenus",
"total": "Total"
},
"date": {
"title": "Date",
"description": "Date d'aujourd'hui.",
"month": "Mois",
"day": "Jour",
"year": "Année"
},
"time": {
"title": "Heure",
"description": "Heure actuelle.",
"hour": "Heure",
"minute": "Minute"
}
},
"user": {
"title": "Utilisateur",
"description": "Changer la couleur, voir mon nom et\nmon anniversaire, et jouer à Snake.",
"color": {
"title": "Couleur",
"description": "Sélectionnez votre\ncouleur favorite.",
"confirmation": "La couleur a été sauvegardée."
},
"birthday": {
"title": "Anniversaire",
"description": "Ma date d'anniversaire.",
"month": "Mois",
"day": "Jour",
"year": "Année",
"confirmation": {
"today": "Oui, c'est aujourd'hui !",
"future": "N'oubliez pas de me le\nsouhaiter dans {days} jours !"
}
},
"userName": {
"title": "Nom d'utilisateur",
"description": "Mon pseudo et mon prénom.",
"userName": "Nom d'utilisateur",
"firstName": "Prénom"
},
"snake": {
"title": "Snake",
"description": "Glissez ou utilisez les flèches\npour vous déplacer.\nNe vous mordez pas la queue !",
"score": "Score : {score}",
"best": "Meilleur : {best}",
"startPrompt": "\n\n\n Appuyez sur icon_a\n pour démarrer",
"quitConfirmation": "Quitter la partie ?",
"restartConfirmation": "Recommencer la partie ?"
}
},
"touchScreen": {
"title": "Écran Tactile",
"description": "Calibrage et jeux de l'écran tactile.",
"tapTap": {
"title": "TapTap",
"description": "Tapez les cercles avant qu'ils\nne disparaissent !",
"startPrompt": "Appuyez sur icon_a pour démarrer.",
"score": "Score : {score}",
"best": "Meilleur : {best}",
"gameOver": "Partie terminée !",
"finalScore": "Score final : {score}",
"newRecord": "Nouveau record !",
"quitConfirmation": "Quitter la partie ?",
"restartConfirmation": "Recommencer la partie ?"
}
}
},
"contact": {
"title": "",
"actions": {
"open": "Ouvrir",
"copy": "Copier",
"git": "Page Git",
"email": "E-mail",
"linkedin": "Lien LinkedIn",
"cv": "CV"
},
"copiedToClipboard": "{item} copié dans le presse-papiers",
"openUrl": "Ouvrir {url} ?",
"opened": "{item} ouvert"
},
"gallery": {
"title": "Galerie de Pihkaal",
"description": "J'ai débuté en mars 2025. J'adore photographier les plantes, les insectes et les arachnides.",
"backToHome": "Retour à l'accueil"
},
"projects": {
"linkConfirmationPopup": {
"yes": "oui",
"no": "non",
"text": "Ouvrir {url} ?"
}
},
"loadingScreen": {
"loading": "Chargement...",
"clickToStart": "Cliquez pour démarrer"
}
}

View File

@@ -37,15 +37,14 @@ export default defineNuxtConfig({
{ property: "og:image:type", content: "image/png" },
{ property: "og:image:width", content: "1200" },
{ property: "og:image:height", content: "630" },
{ property: "og:image:alt", content: "3D Nintendo DS home screen from pihkaal.me" },
{
property: "og:image:alt",
content: "3D Nintendo DS home screen from pihkaal.me",
},
{ property: "og:url", content: URL },
{ property: "og:site_name", content: TITLE },
{ property: "og:locale", content: "en-US" },
{ property: "og:locale:alternate", content: "de-DE" },
{ property: "og:locale:alternate", content: "fr-FR" },
{ property: "og:locale:alternate", content: "es-ES" },
{ property: "og:locale:alternate", content: "it-IT" },
{ property: "og:locale:alternate", content: "ja-JP" },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:title", content: TITLE },
{ name: "twitter:description", content: DESCRIPTION },
@@ -99,8 +98,11 @@ export default defineNuxtConfig({
{ code: "ja", language: "ja-JP", name: "日本語", file: "ja.json" },
],
defaultLocale: "en",
// TODO: put back to true
detectBrowserLanguage: false,
detectBrowserLanguage: {
useCookie: true,
cookieKey: "i18n_redirected",
redirectOn: "root",
},
},
image: {
quality: 80,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 MiB