Refactor code structure for improved readability and maintainability
Some checks failed
/ print-content (push) Failing after 1m44s
Some checks failed
/ print-content (push) Failing after 1m44s
This commit is contained in:
parent
68ac80ac12
commit
921b67b553
9 changed files with 415 additions and 21 deletions
18
bun.lock
18
bun.lock
|
|
@ -8,10 +8,12 @@
|
||||||
"@sun-typeface/suit": "^2.0.5",
|
"@sun-typeface/suit": "^2.0.5",
|
||||||
"astro": "7.3.1",
|
"astro": "7.3.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"three": "^0.186.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.12",
|
"@biomejs/biome": "2.5.12",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@types/three": "^0.185.4",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -101,6 +103,8 @@
|
||||||
|
|
||||||
"@clack/prompts": ["@clack/prompts@1.5.0", "", { "dependencies": { "@clack/core": "1.4.0", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA=="],
|
"@clack/prompts": ["@clack/prompts@1.5.0", "", { "dependencies": { "@clack/core": "1.4.0", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA=="],
|
||||||
|
|
||||||
|
"@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="],
|
||||||
|
|
||||||
"@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
|
"@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
|
||||||
|
|
||||||
"@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
|
"@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
|
||||||
|
|
@ -309,6 +313,8 @@
|
||||||
|
|
||||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
|
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
|
||||||
|
|
||||||
|
"@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
|
||||||
|
|
||||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||||
|
|
@ -321,8 +327,14 @@
|
||||||
|
|
||||||
"@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="],
|
"@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="],
|
||||||
|
|
||||||
|
"@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
|
||||||
|
|
||||||
|
"@types/three": ["@types/three@0.185.4", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA=="],
|
||||||
|
|
||||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||||
|
|
||||||
|
"@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
|
||||||
|
|
||||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="],
|
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="],
|
||||||
|
|
||||||
"am-i-vibing": ["am-i-vibing@0.4.0", "", { "dependencies": { "process-ancestry": "^0.1.0" }, "bin": { "am-i-vibing": "dist/cli.mjs" } }, "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg=="],
|
"am-i-vibing": ["am-i-vibing@0.4.0", "", { "dependencies": { "process-ancestry": "^0.1.0" }, "bin": { "am-i-vibing": "dist/cli.mjs" } }, "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg=="],
|
||||||
|
|
@ -417,6 +429,8 @@
|
||||||
|
|
||||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
|
||||||
|
|
||||||
"find-proc": ["find-proc@0.1.0", "", {}, "sha512-OaOpEYv2PiQ7SQ5LIrl+deA1XaWcxEjnpM6VuWXTUvn+teIXxeFTLDmu18/zDQpFmHN4o3oDBX+BT0AGwEhemg=="],
|
"find-proc": ["find-proc@0.1.0", "", {}, "sha512-OaOpEYv2PiQ7SQ5LIrl+deA1XaWcxEjnpM6VuWXTUvn+teIXxeFTLDmu18/zDQpFmHN4o3oDBX+BT0AGwEhemg=="],
|
||||||
|
|
||||||
"flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="],
|
"flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="],
|
||||||
|
|
@ -491,6 +505,8 @@
|
||||||
|
|
||||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||||
|
|
||||||
|
"meshoptimizer": ["meshoptimizer@1.1.1", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="],
|
||||||
|
|
||||||
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||||
|
|
||||||
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
||||||
|
|
@ -591,6 +607,8 @@
|
||||||
|
|
||||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||||
|
|
||||||
|
"three": ["three@0.186.0", "", {}, "sha512-cr/fIM2ddMSVbYVgkfD4jLJv7Fh/8ZTjvo+7gQeSVGUZHxpx9FDwoL5iC7hUz/LiRA8wMbqfnb90xKfm1/HHkQ=="],
|
||||||
|
|
||||||
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
|
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
|
||||||
|
|
||||||
"tinyclip": ["tinyclip@0.1.13", "", {}, "sha512-8OqlXQ35euK9+e7L68u8UwcODxkHoIkjbGsgXuARKNyQ5G6xt8nw1YPeMbxMLgCPFkToU+UEK5j05t2t8edKpQ=="],
|
"tinyclip": ["tinyclip@0.1.13", "", {}, "sha512-8OqlXQ35euK9+e7L68u8UwcODxkHoIkjbGsgXuARKNyQ5G6xt8nw1YPeMbxMLgCPFkToU+UEK5j05t2t8edKpQ=="],
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,13 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sun-typeface/suit": "^2.0.5",
|
"@sun-typeface/suit": "^2.0.5",
|
||||||
"astro": "7.3.1",
|
"astro": "7.3.1",
|
||||||
"clsx": "^2.1.1"
|
"clsx": "^2.1.1",
|
||||||
|
"three": "^0.186.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.12",
|
"@biomejs/biome": "2.5.12",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@types/three": "^0.185.4",
|
||||||
"tailwindcss": "^4.3.3"
|
"tailwindcss": "^4.3.3"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|
|
||||||
BIN
public/about/cards/0.avif
Normal file
BIN
public/about/cards/0.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
BIN
public/about/cards/1.avif
Normal file
BIN
public/about/cards/1.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
public/about/cards/2.avif
Normal file
BIN
public/about/cards/2.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
BIN
public/about/cards/3.avif
Normal file
BIN
public/about/cards/3.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
BIN
public/about/cards/4.avif
Normal file
BIN
public/about/cards/4.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
BIN
public/about/cards/back.avif
Normal file
BIN
public/about/cards/back.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
|
|
@ -1,5 +1,13 @@
|
||||||
---
|
---
|
||||||
import AboutRow from "@/components/AboutRow.astro";
|
import AboutRow from "@/components/AboutRow.astro";
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{ src: "/about/cards/0.avif", alt: "활동 사진 카드" },
|
||||||
|
{ src: "/about/cards/1.avif", alt: "활동 사진 카드" },
|
||||||
|
{ src: "/about/cards/2.avif", alt: "활동 일러스트 카드" },
|
||||||
|
{ src: "/about/cards/3.avif", alt: "활동 일러스트 카드" },
|
||||||
|
{ src: "/about/cards/4.avif", alt: "활동 사진 카드" },
|
||||||
|
];
|
||||||
---
|
---
|
||||||
|
|
||||||
<section
|
<section
|
||||||
|
|
@ -8,7 +16,7 @@ import AboutRow from "@/components/AboutRow.astro";
|
||||||
class="min-h-[calc(100svh-64px)] snap-start bg-background text-foreground"
|
class="min-h-[calc(100svh-64px)] snap-start bg-background text-foreground"
|
||||||
aria-labelledby="about-title"
|
aria-labelledby="about-title"
|
||||||
>
|
>
|
||||||
<div class="grid min-h-full grid-cols-1 lg:grid-cols-2">
|
<div class="grid min-h-full grid-cols-1 lg:grid-cols-[45fr_55fr]">
|
||||||
<div
|
<div
|
||||||
class="relative z-10 flex flex-col justify-center px-6 py-16 sm:px-10 lg:px-[clamp(52px,5.208vw,100px)] lg:py-[clamp(58px,9.26vh,100px)]"
|
class="relative z-10 flex flex-col justify-center px-6 py-16 sm:px-10 lg:px-[clamp(52px,5.208vw,100px)] lg:py-[clamp(58px,9.26vh,100px)]"
|
||||||
>
|
>
|
||||||
|
|
@ -16,8 +24,8 @@ import AboutRow from "@/components/AboutRow.astro";
|
||||||
About<span class="text-foreground/35" aria-hidden="true">.</span>
|
About<span class="text-foreground/35" aria-hidden="true">.</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mt-6 max-w-lg text-base leading-8 text-foreground">
|
<p class="mt-6 max-w-lg text-base leading-8 text-foreground">
|
||||||
안녕하세요. 정보보안과 소프트웨어 엔지니어링을 공부하는 암냥입니다.
|
안녕하세요. 정보보안과 소프트웨어 엔지니어링을 공부하는 암냥입니다.<br/>
|
||||||
새로운 기술을 배우고, 머릿속의 아이디어를 실제로 작동하는 것으로
|
새로운 기술을 배우고, 머릿속의 아이디어를 실제로 작동하는 것으로<br/>
|
||||||
만드는 일을 좋아해요.
|
만드는 일을 좋아해요.
|
||||||
</p>
|
</p>
|
||||||
<section class="mt-16" aria-labelledby="experience-title">
|
<section class="mt-16" aria-labelledby="experience-title">
|
||||||
|
|
@ -93,23 +101,389 @@ import AboutRow from "@/components/AboutRow.astro";
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="about-card-panel order-last min-h-[min(100vw,540px)] overflow-hidden bg-muted lg:sticky lg:top-0 lg:order-none lg:h-[calc(100svh-64px)] lg:min-h-0" aria-label="활동 사진 선택">
|
||||||
class="relative order-last h-[min(100vw,480px)] min-h-0 overflow-hidden bg-muted lg:sticky lg:top-0 lg:order-none lg:h-[calc(100svh-64px)] lg:min-h-0 lg:self-start"
|
<div class="about-card-stage" data-card-stage>
|
||||||
aria-label="활동 사진"
|
<canvas class="about-card-canvas" data-card-canvas aria-hidden="true"></canvas>
|
||||||
|
<div class="about-card-access" role="group" aria-label="활동 카드 선택">
|
||||||
|
{cards.map((card, index) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`활동 카드 ${index + 1} 선택`}
|
||||||
|
aria-pressed="false"
|
||||||
|
data-card-control={index}
|
||||||
|
data-card-src={card.src}
|
||||||
>
|
>
|
||||||
<img
|
{card.alt}
|
||||||
class="absolute inset-0 h-full w-full select-none object-cover object-center"
|
</button>
|
||||||
src="/about/hand.avif"
|
))}
|
||||||
alt="손"
|
</div>
|
||||||
width="1920"
|
<button class="about-card-flip" type="button" data-card-flip aria-label="선택한 카드 앞뒷면 전환" hidden>
|
||||||
height="1080"
|
<span aria-hidden="true">↻</span>
|
||||||
loading="lazy"
|
</button>
|
||||||
decoding="async"
|
</div>
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="pointer-events-none absolute inset-0 bg-gradient-to-t from-background/35 via-transparent to-black/5"
|
|
||||||
aria-hidden="true"
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import * as THREE from "three/src/Three.js";
|
||||||
|
|
||||||
|
const stage = document.querySelector<HTMLElement>("[data-card-stage]");
|
||||||
|
const canvas = document.querySelector<HTMLCanvasElement>("[data-card-canvas]");
|
||||||
|
const aboutSection = stage?.closest<HTMLElement>("#about");
|
||||||
|
const controls = [...document.querySelectorAll<HTMLButtonElement>("[data-card-control]")];
|
||||||
|
const flipButton = document.querySelector<HTMLButtonElement>("[data-card-flip]");
|
||||||
|
|
||||||
|
if (stage && canvas && controls.length) {
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
const camera = new THREE.PerspectiveCamera(27, 1, 0.1, 100);
|
||||||
|
camera.position.z = 15;
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
|
||||||
|
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.25));
|
||||||
|
|
||||||
|
scene.add(new THREE.HemisphereLight(0xffffff, 0x4b3447, 1.8));
|
||||||
|
const keyLight = new THREE.DirectionalLight(0xffffff, 3.6);
|
||||||
|
keyLight.position.set(-4, 6, 8);
|
||||||
|
scene.add(keyLight);
|
||||||
|
const glowLight = new THREE.PointLight(0xffc8e1, 2.4, 12);
|
||||||
|
glowLight.position.set(0, 1.5, 6);
|
||||||
|
scene.add(glowLight);
|
||||||
|
|
||||||
|
const cardWidth = 2.481875;
|
||||||
|
const cardHeight = 3.474625;
|
||||||
|
const cardDepth = 0.028;
|
||||||
|
const cardCornerRadius = 6;
|
||||||
|
const fanOffsets = [-1.95, -0.98, 0, 0.98, 1.95];
|
||||||
|
const fanHeights = [-1.45, -1.34, -1.38, -1.34, -1.45];
|
||||||
|
const fanRotations = [-0.3, -0.15, 0, 0.15, 0.3];
|
||||||
|
const loader = new THREE.TextureLoader();
|
||||||
|
const raycaster = new THREE.Raycaster();
|
||||||
|
const pointer = new THREE.Vector2();
|
||||||
|
function createCardTexture(image: HTMLImageElement) {
|
||||||
|
const texture = new THREE.Texture(image);
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
texture.anisotropy = Math.min(4, renderer.capabilities.getMaxAnisotropy());
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRoundedAlphaTexture() {
|
||||||
|
const maskCanvas = document.createElement("canvas");
|
||||||
|
maskCanvas.width = 256;
|
||||||
|
maskCanvas.height = 360;
|
||||||
|
const context = maskCanvas.getContext("2d");
|
||||||
|
if (!context) return new THREE.Texture();
|
||||||
|
|
||||||
|
context.fillStyle = "white";
|
||||||
|
context.beginPath();
|
||||||
|
context.roundRect(2, 2, 252, 356, cardCornerRadius);
|
||||||
|
context.fill();
|
||||||
|
|
||||||
|
const texture = new THREE.CanvasTexture(maskCanvas);
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundedAlphaTexture = createRoundedAlphaTexture();
|
||||||
|
let loadedTextureCount = 0;
|
||||||
|
let texturesReady = false;
|
||||||
|
let sectionVisible = false;
|
||||||
|
let deckLiftStarted = false;
|
||||||
|
let fanExpanded = false;
|
||||||
|
let deckLiftTimer: number | undefined;
|
||||||
|
const markTextureReady = () => {
|
||||||
|
loadedTextureCount += 1;
|
||||||
|
if (loadedTextureCount === controls.length + 1) {
|
||||||
|
texturesReady = true;
|
||||||
|
startDeckReveal();
|
||||||
|
}
|
||||||
|
scheduleRender();
|
||||||
|
};
|
||||||
|
const cards = controls.map((control, index) => {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
const frontMaterial = new THREE.MeshBasicMaterial({
|
||||||
|
alphaMap: roundedAlphaTexture,
|
||||||
|
transparent: true,
|
||||||
|
});
|
||||||
|
const front = new THREE.Mesh(new THREE.PlaneGeometry(cardWidth, cardHeight), frontMaterial);
|
||||||
|
loader.load(control.dataset.cardSrc ?? "", (texture: THREE.Texture) => {
|
||||||
|
frontMaterial.map = createCardTexture(texture.image as HTMLImageElement);
|
||||||
|
frontMaterial.needsUpdate = true;
|
||||||
|
front.visible = true;
|
||||||
|
markTextureReady();
|
||||||
|
scheduleRender();
|
||||||
|
});
|
||||||
|
const backMaterial = new THREE.MeshBasicMaterial({
|
||||||
|
alphaMap: roundedAlphaTexture,
|
||||||
|
transparent: true,
|
||||||
|
});
|
||||||
|
const back = new THREE.Mesh(new THREE.PlaneGeometry(cardWidth, cardHeight), backMaterial);
|
||||||
|
front.visible = false;
|
||||||
|
back.visible = false;
|
||||||
|
front.position.z = cardDepth / 2 + 0.008;
|
||||||
|
back.position.z = -cardDepth / 2 - 0.008;
|
||||||
|
back.rotation.y = Math.PI;
|
||||||
|
group.add(front, back);
|
||||||
|
group.userData = { index };
|
||||||
|
group.position.set(0, -0.28, index * 0.04);
|
||||||
|
group.rotation.y = Math.PI;
|
||||||
|
group.rotation.z = 0;
|
||||||
|
scene.add(group);
|
||||||
|
return group;
|
||||||
|
});
|
||||||
|
|
||||||
|
loader.load("/about/cards/back.avif", (texture: THREE.Texture) => {
|
||||||
|
const backTexture = createCardTexture(texture.image as HTMLImageElement);
|
||||||
|
cards.forEach((card) => {
|
||||||
|
const back = card.children[1] as THREE.Mesh;
|
||||||
|
const material = back.material as THREE.MeshBasicMaterial;
|
||||||
|
material.map = backTexture;
|
||||||
|
material.needsUpdate = true;
|
||||||
|
back.visible = true;
|
||||||
|
});
|
||||||
|
markTextureReady();
|
||||||
|
scheduleRender();
|
||||||
|
});
|
||||||
|
|
||||||
|
let selectedIndex = -1;
|
||||||
|
let revealedIndex = -1;
|
||||||
|
let revealTimer: number | undefined;
|
||||||
|
let animationFrameId: number | undefined;
|
||||||
|
const pointerDown = new THREE.Vector2();
|
||||||
|
|
||||||
|
function scheduleRender() {
|
||||||
|
if (animationFrameId === undefined) animationFrameId = requestAnimationFrame(render);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDeckReveal() {
|
||||||
|
if (!texturesReady || !sectionVisible || deckLiftStarted) return;
|
||||||
|
deckLiftStarted = true;
|
||||||
|
scheduleRender();
|
||||||
|
deckLiftTimer = window.setTimeout(() => {
|
||||||
|
fanExpanded = true;
|
||||||
|
scheduleRender();
|
||||||
|
}, 620);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateControls() {
|
||||||
|
controls.forEach((control, index) => {
|
||||||
|
control.setAttribute("aria-pressed", String(index === selectedIndex));
|
||||||
|
});
|
||||||
|
if (flipButton) flipButton.hidden = selectedIndex < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCard(index: number) {
|
||||||
|
if (revealTimer !== undefined) window.clearTimeout(revealTimer);
|
||||||
|
if (index === selectedIndex) {
|
||||||
|
selectedIndex = -1;
|
||||||
|
revealedIndex = -1;
|
||||||
|
updateControls();
|
||||||
|
scheduleRender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedIndex = index;
|
||||||
|
revealedIndex = -1;
|
||||||
|
updateControls();
|
||||||
|
revealTimer = window.setTimeout(() => {
|
||||||
|
if (selectedIndex === index) revealedIndex = index;
|
||||||
|
scheduleRender();
|
||||||
|
}, 280);
|
||||||
|
scheduleRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPointer(event: PointerEvent) {
|
||||||
|
const bounds = canvas!.getBoundingClientRect();
|
||||||
|
pointer.set(
|
||||||
|
((event.clientX - bounds.left) / bounds.width) * 2 - 1,
|
||||||
|
-((event.clientY - bounds.top) / bounds.height) * 2 + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hitCard() {
|
||||||
|
raycaster.setFromCamera(pointer, camera);
|
||||||
|
const hit = raycaster.intersectObjects(cards, true)[0];
|
||||||
|
return hit?.object.parent?.userData.index ?? hit?.object.userData.index ?? -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
controls.forEach((control, index) => {
|
||||||
|
control.addEventListener("click", () => selectCard(index));
|
||||||
|
});
|
||||||
|
flipButton?.addEventListener("click", () => {
|
||||||
|
if (selectedIndex >= 0) {
|
||||||
|
revealedIndex = revealedIndex === selectedIndex ? -1 : selectedIndex;
|
||||||
|
scheduleRender();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
canvas.addEventListener("pointermove", (event) => {
|
||||||
|
setPointer(event);
|
||||||
|
});
|
||||||
|
canvas.addEventListener("pointerleave", () => {
|
||||||
|
canvas.style.cursor = "grab";
|
||||||
|
});
|
||||||
|
canvas.addEventListener("pointerdown", (event) => {
|
||||||
|
pointerDown.set(event.clientX, event.clientY);
|
||||||
|
});
|
||||||
|
canvas.addEventListener("pointerup", (event) => {
|
||||||
|
if (Math.hypot(event.clientX - pointerDown.x, event.clientY - pointerDown.y) > 8) return;
|
||||||
|
setPointer(event);
|
||||||
|
const index = hitCard();
|
||||||
|
if (index >= 0) selectCard(index);
|
||||||
|
});
|
||||||
|
|
||||||
|
const scrollRoot = aboutSection?.closest<HTMLElement>("main") ?? null;
|
||||||
|
const revealObserver = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry?.isIntersecting) {
|
||||||
|
sectionVisible = true;
|
||||||
|
startDeckReveal();
|
||||||
|
scheduleRender();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ root: scrollRoot, threshold: 0.35 },
|
||||||
|
);
|
||||||
|
if (aboutSection) revealObserver.observe(aboutSection);
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
const { width, height } = stage!.getBoundingClientRect();
|
||||||
|
renderer.setSize(width, height, false);
|
||||||
|
camera.aspect = width / height;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
const visibleWidth = 2 * camera.position.z * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2)) * camera.aspect;
|
||||||
|
const scale = Math.min(1, Math.max(0.58, (visibleWidth / (fanOffsets.at(-1)! * 2 + cardWidth)) * 0.92));
|
||||||
|
scene.scale.setScalar(scale * 0.9);
|
||||||
|
scheduleRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(resize);
|
||||||
|
resizeObserver.observe(stage);
|
||||||
|
resize();
|
||||||
|
updateControls();
|
||||||
|
|
||||||
|
let previousTime = performance.now();
|
||||||
|
function render() {
|
||||||
|
animationFrameId = undefined;
|
||||||
|
const now = performance.now();
|
||||||
|
const delta = Math.min((now - previousTime) / 1000, 0.05);
|
||||||
|
previousTime = now;
|
||||||
|
let moving = false;
|
||||||
|
cards.forEach((card, index) => {
|
||||||
|
const selected = index === selectedIndex;
|
||||||
|
card.renderOrder = selected ? 20 : index;
|
||||||
|
const targetX = fanExpanded ? fanOffsets[index] : 0;
|
||||||
|
const targetY = selected
|
||||||
|
? fanHeights[index] + 2
|
||||||
|
: (fanExpanded ? fanHeights[index] : (deckLiftStarted ? -0.28 : -2.4));
|
||||||
|
const targetZ = selected ? 2.6 : index * 0.04;
|
||||||
|
const targetScale = selected ? 1.05 : 1;
|
||||||
|
card.position.x = THREE.MathUtils.damp(card.position.x, targetX, 5.5, delta);
|
||||||
|
card.position.y = THREE.MathUtils.damp(card.position.y, targetY, 5.5, delta);
|
||||||
|
card.position.z = THREE.MathUtils.damp(card.position.z, targetZ, 5.5, delta);
|
||||||
|
card.scale.setScalar(THREE.MathUtils.damp(card.scale.x, targetScale, 5.5, delta));
|
||||||
|
const targetRotation = fanExpanded ? fanRotations[index] : 0;
|
||||||
|
card.rotation.z = THREE.MathUtils.damp(card.rotation.z, targetRotation, 5.5, delta);
|
||||||
|
card.rotation.y = THREE.MathUtils.damp(card.rotation.y, revealedIndex === index ? 0 : Math.PI, 7, delta);
|
||||||
|
moving ||= Math.abs(card.position.x - targetX) > 0.001
|
||||||
|
|| Math.abs(card.position.y - targetY) > 0.001
|
||||||
|
|| Math.abs(card.position.z - targetZ) > 0.001
|
||||||
|
|| Math.abs(card.rotation.z - targetRotation) > 0.001
|
||||||
|
|| Math.abs(card.rotation.y - (revealedIndex === index ? 0 : Math.PI)) > 0.001;
|
||||||
|
});
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
if (moving) scheduleRender();
|
||||||
|
}
|
||||||
|
scheduleRender();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.about-card-panel {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-card-stage {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 540px;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
background: radial-gradient(circle at 50% 40%, color-mix(in srgb, var(--background) 96%, white), var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-card-stage::before {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 16%;
|
||||||
|
width: min(78%, 640px);
|
||||||
|
height: 18%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: color-mix(in srgb, var(--foreground) 14%, transparent);
|
||||||
|
content: "";
|
||||||
|
filter: blur(28px);
|
||||||
|
opacity: .22;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-card-canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: grab;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-card-access {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-card-access button {
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-card-flip {
|
||||||
|
position: absolute;
|
||||||
|
right: 28px;
|
||||||
|
bottom: 28px;
|
||||||
|
display: grid;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
place-items: center;
|
||||||
|
z-index: 2;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--foreground) 18%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--foreground);
|
||||||
|
background: color-mix(in srgb, var(--background) 72%, transparent);
|
||||||
|
font-size: 21px;
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0 8px 24px rgb(32 17 29 / 10%);
|
||||||
|
transition: transform 220ms ease, background 220ms ease, box-shadow 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-card-flip:hover,
|
||||||
|
.about-card-flip:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
background: var(--background);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
box-shadow: 0 10px 28px rgb(32 17 29 / 16%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.about-card-flip {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue