feat(projects): confirmation popup before opening link

This commit is contained in:
2025-12-18 16:19:46 +01:00
parent 9f1bdfceea
commit 4bfc5f5858
8 changed files with 198 additions and 13 deletions

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import Background from "./Background.vue"; import Background from "./Background.vue";
import Buttons from "./Buttons.vue"; import Buttons from "./Buttons.vue";
import LinkConfirmationPopup from "./LinkConfirmationPopup.vue";
const store = useProjectsStore(); const store = useProjectsStore();
@@ -23,5 +24,6 @@ watch(
<template v-if="!store.loading"> <template v-if="!store.loading">
<Background /> <Background />
<Buttons /> <Buttons />
<LinkConfirmationPopup v-if="store.showConfirmationPopup" />
</template> </template>
</template> </template>

View File

@@ -47,6 +47,10 @@ const circleContains = (
radius: number, radius: number,
): boolean => Math.sqrt(Math.pow(x - cx, 2) + Math.pow(y - cy, 2)) < radius; ): boolean => Math.sqrt(Math.pow(x - cx, 2) + Math.pow(y - cy, 2)) < radius;
const SMALL_CIRCLE_DURATION = 0.07;
const BIG_CIRCLE_DURATION = 0.09;
const POST_DURATION = 0.07;
const startButtonAnimation = (type: ButtonType) => { const startButtonAnimation = (type: ButtonType) => {
const anim: ButtonAnimation = { const anim: ButtonAnimation = {
type, type,
@@ -57,10 +61,6 @@ const startButtonAnimation = (type: ButtonType) => {
}; };
currentAnimation = anim; currentAnimation = anim;
const SMALL_CIRCLE_DURATION = 0.07;
const BIG_CIRCLE_DURATION = 0.09;
const POST_DURATION = 0.07;
gsap gsap
.timeline() .timeline()
.to(anim, { duration: SMALL_CIRCLE_DURATION }) .to(anim, { duration: SMALL_CIRCLE_DURATION })
@@ -79,9 +79,14 @@ const startButtonAnimation = (type: ButtonType) => {
}; };
useScreenClick((x, y) => { useScreenClick((x, y) => {
if (currentAnimation || store.isIntro || store.isOutro) return; if (
currentAnimation ||
store.isIntro ||
store.isOutro ||
store.showConfirmationPopup
)
return;
const project = store.projects[store.currentProject];
if (circleContains(BUTTONS.prev.position, [x, y], CLICK_RADIUS)) { if (circleContains(BUTTONS.prev.position, [x, y], CLICK_RADIUS)) {
startButtonAnimation("prev"); startButtonAnimation("prev");
store.scrollProjects("left"); store.scrollProjects("left");
@@ -91,13 +96,12 @@ useScreenClick((x, y) => {
} else if (circleContains(BUTTONS.quit.position, [x, y], CLICK_RADIUS)) { } else if (circleContains(BUTTONS.quit.position, [x, y], CLICK_RADIUS)) {
startButtonAnimation("quit"); startButtonAnimation("quit");
store.animateOutro(); store.animateOutro();
} else if ( } else if (circleContains(BUTTONS.link.position, [x, y], CLICK_RADIUS)) {
circleContains(BUTTONS.link.position, [x, y], CLICK_RADIUS) &&
project?.link
) {
startButtonAnimation("link"); startButtonAnimation("link");
// TODO: show confirmation popup before opening the link, like "you are about to navigate to [...]" setTimeout(
// store.visitProject(); () => (store.showConfirmationPopup = true),
1000 * (SMALL_CIRCLE_DURATION + BIG_CIRCLE_DURATION + POST_DURATION),
);
} }
}); });
@@ -131,7 +135,13 @@ useRender((ctx) => {
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT); ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
}); });
useKeyDown((key) => { useKeyDown((key) => {
if (currentAnimation || store.isIntro || store.isOutro) return; if (
currentAnimation ||
store.isIntro ||
store.isOutro ||
store.showConfirmationPopup
)
return;
switch (key) { switch (key) {
case "NDS_LEFT": case "NDS_LEFT":
startButtonAnimation("prev"); startButtonAnimation("prev");
@@ -141,6 +151,14 @@ useKeyDown((key) => {
startButtonAnimation("next"); startButtonAnimation("next");
store.scrollProjects("right"); store.scrollProjects("right");
break; break;
case "NDS_A":
case "NDS_START":
startButtonAnimation("link");
setTimeout(
() => (store.showConfirmationPopup = true),
1000 * (SMALL_CIRCLE_DURATION + BIG_CIRCLE_DURATION + POST_DURATION),
);
break;
} }
}); });

View File

@@ -0,0 +1,157 @@
<script setup lang="ts">
import gsap from "gsap";
const { assets } = useAssets();
const store = useProjectsStore();
const TEXT_SLOW = 0.05;
const TEXT_FAST = 0.01;
const TEXT_Y = 151;
const YES_Y = 107;
const NO_Y = 123;
let selectedOption: "yes" | "no" = "yes";
const textProgress = ref(0);
let waitingForNdsARelease = false;
let timeline: gsap.core.Timeline | null = null;
const text = computed(() => {
const project = store.projects[store.currentProject]!;
return $t("projects.linkConformationPopup.text", {
url: project.link.replace(/^https?:\/\//, ""),
});
});
const animateText = (speed: number) => {
timeline?.kill();
const remaining = (1 - textProgress.value) * text.value.length;
timeline = gsap.timeline().to(textProgress, {
value: 1,
duration: remaining * speed,
ease: "none",
});
};
onMounted(() => {
animateText(TEXT_SLOW);
});
onUnmounted(() => timeline?.kill());
useKeyDown((key) => {
if (!store.showConfirmationPopup) return;
if (textProgress.value < 1 && key === "NDS_A") {
waitingForNdsARelease = true;
animateText(TEXT_FAST);
return;
}
if (textProgress.value < 1 || waitingForNdsARelease) return;
switch (key) {
case "NDS_UP":
selectedOption = "yes";
break;
case "NDS_DOWN":
selectedOption = "no";
break;
case "NDS_A":
case "NDS_START":
setTimeout(() => {
if (selectedOption === "yes") store.visitProject();
store.showConfirmationPopup = false;
}, 5);
break;
case "NDS_B":
setTimeout(() => {
store.showConfirmationPopup = false;
}, 5);
break;
}
});
useKeyUp((key) => {
if (store.showConfirmationPopup && key === "NDS_A") {
waitingForNdsARelease = false;
}
});
useScreenClick((x, y) => {
if (
!store.showConfirmationPopup ||
textProgress.value < 1 ||
waitingForNdsARelease
)
return;
const ACTIVATION_DELAY = 50;
if (rectContains([198, 105, 50, 14], [x, y])) {
selectedOption = "yes";
setTimeout(() => {
store.visitProject();
store.showConfirmationPopup = false;
}, ACTIVATION_DELAY);
} else if (rectContains([198, 121, 50, 14], [x, y])) {
selectedOption = "no";
setTimeout(() => (store.showConfirmationPopup = false), ACTIVATION_DELAY);
}
});
const drawTextWithShadow = (
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
) => {
ctx.fillStyle = "#a2a2aa";
ctx.fillText(text, x + 1, y);
ctx.fillText(text, x + 1, y + 1);
ctx.fillText(text, x, y + 1);
ctx.fillStyle = "#515159";
ctx.fillText(text, x, y);
};
useRender((ctx) => {
if (!store.showConfirmationPopup) return;
// frame
ctx.strokeStyle = "#0000007f";
ctx.lineWidth = 1;
ctx.strokeRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// text
ctx.drawImage(assets.projects.bottomScreen.popupTextBackground, 2, 146);
ctx.font = "16px Pokemon DP Pro";
ctx.textBaseline = "top";
const displayedText = text.value.slice(
0,
Math.floor(textProgress.value * text.value.length),
);
drawTextWithShadow(ctx, displayedText, 15, TEXT_Y);
// choice box
if (textProgress.value >= 1) {
ctx.drawImage(assets.projects.bottomScreen.popupChoiceBackground, 194, 98);
const y = selectedOption === "yes" ? YES_Y : NO_Y;
ctx.drawImage(assets.projects.bottomScreen.popupSelector, 200, y);
drawTextWithShadow(
ctx,
$t("projects.linkConformationPopup.yes").toUpperCase(),
207,
YES_Y - 3,
);
drawTextWithShadow(
ctx,
$t("projects.linkConformationPopup.no").toUpperCase(),
207,
NO_Y - 3,
);
}
});
defineOptions({ render: () => null });
</script>

View File

@@ -24,6 +24,7 @@ export const useProjectsStore = defineStore("projects", {
isIntro: true, isIntro: true,
isOutro: false, isOutro: false,
showConfirmationPopup: false,
}), }),
actions: { actions: {

View File

@@ -32,5 +32,12 @@
"title": "Touch Screen", "title": "Touch Screen",
"description": "TODO" "description": "TODO"
} }
},
"projects": {
"linkConformationPopup": {
"yes": "yes",
"no": "no",
"text": "Open {url}?"
}
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 B