feat(contact): implement contact screen

This commit is contained in:
2025-12-13 19:55:26 +01:00
parent b70d0f3347
commit 3db8f850f0
13 changed files with 258 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

View File

@@ -0,0 +1,131 @@
import { ImageLoader } from "../../../utils/loadImages";
import { ButtonNavigation } from "../../../utils/buttonNavigation";
import { ButtonSelector } from "../../../utils/buttonSelector";
import { HomeScreen } from "../../home";
import type { ContactScreenContext } from "../index";
type ContactButton = "github" | "email" | "website" | "cv";
const ACTIONS = {
github: ["Open", "Github profile", "https://github.com/pihkaal"],
email: ["Copy", "Email", "hello@pihkaal.me"],
website: ["Copy", "Website link", "https://pihkaal.me"],
cv: ["Open", "CV", "https://pihkaal.me/cv"],
} as const satisfies Record<
ContactButton,
[action: "Copy" | "Open", verb: string, content: string]
>;
export class ContactBottomScreen {
private images = new ImageLoader({
homeBackground: "/images/home/bottom-screen/background.webp",
contactBackground: "/images/contact/bottom-screen/background.webp",
buttons: "/images/contact/bottom-screen/buttons.webp",
topBar: "/images/contact/bottom-screen/top-bar.webp",
bottomBar: "/images/contact/bottom-screen/bottom-bar.webp",
okButton: "/images/contact/bottom-screen/ok-button.webp",
});
private navigation: ButtonNavigation<ContactButton>;
private selector = new ButtonSelector([26, 27, 202, 42]);
private context: ContactScreenContext;
constructor(context: ContactScreenContext) {
this.context = context;
this.navigation = new ButtonNavigation<ContactButton>({
buttons: {
github: [26, 27, 202, 42],
email: [26, 59, 202, 42],
website: [26, 91, 202, 42],
cv: [26, 123, 202, 42],
},
navigation: {
github: {
down: "email",
},
email: {
up: "github",
down: "website",
},
website: {
up: "email",
down: "cv",
},
cv: {
up: "website",
},
},
initialButton: "github",
onButtonClick: (button) => {
this.actionateButton(button);
},
});
}
private async actionateButton(button: ContactButton): Promise<void> {
const [action, verb, content] = ACTIONS[button];
if (action === "Copy") {
try {
await navigator.clipboard.writeText(content);
this.context.pushNotification(`${verb} copied to clipboard`);
} catch (error) {
console.error("Failed to copy to clipboard:", error);
}
} else {
window.open(content, "_blank");
this.context.pushNotification(`${verb} opened`);
}
}
render(ctx: CanvasRenderingContext2D): void {
if (!this.images.isReady) return;
// backgrounds
ctx.drawImage(this.images.require("homeBackground"), 0, 0);
ctx.drawImage(this.images.require("contactBackground"), 0, 0);
// top bar
ctx.drawImage(this.images.require("topBar"), 0, 0);
// buttons
ctx.drawImage(this.images.require("buttons"), 31, 32);
// selector
this.selector.render(ctx, this.navigation.getSelectorPosition());
// bottom bar
ctx.save();
ctx.translate(0, 192 - 24);
ctx.drawImage(this.images.require("bottomBar"), 0, 0);
ctx.drawImage(this.images.require("okButton"), 144, 4);
ctx.font = "10px NDS10";
ctx.fillStyle = "#000000";
const okLabel = ACTIONS[this.navigation.getSelectedButton()][0];
ctx.fillText(okLabel, 144 + 35, 4 + 13);
ctx.restore();
}
handleTouch(x: number, y: number): void {
// Handle Quit button
if (x >= 31 && x <= 111 && y >= 172 && y <= 190) {
this.context.navigate(new HomeScreen(this.context));
return;
}
// Handle OK button
if (x >= 144 && x <= 224 && y >= 172 && y <= 190) {
this.actionateButton(this.navigation.getSelectedButton());
return;
}
// Handle button navigation
this.navigation.handleTouch(x, y);
}
destroy(): void {
this.navigation.destroy();
}
}

