feat(contact): implement contact screen
BIN
public/images/contact/bottom-screen/background.webp
Normal file
|
After Width: | Height: | Size: 102 B |
BIN
public/images/contact/bottom-screen/bottom-bar.webp
Normal file
|
After Width: | Height: | Size: 438 B |
BIN
public/images/contact/bottom-screen/buttons.webp
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/images/contact/bottom-screen/notification.webp
Normal file
|
After Width: | Height: | Size: 110 B |
BIN
public/images/contact/bottom-screen/ok-button.webp
Normal file
|
After Width: | Height: | Size: 236 B |
BIN
public/images/contact/bottom-screen/top-bar.webp
Normal file
|
After Width: | Height: | Size: 334 B |
BIN
public/images/contact/top-screen/background.webp
Normal file
|
After Width: | Height: | Size: 60 B |
BIN
public/images/contact/top-screen/left-bar-things.webp
Normal file
|
After Width: | Height: | Size: 176 B |
BIN
public/images/contact/top-screen/left-bar.webp
Normal file
|
After Width: | Height: | Size: 44 B |
BIN
public/images/contact/top-screen/title.webp
Normal file
|
After Width: | Height: | Size: 180 B |
131
src/screens/contact/bottom/index.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
39
src/screens/contact/index.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
88
src/screens/contact/top/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||