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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||