Compare commits

...

10 Commits

Author SHA1 Message Date
f6d576dcfc feat: add license
Some checks failed
Build and Push Docker Image / build (push) Failing after 1m53s
2026-02-13 12:16:57 +01:00
9837b5ccdc feat: docker build and gitea workflow 2026-02-13 12:16:33 +01:00
0c34825482 feat(gallery): add images 2026-02-13 12:02:20 +01:00
d99410472e feat(nds): dispatch mousedown and mouseup even on touch start and end 2026-02-13 00:35:16 +01:00
515c630a60 fix(common): fig click detection 2026-02-13 00:22:34 +01:00
f6ca995643 feat(nds): fix missing @activate-a event handlers 2026-02-13 00:08:23 +01:00
a5424993b3 feat(nds): 3d touch support 2026-02-12 22:31:13 +01:00
161b22259a fix(nds): wrong 3d click detection 2026-02-12 18:24:13 +01:00
a751c1a150 fix(settings): synchronize button animation with arrows of number input 2026-02-12 15:31:37 +01:00
b4b38af4ab fix(utils): wrong useButtonNavigation last handling 2026-02-12 01:07:33 +01:00
69 changed files with 568 additions and 254 deletions

View File

@@ -0,0 +1,30 @@
name: Build and Push Docker Image
on:
push:
branches:
- beta
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.pihkaal.me
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push Docker image
run: |
docker build -t git.pihkaal.me/pihkaal/pihkaal-me:latest .
docker push git.pihkaal.me/pihkaal/pihkaal-me:latest

View File

@@ -1,34 +0,0 @@
name: CI
on:
push:
branches: [main, 3d-nds]
pull_request:
branches: [main, 3d-nds]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: pnpm setup
uses: pnpm/action-setup@v4
with:
version: 10
- name: node setup
uses: actions/setup-node@v4
with:
node-version: 25
cache: "pnpm"
- name: install
run: pnpm install --frozen-lockfile
- name: lint
run: pnpm lint
- name: build
run: pnpm build

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM node:22-alpine AS base
RUN corepack enable
FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
FROM base AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
FROM base AS runtime
WORKDIR /app
COPY --from=build /app/.output ./.output
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

21
LICENSE.md Normal file
View File

@@ -0,0 +1,21 @@
Copyright (c) 2025 Pihkaal <hello@pihkaal.me>
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,75 +1 @@
# Nuxt Minimal Starter # beta.pihkaal.me
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

View File

@@ -102,21 +102,26 @@ onRender((ctx) => {
); );
}, 60); }, 60);
let lastPressedButton: "A" | "B" | null = null;
onClick((x, y) => { onClick((x, y) => {
if (props.yOffset !== 0) return; if (props.yOffset !== 0) return;
if (rectContains(B_BUTTON, [x, y])) { if (rectContains(B_BUTTON, [x, y]) && lastPressedButton === "B") {
emit("activateB"); emit("activateB");
} else if (rectContains(A_BUTTON, [x, y])) { } else if (rectContains(A_BUTTON, [x, y]) && lastPressedButton === "A") {
emit("activateA"); emit("activateA");
} }
lastPressedButton = null;
}); });
onMouseDown((x, y) => { onMouseDown((x, y) => {
if (props.yOffset !== 0) return; if (props.yOffset !== 0) return;
if (rectContains(B_BUTTON, [x, y])) { if (rectContains(B_BUTTON, [x, y])) {
bButtonPressed = true; bButtonPressed = true;
lastPressedButton = "B";
} else if (rectContains(A_BUTTON, [x, y])) { } else if (rectContains(A_BUTTON, [x, y])) {
aButtonPressed = true; aButtonPressed = true;
lastPressedButton = "A";
} }
}); });

View File

