From 3db8f850f0aa06d69b1bb113975f80019024cb8a Mon Sep 17 00:00:00 2001 From: Pihkaal Date: Sat, 13 Dec 2025 19:55:26 +0100 Subject: [PATCH] feat(contact): implement contact screen --- .../contact/bottom-screen/background.webp | Bin 0 -> 102 bytes .../contact/bottom-screen/bottom-bar.webp | Bin 0 -> 438 bytes .../images/contact/bottom-screen/buttons.webp | Bin 0 -> 1242 bytes .../contact/bottom-screen/notification.webp | Bin 0 -> 110 bytes .../contact/bottom-screen/ok-button.webp | Bin 0 -> 236 bytes .../images/contact/bottom-screen/top-bar.webp | Bin 0 -> 334 bytes .../images/contact/top-screen/background.webp | Bin 0 -> 60 bytes .../contact/top-screen/left-bar-things.webp | Bin 0 -> 176 bytes .../images/contact/top-screen/left-bar.webp | Bin 0 -> 44 bytes public/images/contact/top-screen/title.webp | Bin 0 -> 180 bytes src/screens/contact/bottom/index.ts | 131 ++++++++++++++++++ src/screens/contact/index.ts | 39 ++++++ src/screens/contact/top/index.ts | 88 ++++++++++++ 13 files changed, 258 insertions(+) create mode 100644 public/images/contact/bottom-screen/background.webp create mode 100644 public/images/contact/bottom-screen/bottom-bar.webp create mode 100644 public/images/contact/bottom-screen/buttons.webp create mode 100644 public/images/contact/bottom-screen/notification.webp create mode 100644 public/images/contact/bottom-screen/ok-button.webp create mode 100644 public/images/contact/bottom-screen/top-bar.webp create mode 100644 public/images/contact/top-screen/background.webp create mode 100644 public/images/contact/top-screen/left-bar-things.webp create mode 100644 public/images/contact/top-screen/left-bar.webp create mode 100644 public/images/contact/top-screen/title.webp create mode 100644 src/screens/contact/bottom/index.ts create mode 100644 src/screens/contact/index.ts create mode 100644 src/screens/contact/top/index.ts diff --git a/public/images/contact/bottom-screen/background.webp b/public/images/contact/bottom-screen/background.webp new file mode 100644 index 0000000000000000000000000000000000000000..868887f6dc0c4a2a14b3d12af1b2e8493a0505b1 GIT binary patch literal 102 zcmV-s0Ga<%Nk&Fq00012MM6+kP&iCd0000l|G+N*7a$Nw{FSnrU=4vl;;)p|1PcTb zf2FJ@c>V)_6)0#Vskwg;fPV?-?7c%o|B0q;h^w014Z8V|-S0U-$k0 I9V_5>0EV?JLjV8( literal 0 HcmV?d00001 diff --git a/public/images/contact/bottom-screen/bottom-bar.webp b/public/images/contact/bottom-screen/bottom-bar.webp new file mode 100644 index 0000000000000000000000000000000000000000..386613fb3d0a86611df87f5a7fb4c0a1ff571ea2 GIT binary patch literal 438 zcmV;n0ZIN+Nk&Gl0RRA3MM6+kP&iDY0RR9m|G)(R&!8xh6ld`-4CKHsTo@ZlBT0(( z(Bh!l{xN1o#!#ckV0^8U+Yi;h$Gch~bD63t}y1~F@as2Ct7q-7A@VUl)(=n6?X zby<+jdZOnPqX?kz+yItryKNhSO})6=``;-5GJpy+ldp*WJ967Lkfe?eMLO_wPy2=6 z<@@^g_o?=KFrW7N4gOfZE#`J8P2!;ynGPiKu*cm1ht8D_v*FyeNpz9~$Lv{$p6?eO zU>5b9XpV8WCiM#uJ~NH-1vB68DjTAvY!10U%t^I`L~uPg+V81EPy6LLa1PrDWtihO zJphm*H}dHx}Nqc=b#Zinaxf9z1&x8Qd*~SJ%mJX z4Lir*)X;wA95h)hal(sL|Iph+lw@%%u?~qRZ-B%sNt!b%q%LbA5)~^|Iwal%BW7?( z(v!^*>*-W~=r(L2xD>b0C`MrpWrI)1J{}_3Tt>$AY&mFqiC1|&7&#teu{i{<;cTh@ gp`{LE;pcGwyNB^~?*E~m@39<4gKV!p+wtOa1XK;vxBvhE literal 0 HcmV?d00001 diff --git a/public/images/contact/bottom-screen/buttons.webp b/public/images/contact/bottom-screen/buttons.webp new file mode 100644 index 0000000000000000000000000000000000000000..b0e8024e2ba828a17de20deeb45e1fed4d347683 GIT binary patch literal 1242 zcmV<01SR`YNk&G}1ONb6MM6+kP&iD*1ONapzrY^=Z=j};6gbLHyC|VX0%{sbfusDi zixO%ipr(-&ILc4ED4|9I^B*Ge|A`6}wY%=l%m4rY-Az@gDK}@JV_Tc!xZxIl+JXvn zh-793nuAOm@ZNh9d39O=I6ViN02$3HJfYJOpu?;wM>h_OQPuw!+Wa)(|3&m)f+R_f z6nQSy^rD)RlgtljZC+8A>(!cUnzs1st2KE-BaYPCp^%)WhkA)zh^}Z$1Al1C<1tQ4 zLE~8C)98}6F!IAb-BoLM1psRd_{Xg`6Q@DuAl+Ee3idB^F=b}v*a6(dwSk#A%N_{U zGXDB$f&|#+Hp^gpAilub1a_M2febC+c2D4~^1;(yha z^mO(Gn_p`S`*&l9*;RU7dPIHn_l~RTd&jen>yI0a*X`yhldF}+>-LH)6Q~^dcg9tX z!{^6UjlIC<9{4n=hXF%218x%XyDnScida8$ynb;stCrrtM$fcqaGbSjMQDXC#M(n#?nS+i%8vddqQ_=TUs>S)j3X6 zof4Pjt13s_^Ot0ItgaVC#EepoDh08c4IlikLC?@&5ntmb zI`({0wCWlCT({q=hYVX@+a0H#p(@g%w%h4%w?}+%8?^k1e*S+`difx%zl~#P= zt4@3=M@L`XRSNjv1mSslb+S?mZyLcSyDQ+dy51qK8#DI>#`=24`s?avkKUpG=iW~Z z1l-ijQ}pUwF>zNw<$VWc%Dq~d!ut++n5!LL(l|0ar|-Ui6sAtOxf$28cc3p*LS=S> zY1%t_>>y&etKPsg?HxUK5b^igNAI)IvUkkP(F_;S<;ZfcCaro0pXj)NaIQ*EGp%}u z;0)dKS1X&Im{f7J>m3fgnUcNpCe4#LGV!+0;5H6{IOMbK}7#20LmB0=NoY4yW|L6Tp8~V QUyjfhoX|o(-vG)N08x-GS^xk5 literal 0 HcmV?d00001 diff --git a/public/images/contact/bottom-screen/ok-button.webp b/public/images/contact/bottom-screen/ok-button.webp new file mode 100644 index 0000000000000000000000000000000000000000..5f438af915bb5b2b7df2155c089d5b27568b342a GIT binary patch literal 236 zcmV!ND2ZscC?}|Sl|v{OKy>m*mO(HGa2Nnc z+kks(^8d4m*iy;?5&cKowr!-uQ_^5zt;-5+r)6>g literal 0 HcmV?d00001 diff --git a/public/images/contact/bottom-screen/top-bar.webp b/public/images/contact/bottom-screen/top-bar.webp new file mode 100644 index 0000000000000000000000000000000000000000..7ceca756b772ca1842293a2b340179a555b9790a GIT binary patch literal 334 zcmV-U0kQs4Nk&FS0RRA3MM6+kP&iCF0RR9m|G)(RU!bIoBxoh9e@m<6(8KP~*iaLI z03(9_b%YrWe9W|M(=r%;S|gHu`!fF_q&cEBMh9F~aBU9IuWnBPDY9+X$|Ie6Q#9Zp z2>20fihNfIY<-gQ!$q@~jMW~>(y`s}T=fAji668bo5d8@`5tCNltF)?A1Vv@=P z;)mIU12)DO1FjWr8Gr4M74BFW-s${ac||d@ z%~+8T2!#4b%3TzJ00|+K2qi)-w`%+_)_V{V5~2+F$ruUAAcQ~&8`1KS#=}_qP8!rR g^b9DFKq4tpKcrU}5=E#VdS&=EKmYr`{QDpN_UP`r5EKW2Wf+hz&B{$D=Y9oGBe QztRS|l;5|%?`LEH0B7wO{{R30 literal 0 HcmV?d00001 diff --git a/public/images/contact/top-screen/left-bar-things.webp b/public/images/contact/top-screen/left-bar-things.webp new file mode 100644 index 0000000000000000000000000000000000000000..55e5a5115ef609a3da6868875dd4eb8ac9b3023d GIT binary patch literal 176 zcmV;h08jr?Nk&Gf00012MM6+kP&iDS0000l62LDIZ=fIm$j0rdg~|f3e`+WQ0J3po z2`z^@>>mYfBq@jfwVXG8oc{m-08mvSB1Dww!qj-c1DTuuzxSRw$65>6M7HhZUI(8H z-P3>s97GEb3BSbxCAh!>63~=mMG?{e3BX2iokc>dZB$hW$(%(((hc+AI8GiAGIc9v e?AM&caq3g@;DF^Fx=a{nnK-sx$Nzr2j;{~a1WTj< literal 0 HcmV?d00001 diff --git a/public/images/contact/top-screen/left-bar.webp b/public/images/contact/top-screen/left-bar.webp new file mode 100644 index 0000000000000000000000000000000000000000..a1722e69794c9d13a58707177efca9c64e75c61e GIT binary patch literal 44 zcmWIYbaPW-U|V1||i i90u<69BNcwQuCJ${IA*$109#z2U@GP>%2=e)o+Dsh) literal 0 HcmV?d00001 diff --git a/src/screens/contact/bottom/index.ts b/src/screens/contact/bottom/index.ts new file mode 100644 index 0000000..678144d --- /dev/null +++ b/src/screens/contact/bottom/index.ts @@ -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; + private selector = new ButtonSelector([26, 27, 202, 42]); + private context: ContactScreenContext; + + constructor(context: ContactScreenContext) { + this.context = context; + this.navigation = new ButtonNavigation({ + 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 { + 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(); + } +} diff --git a/src/screens/contact/index.ts b/src/screens/contact/index.ts new file mode 100644 index 0000000..ed0ae61 --- /dev/null +++ b/src/screens/contact/index.ts @@ -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(); + } +} diff --git a/src/screens/contact/top/index.ts b/src/screens/contact/top/index.ts new file mode 100644 index 0000000..666ce1f --- /dev/null +++ b/src/screens/contact/top/index.ts @@ -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); + } + } + } +}