View File

@@ -0,0 +1,39 @@
import type { Screen, ScreenContext } from "../../screen";
import { ContactTopScreen } from "./top";
import { ContactBottomScreen } from "./bottom";
export interface ContactScreenContext extends ScreenContext {
pushNotification: (message: string) => void;
}
export class ContactScreen implements Screen {
private topScreen = new ContactTopScreen();
private bottomScreen: ContactBottomScreen;
constructor(context: ScreenContext) {
const contactContext: ContactScreenContext = {
...context,
pushNotification: (message: string) => {
this.topScreen.pushNotification(message);
},
};
this.bottomScreen = new ContactBottomScreen(contactContext);
}
renderTop(ctx: CanvasRenderingContext2D) {
this.topScreen.render(ctx);
}
renderBottom(ctx: CanvasRenderingContext2D) {
this.bottomScreen.render(ctx);
}
handleTouch(x: number, y: number): void {
this.bottomScreen.handleTouch(x, y);
}
destroy(): void {
this.bottomScreen.destroy();
}
}

View File

@@ -0,0 +1,88 @@
import { ImageLoader } from "../../../utils/loadImages";
export class ContactTopScreen {
private images = new ImageLoader({
homeBackground: "/images/home/top-screen/background.webp",
contactBackground: "/images/contact/top-screen/background.webp",
leftBar: "/images/contact/top-screen/left-bar.webp",
leftBarThings: "/images/contact/top-screen/left-bar-things.webp",
notification: "/images/contact/bottom-screen/notification.webp",
title: "/images/contact/top-screen/title.webp",
});
private notifications: string[] = [];
private notificationsYOffset = 0;
public pushNotification(message: string): void {
this.notifications.push(message);
}
render(ctx: CanvasRenderingContext2D): void {
if (!this.images.isReady) return;
// backgrounds
// NOTE: animate home background
ctx.drawImage(this.images.require("homeBackground"), 0, 0);
ctx.drawImage(this.images.require("contactBackground"), 0, 0);
// left bar
ctx.drawImage(this.images.require("leftBar"), 0, 0);
ctx.drawImage(this.images.require("leftBarThings"), 0, 0);
ctx.font = "10px NDS10";
// notifications
for (let i = this.notifications.length - 1; i >= 0; i--) {
const index = this.notifications.length - 1 - i;
const y = 169 - 24 * index + this.notificationsYOffset;
if (y < -24) break;
ctx.drawImage(this.images.require("notification"), 21, y);
const content = this.notifications[i]!;
ctx.fillStyle = content.includes("opened") ? "#00fbba" : "#e3f300";
ctx.fillText(this.notifications[i]!, 27, y + 15);
}
// title
ctx.drawImage(
this.images.require("title"),
21,
169 - 24 * this.notifications.length + this.notificationsYOffset,
);
// notifications count (left bar)
const MAX = 36;
const MAX_VISIBLE = 8;
let visibleNotifications = Math.min(this.notifications.length, MAX_VISIBLE);
const extraActive =
this.notificationsYOffset > 0 && this.notifications.length > MAX_VISIBLE;
if (extraActive) {
visibleNotifications += 1;
}
ctx.fillStyle = "#415969";
for (let i = 0; i < visibleNotifications; i++) {
ctx.fillRect(3, 161 - i * 4, 12, 2);
}
ctx.fillStyle = "#b2c3db";
const startY = 161 - visibleNotifications * 4;
const top = MAX - MAX_VISIBLE - (extraActive ? 1 : 0);
for (
let i = 0;
i < this.notifications.length - visibleNotifications && i < top;
i++
) {
if (i === top - 1) {
ctx.fillRect(7, startY - i * 4, 4, 2);
} else if (i === top - 2) {
ctx.fillRect(5, startY - i * 4, 8, 2);
} else {
ctx.fillRect(3, startY - i * 4, 12, 2);
}
}
}
}