@@ -68,7 +68,15 @@ const { selected, pressed, selectorPosition } = useButtonNavigation({
}, },
}, },
initialButton: "git", initialButton: "git",
onActivate: async (button) => { onActivate: (button) => handleActivateA(button),
disabled: computed(() => store.isIntro || store.isOutro),
selectorAnimation: {
duration: 0.075,
ease: "none",
},
});
const handleActivateA = async (button: typeof selected.value) => {
const { action, url } = BUTTONS[button]; const { action, url } = BUTTONS[button];
const verb = $t(`contact.actions.${button}`); const verb = $t(`contact.actions.${button}`);
if (action === "copy") { if (action === "copy") {
@@ -94,13 +102,7 @@ const { selected, pressed, selectorPosition } = useButtonNavigation({
}, },
}); });
} }
}, };
disabled: computed(() => store.isIntro || store.isOutro),
selectorAnimation: {
duration: 0.075,
ease: "none",
},
});
const handleActivateB = () => { const handleActivateB = () => {
if (store.isIntro || store.isOutro) return; if (store.isIntro || store.isOutro) return;
@@ -157,6 +159,7 @@ onRender((ctx) => {
" "
:b-label="$t('common.quit')" :b-label="$t('common.quit')"
:a-label="$t(`contact.actions.${BUTTONS[selected].action}`)" :a-label="$t(`contact.actions.${BUTTONS[selected].action}`)"
@activate-a="handleActivateA(selected)"
@activate-b="handleActivateB" @activate-b="handleActivateB"
/> />
</template> </template>

View File

@@ -274,51 +274,103 @@ const pressButton = (button: string) => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: `NDS_${button}` })); window.dispatchEvent(new KeyboardEvent("keydown", { key: `NDS_${button}` }));
}; };
const handleClick = (event: MouseEvent) => { const raycast = (clientX: number, clientY: number) => {
if (!hasAnimated.value) {
animateIntro();
return;
}
const domElement = renderer.instance.domElement; const domElement = renderer.instance.domElement;
const rect = domElement.getBoundingClientRect(); const rect = domElement.getBoundingClientRect();
const raycaster = new THREE.Raycaster(); const raycaster = new THREE.Raycaster();
raycaster.setFromCamera( raycaster.setFromCamera(
new THREE.Vector2( new THREE.Vector2(
((event.clientX - rect.left) / rect.width) * 2 - 1, ((clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1, -((clientY - rect.top) / rect.height) * 2 + 1,
), ),
camera.activeCamera.value, camera.activeCamera.value,
); );
const intersects = raycaster.intersectObjects(model.children, true); const intersects = raycaster.intersectObjects(model.children, true);
const intersection = intersects[0]; return intersects[0];
if (!intersection?.uv) return; };
switch (intersection.object.name) { const getScreenCanvas = (name: string) => {
case TOP_SCREEN: if (name === TOP_SCREEN) return props.topScreenCanvas;
case BOTTOM_SCREEN: { if (name === BOTTOM_SCREEN) return props.bottomScreenCanvas;
const canvas = return null;
intersection.object.name === TOP_SCREEN };
? props.topScreenCanvas
: props.bottomScreenCanvas;
if (!canvas) break; const toScreenCoords = (
canvas: HTMLCanvasElement,
intersection: THREE.Intersection,
) => {
if (!intersection.uv) return null;
const logicalX = (1 - intersection.uv.x) * LOGICAL_WIDTH; const logicalX = (1 - intersection.uv.x) * LOGICAL_WIDTH;
const logicalY = (1 - intersection.uv.y) * LOGICAL_HEIGHT; const logicalY = (1 - intersection.uv.y) * LOGICAL_HEIGHT;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
canvas.dispatchEvent( return {
new MouseEvent("click", {
bubbles: true,
cancelable: true,
clientX: (logicalX / LOGICAL_WIDTH) * rect.width + rect.left, clientX: (logicalX / LOGICAL_WIDTH) * rect.width + rect.left,
clientY: (logicalY / LOGICAL_HEIGHT) * rect.height + rect.top, clientY: (logicalY / LOGICAL_HEIGHT) * rect.height + rect.top,
};
};
const dispatchScreenEvent = (
type: string,
canvas: HTMLCanvasElement,
intersection: THREE.Intersection,
) => {
const coords = toScreenCoords(canvas, intersection);
if (!coords) return;
canvas.dispatchEvent(
new MouseEvent(type, { bubbles: true, cancelable: true, ...coords }),
);
};
const dispatchScreenTouchEvent = (
type: string,
canvas: HTMLCanvasElement,
intersection: THREE.Intersection,
) => {
const coords = toScreenCoords(canvas, intersection);
if (!coords) return;
const touch = new Touch({ identifier: 0, target: canvas, ...coords });
canvas.dispatchEvent(
new TouchEvent(type, {
bubbles: true,
cancelable: true,
touches: type === "touchend" ? [] : [touch],
changedTouches: [touch],
}), }),
); );
};
const BUTTON_MAP = {
[X_BUTTON]: "X",
[A_BUTTON]: "A",
[Y_BUTTON]: "Y",
[B_BUTTON]: "B",
[SELECT_BUTTON]: "SELECT",
[START_BUTTON]: "START",
} as const;
const handleInteraction = (
clientX: number,
clientY: number,
onScreen: (
canvas: HTMLCanvasElement,
intersection: THREE.Intersection,
) => void,
) => {
const intersection = raycast(clientX, clientY);
if (!intersection?.uv) return;
switch (intersection.object.name) {
case TOP_SCREEN:
case BOTTOM_SCREEN: {
const canvas = getScreenCanvas(intersection.object.name);
if (canvas) onScreen(canvas, intersection);
break; break;
} }
@@ -339,37 +391,84 @@ const handleClick = (event: MouseEvent) => {
break; break;
} }
case X_BUTTON: default: {
case A_BUTTON: const button =
case Y_BUTTON: BUTTON_MAP[intersection.object.name as keyof typeof BUTTON_MAP];
case B_BUTTON:
case SELECT_BUTTON:
case START_BUTTON: {
const BUTTON_MAP = {
[X_BUTTON]: "X",
[A_BUTTON]: "A",
[Y_BUTTON]: "Y",
[B_BUTTON]: "B",
[SELECT_BUTTON]: "SELECT",
[START_BUTTON]: "START",
} as const;
const button = BUTTON_MAP[intersection.object.name];
if (button) pressButton(button); if (button) pressButton(button);
break; break;
} }
} }
}; };
const handleMouseUp = () => { const releaseButton = () => {
if (mousePressedButton) { if (!mousePressedButton) return;
physicalButtonsDown.delete(mousePressedButton);
physicalButtonsDown.delete(mousePressedButton);
window.dispatchEvent( window.dispatchEvent(
new KeyboardEvent("keyup", { key: `NDS_${mousePressedButton}` }), new KeyboardEvent("keyup", { key: `NDS_${mousePressedButton}` }),
); );
mousePressedButton = null; mousePressedButton = null;
};
const handleMouseDown = (event: MouseEvent) => {
if (!hasAnimated.value) {
animateIntro();
return;
}
handleInteraction(event.clientX, event.clientY, (canvas, intersection) => {
dispatchScreenEvent("mousedown", canvas, intersection);
});
};
const handleClick = (event: MouseEvent) => {
if (!hasAnimated.value) return;
const intersection = raycast(event.clientX, event.clientY);
if (!intersection?.uv) return;
const canvas = getScreenCanvas(intersection.object.name);
if (canvas) dispatchScreenEvent("click", canvas, intersection);
};
const handleMouseUp = (event: MouseEvent) => {
releaseButton();
const intersection = raycast(event.clientX, event.clientY);
if (!intersection?.uv) return;
const canvas = getScreenCanvas(intersection.object.name);
if (canvas) dispatchScreenEvent("mouseup", canvas, intersection);
};
const handleTouchStart = (event: TouchEvent) => {
const touch = event.touches[0];
if (!touch) return;
if (!hasAnimated.value) {
animateIntro();
return;
}
handleInteraction(touch.clientX, touch.clientY, (canvas, intersection) => {
dispatchScreenEvent("mousedown", canvas, intersection);
dispatchScreenTouchEvent("touchstart", canvas, intersection);
});
};
const handleTouchEnd = (event: TouchEvent) => {
releaseButton();
const touch = event.changedTouches[0];
if (!touch) return;
const intersection = raycast(touch.clientX, touch.clientY);
if (!intersection?.uv) return;
const canvas = getScreenCanvas(intersection.object.name);
if (canvas) {
dispatchScreenEvent("click", canvas, intersection);
dispatchScreenTouchEvent("touchend", canvas, intersection);
} }
}; };
@@ -377,15 +476,33 @@ onMounted(() => {
app.ready = true; app.ready = true;
if (renderer) { if (renderer) {
renderer.instance.domElement.addEventListener("mousedown", handleClick); renderer.instance.domElement.addEventListener("mousedown", handleMouseDown);
renderer.instance.domElement.addEventListener("click", handleClick);
renderer.instance.domElement.addEventListener("mouseup", handleMouseUp); renderer.instance.domElement.addEventListener("mouseup", handleMouseUp);
renderer.instance.domElement.addEventListener(
"touchstart",
handleTouchStart,
);
renderer.instance.domElement.addEventListener("touchend", handleTouchEnd);
} }
}); });
onUnmounted(() => { onUnmounted(() => {
if (renderer) { if (renderer) {
renderer.instance.domElement.removeEventListener("mousedown", handleClick); renderer.instance.domElement.removeEventListener(
"mousedown",
handleMouseDown,
);
renderer.instance.domElement.removeEventListener("click", handleClick);
renderer.instance.domElement.removeEventListener("mouseup", handleMouseUp); renderer.instance.domElement.removeEventListener("mouseup", handleMouseUp);
renderer.instance.domElement.removeEventListener(
"touchstart",
handleTouchStart,
);
renderer.instance.domElement.removeEventListener(
"touchend",
handleTouchEnd,
);
} }
topScreenTexture?.dispose(); topScreenTexture?.dispose();
bottomScreenTexture?.dispose(); bottomScreenTexture?.dispose();

View File

@@ -96,14 +96,29 @@ const dispatchSwipe = (endX: number, endY: number) => {
const handleTouchStart = (event: TouchEvent) => { const handleTouchStart = (event: TouchEvent) => {
const touch = event.touches[0]; const touch = event.touches[0];
if (!touch) return; if (!touch) return;
swipeStartX = touch.clientX; swipeStartX = touch.clientX;
swipeStartY = touch.clientY; swipeStartY = touch.clientY;
canvas.value?.dispatchEvent(
new MouseEvent("mousedown", {
clientX: touch.clientX,
clientY: touch.clientY,
}),
);
}; };
const handleTouchEnd = (event: TouchEvent) => { const handleTouchEnd = (event: TouchEvent) => {
const touch = event.changedTouches[0]; const touch = event.changedTouches[0];
if (!touch) return; if (!touch) return;
dispatchSwipe(touch.clientX, touch.clientY); dispatchSwipe(touch.clientX, touch.clientY);
document.dispatchEvent(
new MouseEvent("mouseup", {
clientX: touch.clientX,
clientY: touch.clientY,
}),
);
}; };
let mouseSwiping = false; let mouseSwiping = false;
@@ -178,7 +193,7 @@ onMounted(() => {
}); });
canvas.value.addEventListener("touchend", handleTouchEnd, { passive: true }); canvas.value.addEventListener("touchend", handleTouchEnd, { passive: true });
canvas.value.addEventListener("mousedown", handleSwipeMouseDown); canvas.value.addEventListener("mousedown", handleSwipeMouseDown);
document.addEventListener("mouseup", handleSwipeMouseUp); canvas.value.addEventListener("mouseup", handleSwipeMouseUp);
animationFrameId = requestAnimationFrame(renderFrame); animationFrameId = requestAnimationFrame(renderFrame);
}); });
@@ -195,8 +210,8 @@ onUnmounted(() => {
canvas.value.removeEventListener("touchstart", handleTouchStart); canvas.value.removeEventListener("touchstart", handleTouchStart);
canvas.value.removeEventListener("touchend", handleTouchEnd); canvas.value.removeEventListener("touchend", handleTouchEnd);
canvas.value.removeEventListener("mousedown", handleSwipeMouseDown); canvas.value.removeEventListener("mousedown", handleSwipeMouseDown);
canvas.value.removeEventListener("mouseup", handleSwipeMouseUp);
} }
document.removeEventListener("mouseup", handleSwipeMouseUp);
}); });
defineExpose({ defineExpose({

View File

@@ -35,6 +35,9 @@ const confirmationModal = useConfirmationModal();
const isAnimating = ref(true); const isAnimating = ref(true);
const bLabel = ref($t("common.goBack"));
const aLabel = ref($t("common.select"));
const SLIDE_OFFSET = 96; const SLIDE_OFFSET = 96;
const SLIDE_DURATION = 0.25; const SLIDE_DURATION = 0.25;
const ARROW_SLIDE_DELAY = 0.15; const ARROW_SLIDE_DELAY = 0.15;
@@ -60,6 +63,14 @@ const animateIntro = async () => {
.timeline() .timeline()
.to(animation, { offsetY: 0, duration: SLIDE_DURATION, ease: "none" }, 0) .to(animation, { offsetY: 0, duration: SLIDE_DURATION, ease: "none" }, 0)
.to(animation, { opacity: 1, duration: SLIDE_DURATION, ease: "none" }, 0) .to(animation, { opacity: 1, duration: SLIDE_DURATION, ease: "none" }, 0)
.call(
() => {
bLabel.value = $t("common.cancel");
aLabel.value = $t("common.reset");
},
[],
SUBMENU_LABEL_CHANGE_DELAY,
)
.to( .to(
animation, animation,
{ viewAllOffsetY: 0, duration: ARROW_SLIDE_DURATION, ease: "none" }, { viewAllOffsetY: 0, duration: ARROW_SLIDE_DURATION, ease: "none" },
@@ -197,8 +208,8 @@ onRender((ctx) => {
<CommonButtons <CommonButtons
:y-offset="confirmationModal.buttonsYOffset + store.submenuButtonsOffsetY" :y-offset="confirmationModal.buttonsYOffset + store.submenuButtonsOffsetY"
:b-label="$t('common.cancel')" :b-label="bLabel"
:a-label="$t('common.reset')" :a-label="aLabel"
@activate-b="handleActivateB()" @activate-b="handleActivateB()"
@activate-a="handleReset()" @activate-a="handleReset()"
/> />

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { SettingsBottomScreenNumberInput as NumberInput } from "#components"; import { SettingsBottomScreenNumberInput as NumberInput } from "#components";
import { useIntervalFn } from "@vueuse/core"; import { useIntervalFn } from "@vueuse/core";
import gsap from "gsap";
const store = useSettingsStore(); const store = useSettingsStore();
@@ -12,6 +13,9 @@ useIntervalFn(() => {
const isAnimating = ref(true); const isAnimating = ref(true);
const bLabel = ref($t("common.goBack"));
const aLabel = ref($t("common.select"));
const monthRef = useTemplateRef<InstanceType<typeof NumberInput>>("month"); const monthRef = useTemplateRef<InstanceType<typeof NumberInput>>("month");
const dayRef = useTemplateRef<InstanceType<typeof NumberInput>>("day"); const dayRef = useTemplateRef<InstanceType<typeof NumberInput>>("day");
const yearRef = useTemplateRef<InstanceType<typeof NumberInput>>("year"); const yearRef = useTemplateRef<InstanceType<typeof NumberInput>>("year");
@@ -22,6 +26,14 @@ const animateIntro = async () => {
monthRef.value?.animateIntro(), monthRef.value?.animateIntro(),
dayRef.value?.animateIntro(), dayRef.value?.animateIntro(),
yearRef.value?.animateIntro(), yearRef.value?.animateIntro(),
gsap.timeline().call(
() => {
bLabel.value = $t("common.cancel");
aLabel.value = $t("common.confirm");
},
[],
SUBMENU_LABEL_CHANGE_DELAY,
),
]); ]);
isAnimating.value = false; isAnimating.value = false;
}; };
@@ -80,8 +92,8 @@ defineOptions({ render: () => null });
<CommonButtons <CommonButtons
:y-offset="store.submenuButtonsOffsetY" :y-offset="store.submenuButtonsOffsetY"
:b-label="$t('common.cancel')" :b-label="bLabel"
:a-label="$t('common.confirm')" :a-label="aLabel"
@activate-b="handleActivateB" @activate-b="handleActivateB"
@activate-a="handleActivateA" @activate-a="handleActivateA"
/> />

View File

@@ -23,6 +23,9 @@ const animation = reactive({
const isAnimating = ref(true); const isAnimating = ref(true);
const bLabel = ref($t("common.goBack"));
const aLabel = ref($t("common.select"));
const hourRef = useTemplateRef<InstanceType<typeof NumberInput>>("hour"); const hourRef = useTemplateRef<InstanceType<typeof NumberInput>>("hour");
const minuteRef = useTemplateRef<InstanceType<typeof NumberInput>>("minute"); const minuteRef = useTemplateRef<InstanceType<typeof NumberInput>>("minute");
@@ -34,7 +37,15 @@ const animateIntro = async () => {
gsap gsap
.timeline() .timeline()
.to(animation, { offsetY: 0, duration: SLIDE_DURATION, ease: "none" }, 0) .to(animation, { offsetY: 0, duration: SLIDE_DURATION, ease: "none" }, 0)
.to(animation, { opacity: 1, duration: SLIDE_DURATION, ease: "none" }, 0), .to(animation, { opacity: 1, duration: SLIDE_DURATION, ease: "none" }, 0)
.call(
() => {
bLabel.value = $t("common.cancel");
aLabel.value = $t("common.confirm");
},
[],
SUBMENU_LABEL_CHANGE_DELAY,
),
]); ]);
isAnimating.value = false; isAnimating.value = false;
}; };
@@ -98,8 +109,8 @@ defineOptions({ render: () => null });
<CommonButtons <CommonButtons
:y-offset="store.submenuButtonsOffsetY" :y-offset="store.submenuButtonsOffsetY"
:b-label="$t('common.cancel')" :b-label="bLabel"
:a-label="$t('common.confirm')" :a-label="aLabel"
@activate-b="handleActivateB" @activate-b="handleActivateB"
@activate-a="handleActivateA" @activate-a="handleActivateA"
/> />

View File

@@ -250,6 +250,21 @@ const selectorTransitionOffsetY = computed(() => {
return store.submenuTransition.offsetY; return store.submenuTransition.offsetY;
}); });
const handleActivateA = () => {
if (store.isIntro || store.isOutro || store.submenuTransition.opacity < 1)
return;
if (isSubMenu(selected.value)) {
store.openSubMenu(selected.value);
} else {
if (selected.value === "options") select("optionsLanguage");
if (selected.value === "clock") select("clockAchievements");
if (selected.value === "user") select("userUserName");
if (selected.value === "touchScreen")
store.openSubMenu("touchScreenTapTap");
}
};
const handleActivateB = () => { const handleActivateB = () => {
if (store.isIntro || store.isOutro || store.submenuTransition.opacity < 1) if (store.isIntro || store.isOutro || store.submenuTransition.opacity < 1)
return; return;
@@ -318,6 +333,7 @@ const handleActivateB = () => {
:y-offset="store.barOffsetY + store.submenuButtonsOffsetY" :y-offset="store.barOffsetY + store.submenuButtonsOffsetY"
:b-label="isSubmenuSelected ? $t('common.goBack') : $t('common.quit')" :b-label="isSubmenuSelected ? $t('common.goBack') : $t('common.quit')"
:a-label="$t('common.select')" :a-label="$t('common.select')"
@activate-a="handleActivateA()"
@activate-b="handleActivateB()" @activate-b="handleActivateB()"
/> />
</template> </template>

View File

@@ -77,6 +77,9 @@ const SCORE_DURATION = 0.167;
const isAnimating = ref(true); const isAnimating = ref(true);
const bLabel = ref($t("common.goBack"));
const aLabel = ref($t("common.select"));
const intro = reactive({ const intro = reactive({
frameOffsetY: SLIDE_OFFSET, frameOffsetY: SLIDE_OFFSET,
frameOpacity: 0, frameOpacity: 0,
@@ -148,7 +151,11 @@ const animateIntro = async () => {
buildTilesFromBoard(); buildTilesFromBoard();
await gsap await gsap
.timeline() .timeline({
onComplete: () => {
isAnimating.value = false;
},
})
.to(intro, { frameOffsetY: 0, duration: SLIDE_DURATION, ease: "none" }, 0) .to(intro, { frameOffsetY: 0, duration: SLIDE_DURATION, ease: "none" }, 0)
.to(intro, { frameOpacity: 1, duration: SLIDE_DURATION, ease: "none" }, 0) .to(intro, { frameOpacity: 1, duration: SLIDE_DURATION, ease: "none" }, 0)
.call( .call(
@@ -163,14 +170,25 @@ const animateIntro = async () => {
intro, intro,
{ scoreOffsetY: 0, duration: SCORE_DURATION, ease: "none" }, { scoreOffsetY: 0, duration: SCORE_DURATION, ease: "none" },
SLIDE_DURATION, SLIDE_DURATION,
)
.call(
() => {
bLabel.value = $t("common.quit");
aLabel.value = $t("common.restart");
},
[],
SUBMENU_LABEL_CHANGE_DELAY,
); );
isAnimating.value = false;
}; };
const animateOutro = async () => { const animateOutro = async () => {
isAnimating.value = true; isAnimating.value = true;
await gsap await gsap
.timeline() .timeline({
onComplete: () => {
isAnimating.value = false;
},
})
.to( .to(
intro, intro,
{ frameOffsetY: SLIDE_OFFSET, duration: SLIDE_DURATION, ease: "none" }, { frameOffsetY: SLIDE_OFFSET, duration: SLIDE_DURATION, ease: "none" },
@@ -581,8 +599,8 @@ useKeyDown(({ key }) => {
<template> <template>
<CommonButtons <CommonButtons
:y-offset="confirmationModal.buttonsYOffset + store.submenuButtonsOffsetY" :y-offset="confirmationModal.buttonsYOffset + store.submenuButtonsOffsetY"
:b-label="$t('common.quit')" :b-label="bLabel"
:a-label="$t('common.restart')" :a-label="aLabel"
@activate-b="handleActivateB()" @activate-b="handleActivateB()"
@activate-a="handleActivateA()" @activate-a="handleActivateA()"
/> />

View File

@@ -28,6 +28,9 @@ const BUTTON_POSITIONS = [
const isAnimating = ref(true); const isAnimating = ref(true);
const bLabel = ref($t("common.goBack"));
const aLabel = ref($t("common.select"));
const { selected, selectorPosition } = useButtonNavigation({ const { selected, selectorPosition } = useButtonNavigation({
buttons: { buttons: {
english: [10, 27, 106, 41], english: [10, 27, 106, 41],
@@ -95,7 +98,11 @@ const animation = reactive({
const animateIntro = async () => { const animateIntro = async () => {
isAnimating.value = true; isAnimating.value = true;
const timeline = gsap.timeline(); const timeline = gsap.timeline({
onComplete: () => {
isAnimating.value = false;
},
});
for (let i = 0; i < ROW_COUNT; i++) { for (let i = 0; i < ROW_COUNT; i++) {
timeline timeline
.to( .to(
@@ -109,13 +116,24 @@ const animateIntro = async () => {
i * ROW_STAGGER, i * ROW_STAGGER,
); );
} }
timeline.call(
() => {
bLabel.value = $t("common.cancel");
aLabel.value = $t("common.confirm");
},
[],
SUBMENU_LABEL_CHANGE_DELAY,
);
await timeline; await timeline;
isAnimating.value = false;
}; };
const animateOutro = async () => { const animateOutro = async () => {
isAnimating.value = true; isAnimating.value = true;
const timeline = gsap.timeline(); const timeline = gsap.timeline({
onComplete: () => {
isAnimating.value = false;
},
});
for (let i = 0; i < ROW_COUNT; i++) { for (let i = 0; i < ROW_COUNT; i++) {
timeline timeline
.to( .to(
@@ -205,8 +223,9 @@ defineOptions({
<template> <template>
<CommonButtons <CommonButtons
:y-offset="confirmationModal.buttonsYOffset + store.submenuButtonsOffsetY" :y-offset="confirmationModal.buttonsYOffset + store.submenuButtonsOffsetY"
:b-label="$t('common.cancel')" :b-label="bLabel"
:a-label="$t('common.confirm')" :a-label="aLabel"
@activate-a="handleActivateA"
@activate-b="handleActivateB" @activate-b="handleActivateB"
/> />

View File

@@ -18,6 +18,9 @@ const SLIDE_DURATION = 0.25;
const isAnimating = ref(true); const isAnimating = ref(true);
const bLabel = ref($t("common.goBack"));
const aLabel = ref($t("common.select"));
const animation = reactive({ const animation = reactive({
_3dMode: { headerOffsetY: HEADER_HEIGHT * 3, opacity: 0 }, _3dMode: { headerOffsetY: HEADER_HEIGHT * 3, opacity: 0 },
_2dMode: { headerOffsetY: HEADER_HEIGHT * 3, opacity: 0 }, _2dMode: { headerOffsetY: HEADER_HEIGHT * 3, opacity: 0 },
@@ -48,6 +51,14 @@ const animateIntro = async () => {
ease: "none", ease: "none",
}, },
BUTTON_STAGGER, BUTTON_STAGGER,
)
.call(
() => {
bLabel.value = $t("common.cancel");
aLabel.value = $t("common.confirm");
},
[],
SUBMENU_LABEL_CHANGE_DELAY,
); );
isAnimating.value = false; isAnimating.value = false;
}; };
@@ -183,8 +194,9 @@ onRender((ctx) => {
<template> <template>
<CommonButtons <CommonButtons
:y-offset="confirmationModal.buttonsYOffset + store.submenuButtonsOffsetY" :y-offset="confirmationModal.buttonsYOffset + store.submenuButtonsOffsetY"
:b-label="$t('common.cancel')" :b-label="bLabel"
:a-label="$t('common.confirm')" :a-label="aLabel"
@activate-a="handleActivateA"
@activate-b="handleActivateB" @activate-b="handleActivateB"
/> />

View File

@@ -68,6 +68,17 @@ let isNewBest = false;
const isAnimating = ref(true); const isAnimating = ref(true);
const labelsReady = ref(false);
const bLabel = $t("common.quit");
const aLabel = computed(() => {
if (!labelsReady.value) return $t("common.select");
return state.value === "waiting" ? $t("common.start") : $t("common.restart");
});
const buttonsRef = useTemplateRef<{ forceAnimateBLabel: () => void }>(
"buttons",
);
const AREA_FADE_DURATION = 0.2; const AREA_FADE_DURATION = 0.2;
const SCORE_OFFSET = -20; const SCORE_OFFSET = -20;
const SCORE_DURATION = 0.167; const SCORE_DURATION = 0.167;
@@ -81,7 +92,11 @@ const animation = reactive({
const animateIntro = async () => { const animateIntro = async () => {
isAnimating.value = true; isAnimating.value = true;
await gsap await gsap
.timeline() .timeline({
onComplete: () => {
isAnimating.value = false;
},
})
.to( .to(
animation, animation,
{ areaOpacity: 1, duration: AREA_FADE_DURATION, ease: "none" }, { areaOpacity: 1, duration: AREA_FADE_DURATION, ease: "none" },
@@ -91,8 +106,15 @@ const animateIntro = async () => {
animation, animation,
{ scoreOffsetY: 0, duration: SCORE_DURATION, ease: "none" }, { scoreOffsetY: 0, duration: SCORE_DURATION, ease: "none" },
AREA_FADE_DURATION, AREA_FADE_DURATION,
)
.call(
() => {
labelsReady.value = true;
buttonsRef.value?.forceAnimateBLabel();
},
[],
SUBMENU_LABEL_CHANGE_DELAY,
); );
isAnimating.value = false;
}; };
const animateOutro = async () => { const animateOutro = async () => {
@@ -101,7 +123,11 @@ const animateOutro = async () => {
targetY = LOGICAL_HEIGHT * 2 - 20; targetY = LOGICAL_HEIGHT * 2 - 20;
await gsap await gsap
.timeline() .timeline({
onComplete: () => {
isAnimating.value = false;
},
})
.to( .to(
animation, animation,
{ areaOpacity: 0, duration: AREA_FADE_DURATION, ease: "none" }, { areaOpacity: 0, duration: AREA_FADE_DURATION, ease: "none" },
@@ -418,9 +444,10 @@ defineOptions({ render: () => null });
<template> <template>
<CommonButtons <CommonButtons
ref="buttons"
:y-offset="confirmationModal.buttonsYOffset + store.submenuButtonsOffsetY" :y-offset="confirmationModal.buttonsYOffset + store.submenuButtonsOffsetY"
:b-label="$t('common.quit')" :b-label="bLabel"
:a-label="state === 'waiting' ? $t('common.start') : $t('common.restart')" :a-label="aLabel"
@activate-b="handleActivateB" @activate-b="handleActivateB"
@activate-a="handleActivateA" @activate-a="handleActivateA"
/> />

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { SettingsBottomScreenNumberInput as NumberInput } from "#components"; import { SettingsBottomScreenNumberInput as NumberInput } from "#components";
import gsap from "gsap";
const store = useSettingsStore(); const store = useSettingsStore();
const confirmationModal = useConfirmationModal(); const confirmationModal = useConfirmationModal();
@@ -10,6 +11,9 @@ const BIRTHDAY_YEAR = 2002;
const isAnimating = ref(true); const isAnimating = ref(true);
const bLabel = ref($t("common.goBack"));
const aLabel = ref($t("common.select"));
const monthRef = useTemplateRef<InstanceType<typeof NumberInput>>("month"); const monthRef = useTemplateRef<InstanceType<typeof NumberInput>>("month");
const dayRef = useTemplateRef<InstanceType<typeof NumberInput>>("day"); const dayRef = useTemplateRef<InstanceType<typeof NumberInput>>("day");
const yearRef = useTemplateRef<InstanceType<typeof NumberInput>>("year"); const yearRef = useTemplateRef<InstanceType<typeof NumberInput>>("year");
@@ -20,6 +24,14 @@ const animateIntro = async () => {
monthRef.value?.animateIntro(), monthRef.value?.animateIntro(),
dayRef.value?.animateIntro(), dayRef.value?.animateIntro(),
yearRef.value?.animateIntro(), yearRef.value?.animateIntro(),
gsap.timeline().call(
() => {
bLabel.value = $t("common.cancel");
aLabel.value = $t("common.confirm");
},
[],
SUBMENU_LABEL_CHANGE_DELAY,
),
]); ]);
isAnimating.value = false; isAnimating.value = false;
}; };
@@ -113,8 +125,8 @@ defineOptions({ render: () => null });
<CommonButtons <CommonButtons
:y-offset="confirmationModal.buttonsYOffset + store.submenuButtonsOffsetY" :y-offset="confirmationModal.buttonsYOffset + store.submenuButtonsOffsetY"
:b-label="$t('common.cancel')" :b-label="bLabel"
:a-label="$t('common.confirm')" :a-label="aLabel"
@activate-b="handleActivateB" @activate-b="handleActivateB"
@activate-a="handleActivateA" @activate-a="handleActivateA"
/> />

View File

@@ -23,6 +23,9 @@ const CELL_ANIMATION_STAGGER = 0.02;
const SNAIL_ORDER = [0, 1, 2, 3, 7, 11, 15, 14, 13, 12, 8, 4, 5, 6, 10, 9]; const SNAIL_ORDER = [0, 1, 2, 3, 7, 11, 15, 14, 13, 12, 8, 4, 5, 6, 10, 9];
const bLabel = ref($t("common.goBack"));
const aLabel = ref($t("common.select"));
// animation state // animation state
const animation = reactive({ const animation = reactive({
cellVisibility: Array(16).fill(0) as number[], cellVisibility: Array(16).fill(0) as number[],
@@ -74,6 +77,15 @@ const animateIntro = (): gsap.core.Timeline => {
); );
} }
timeline.call(
() => {
bLabel.value = $t("common.cancel");
aLabel.value = $t("common.confirm");
},
[],
SUBMENU_LABEL_CHANGE_DELAY,
);
return timeline; return timeline;
}; };
@@ -287,8 +299,8 @@ const handleActivateA = () => {
<template> <template>
<CommonButtons <CommonButtons
:y-offset="confirmationModal.buttonsYOffset + store.submenuButtonsOffsetY" :y-offset="confirmationModal.buttonsYOffset + store.submenuButtonsOffsetY"
:b-label="$t('common.cancel')" :b-label="bLabel"
:a-label="$t('common.confirm')" :a-label="aLabel"
@activate-b="handleActivateB" @activate-b="handleActivateB"
@activate-a="handleActivateA" @activate-a="handleActivateA"
/> />

View File

@@ -20,6 +20,16 @@ const SCORE_OFFSET = -20;
const SCORE_DURATION = 0.167; const SCORE_DURATION = 0.167;
const isAnimating = ref(true); const isAnimating = ref(true);
const state = ref<"waiting" | "alive" | "pause" | "dead">("waiting");
const labelsReady = ref(false);
const bLabel = computed(() =>
labelsReady.value ? $t("common.quit") : $t("common.goBack"),
);
const aLabel = computed(() => {
if (!labelsReady.value) return $t("common.select");
return state.value === "waiting" ? $t("common.start") : $t("common.restart");
});
const intro = reactive({ const intro = reactive({
boardOffsetY: BOARD_SLIDE_OFFSET, boardOffsetY: BOARD_SLIDE_OFFSET,
@@ -31,7 +41,11 @@ const intro = reactive({
const animateIntro = async () => { const animateIntro = async () => {
isAnimating.value = true; isAnimating.value = true;
await gsap await gsap
.timeline() .timeline({
onComplete: () => {
isAnimating.value = false;
},
})
.to( .to(
intro, intro,
{ boardOffsetY: 0, duration: BOARD_SLIDE_DURATION, ease: "none" }, { boardOffsetY: 0, duration: BOARD_SLIDE_DURATION, ease: "none" },
@@ -51,14 +65,24 @@ const animateIntro = async () => {
intro, intro,
{ scoreOffsetY: 0, duration: SCORE_DURATION, ease: "none" }, { scoreOffsetY: 0, duration: SCORE_DURATION, ease: "none" },
BOARD_SLIDE_DURATION + TEXT_FADE_DURATION, BOARD_SLIDE_DURATION + TEXT_FADE_DURATION,
)
.call(
() => {
labelsReady.value = true;
},
[],
SUBMENU_LABEL_CHANGE_DELAY,
); );
isAnimating.value = false;
}; };
const animateOutro = async () => { const animateOutro = async () => {
isAnimating.value = true; isAnimating.value = true;
await gsap await gsap
.timeline() .timeline({
onComplete: () => {
isAnimating.value = false;
},
})
.to( .to(
intro, intro,
{ {
@@ -160,7 +184,6 @@ const tail: THREE.Vector2[] = [];
const food = new THREE.Vector2(); const food = new THREE.Vector2();
let score = 0; let score = 0;
const state = ref<"waiting" | "alive" | "pause" | "dead">("waiting");
const randomFoodPos = (): THREE.Vector2 => { const randomFoodPos = (): THREE.Vector2 => {
// can't spawn on the head or tail // can't spawn on the head or tail
@@ -337,8 +360,8 @@ defineOptions({ render: () => null });
<template> <template>
<CommonButtons <CommonButtons
:y-offset="confirmationModal.buttonsYOffset + store.submenuButtonsOffsetY" :y-offset="confirmationModal.buttonsYOffset + store.submenuButtonsOffsetY"
:b-label="$t('common.quit')" :b-label="bLabel"
:a-label="state === 'waiting' ? $t('common.start') : $t('common.restart')" :a-label="aLabel"
@activate-b="handleActivateB" @activate-b="handleActivateB"
@activate-a="handleActivateA" @activate-a="handleActivateA"
/> />

View File

@@ -19,6 +19,9 @@ const ROW_COUNT = 2;
const isAnimating = ref(true); const isAnimating = ref(true);
const bLabel = ref($t("common.goBack"));
const aLabel = ref($t("common.select"));
const animation = reactive({ const animation = reactive({
rowOffsetY: new Array<number>(ROW_COUNT).fill(SLIDE_OFFSET), rowOffsetY: new Array<number>(ROW_COUNT).fill(SLIDE_OFFSET),
rowOpacity: new Array<number>(ROW_COUNT).fill(0), rowOpacity: new Array<number>(ROW_COUNT).fill(0),
@@ -26,7 +29,11 @@ const animation = reactive({
const animateIntro = async () => { const animateIntro = async () => {
isAnimating.value = true; isAnimating.value = true;
const timeline = gsap.timeline(); const timeline = gsap.timeline({
onComplete: () => {
isAnimating.value = false;
},
});
for (let i = 0; i < ROW_COUNT; i++) { for (let i = 0; i < ROW_COUNT; i++) {
timeline timeline
.to( .to(
@@ -40,13 +47,24 @@ const animateIntro = async () => {
i * ROW_STAGGER, i * ROW_STAGGER,
); );
} }
timeline.call(
() => {
bLabel.value = $t("common.cancel");
aLabel.value = $t("common.confirm");
},
[],
SUBMENU_LABEL_CHANGE_DELAY,
);
await timeline; await timeline;
isAnimating.value = false;
}; };
const animateOutro = async () => { const animateOutro = async () => {
isAnimating.value = true; isAnimating.value = true;
const timeline = gsap.timeline(); const timeline = gsap.timeline({
onComplete: () => {
isAnimating.value = false;
},
});
for (let i = 0; i < ROW_COUNT; i++) { for (let i = 0; i < ROW_COUNT; i++) {
timeline timeline
.to( .to(
@@ -128,8 +146,8 @@ defineOptions({ render: () => null });
<template> <template>
<CommonButtons <CommonButtons
:y-offset="store.submenuButtonsOffsetY" :y-offset="store.submenuButtonsOffsetY"
:b-label="$t('common.cancel')" :b-label="bLabel"
:a-label="$t('common.confirm')" :a-label="aLabel"
@activate-b="handleActivateB" @activate-b="handleActivateB"
@activate-a="handleActivateA" @activate-a="handleActivateA"
/> />

View File

@@ -268,9 +268,16 @@ export const useButtonNavigation = <T extends Record<string, Rect>>({
if (nextButton.value) { if (nextButton.value) {
targetButton = nextButton.value; targetButton = nextButton.value;
} else { } else {
const leftConfig = getNavigationTarget(currentNav.left); for (const [button, nav] of Object.entries(navigation) as [
const rightConfig = getNavigationTarget(currentNav.right); Entry,
targetButton = leftConfig?.target ?? rightConfig?.target; (typeof navigation)[Entry],
][]) {
const downConfig = getNavigationTarget(nav.down);
if (downConfig?.target === currentButton) {
targetButton = button;
break;
}
}
} }
} else { } else {
const targetNav = navigation[upConfig.target]; const targetNav = navigation[upConfig.target];
@@ -292,9 +299,16 @@ export const useButtonNavigation = <T extends Record<string, Rect>>({
if (nextButton.value) { if (nextButton.value) {
targetButton = nextButton.value; targetButton = nextButton.value;
} else { } else {
const leftConfig = getNavigationTarget(currentNav.left); for (const [button, nav] of Object.entries(navigation) as [
const rightConfig = getNavigationTarget(currentNav.right); Entry,
targetButton = leftConfig?.target ?? rightConfig?.target; (typeof navigation)[Entry],
][]) {
const upConfig = getNavigationTarget(nav.up);
if (upConfig?.target === currentButton) {
targetButton = button;
break;
}
}
} }
} else { } else {
const targetNav = navigation[downConfig.target]; const targetNav = navigation[downConfig.target];

View File

@@ -1,5 +1,8 @@
import gsap from "gsap"; import gsap from "gsap";
// not sure how i came up with it last night
export const SUBMENU_LABEL_CHANGE_DELAY = 0.25 + 0.15 - (0.167 + 0.08);
export const SETTINGS_MENUS = [ export const SETTINGS_MENUS = [
"options", "options",
"clock", "clock",
@@ -80,23 +83,9 @@ export const useSettingsStore = defineStore("settings", {
{ offsetY: 0, opacity: 1, duration: 0.25, ease: "none" }, { offsetY: 0, opacity: 1, duration: 0.25, ease: "none" },
"+=0.05", "+=0.05",
) )
.to(this, {
submenuButtonsOffsetY: 24,
duration: 0.167,
ease: "none",
})
.call(() => { .call(() => {
this.currentSubMenu = submenu; this.currentSubMenu = submenu;
}) });
.to(
this,
{
submenuButtonsOffsetY: 0,
duration: 0.167,
ease: "none",
},
"+=0.05",
);
const achievements = useAchievementsStore(); const achievements = useAchievementsStore();
if (!achievements.advancement.visitedSettings.includes(submenu)) { if (!achievements.advancement.visitedSettings.includes(submenu)) {

18
docker-compose.yml Normal file
View File

@@ -0,0 +1,18 @@
services:
app:
image: git.pihkaal.me/pihkaal/pihkaal-me:latest
restart: unless-stopped
networks:
- web
labels:
- "traefik.enable=true"
- "traefik.http.routers.pihkaal-me.rule=Host(`beta.pihkaal.me`)"
- "traefik.http.routers.pihkaal-me.middlewares=auth"
- "traefik.http.routers.pihkaal-me.tls=true"
- "traefik.http.routers.pihkaal-me.tls.certResolver=myresolver"
- "traefik.http.services.pihkaal-me.loadbalancer.server.port=3000"
- "traefik.http.middlewares.auth.basicauth.users=${BASIC_AUTH_USERS}"
networks:
web:
external: true

BIN
public/gallery/P1010002.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 MiB

BIN
public/gallery/P1010006.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 MiB

BIN
public/gallery/P1010011.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 MiB

BIN
public/gallery/P1010012.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 MiB

BIN
public/gallery/P1010016.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 MiB

BIN
public/gallery/P1010017.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 MiB

BIN
public/gallery/P1010031.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 MiB

BIN
public/gallery/P1010037.JPG Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 MiB

BIN
public/gallery/P1010039.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 MiB

BIN
public/gallery/P1010045.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 MiB

BIN
public/gallery/P1010047.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

BIN
public/gallery/P1010065.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 MiB

BIN
public/gallery/P1010076.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 MiB

BIN
public/gallery/P1010116.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 MiB

BIN
public/gallery/P1010156.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 MiB

BIN
public/gallery/P1010158.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

BIN
public/gallery/P1010195.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 MiB

BIN
public/gallery/P1010224.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 MiB

BIN
public/gallery/P1010248.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 MiB

BIN
public/gallery/P1010268.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 MiB

BIN
public/gallery/P1010301.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

BIN
public/gallery/P1010328.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 MiB

BIN
public/gallery/P1010336.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 MiB

BIN
public/gallery/P1010465.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 MiB

BIN
public/gallery/P1010667.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 MiB

BIN
public/gallery/P1010668.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

BIN
public/gallery/P1010670.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

BIN
public/gallery/P1010682.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

BIN
public/gallery/P1010696.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 MiB

BIN
public/gallery/P1010714.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 MiB

BIN
public/gallery/P1010724.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 MiB

BIN
public/gallery/P1010725.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 MiB

BIN
public/gallery/P1010745.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 MiB

BIN
public/gallery/P1010753.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 MiB

BIN
public/gallery/P8120190.JPG Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

BIN
public/gallery/P9100259.JPG Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

BIN
public/gallery/P9100305.JPG Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 MiB

BIN
public/gallery/P9100336.JPG Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 MiB

BIN
public/gallery/P9100341.JPG Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

BIN
public/gallery/P9100349.JPG Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 MiB

BIN
public/gallery/P9100351.JPG Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 MiB

BIN
public/gallery/P9100356.JPG Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 MiB

BIN
public/gallery/P9100357.JPG Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 MiB

BIN
public/gallery/P9100377.JPG Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 MiB

BIN
public/gallery/PA170122.JPG Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 MiB