diff options
58 files changed, 1861 insertions, 374 deletions
diff --git a/LICENSES.md b/LICENSES.md new file mode 100644 index 0000000..d47daab --- /dev/null +++ b/LICENSES.md @@ -0,0 +1,9 @@ +Everything in `/assets` is licensed under [CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/) except for the following: +- `Nintendo-DS-BIOS.ttf` +- `text.png` +- `video.png` +- `image.png` + +The last three are processed versions of icons taken from https://web.archive.org/web/20260000000000*/https://winclassic.net/thread/96/resolution-windows-icons-official-microsoft + +Source code is licensed under [GPLv3](https://www.gnu.org/licenses/gpl-3.0.en.html) diff --git a/assets/audio.png b/assets/audio.png Binary files differnew file mode 100644 index 0000000..a96d5b7 --- /dev/null +++ b/assets/audio.png diff --git a/assets/bandcamp.png b/assets/bandcamp.png Binary files differdeleted file mode 100644 index 791a193..0000000 --- a/assets/bandcamp.png +++ /dev/null diff --git a/assets/bg.svg b/assets/bg.svg deleted file mode 100644 index 958afdd..0000000 --- a/assets/bg.svg +++ /dev/null @@ -1,24 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<!-- Created with Inkscape (http://www.inkscape.org/) --> - -<svg - width="32" - height="31.999998" - viewBox="0 0 8.4666664 8.4666661" - version="1.1" - id="svg1" - xmlns="http://www.w3.org/2000/svg" - xmlns:svg="http://www.w3.org/2000/svg"> - <defs - id="defs1" /> - <g - id="layer1"> - <rect - style="fill:#ff0047;fill-opacity:1;stroke-width:0.176666;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:16" - id="rect1" - width="2.1166666" - height="2.1166666" - x="3.175" - y="3.175" /> - </g> -</svg> diff --git a/assets/cozette_vector.woff2 b/assets/cozette_vector.woff2 Binary files differdeleted file mode 100644 index e141ead..0000000 --- a/assets/cozette_vector.woff2 +++ /dev/null diff --git a/assets/image.png b/assets/image.png Binary files differnew file mode 100644 index 0000000..060dcc3 --- /dev/null +++ b/assets/image.png diff --git a/assets/link.png b/assets/link.png Binary files differnew file mode 100644 index 0000000..55db866 --- /dev/null +++ b/assets/link.png diff --git a/assets/listen.png b/assets/listen.png Binary files differnew file mode 100644 index 0000000..4227d87 --- /dev/null +++ b/assets/listen.png diff --git a/assets/newgrounds.png b/assets/newgrounds.png Binary files differdeleted file mode 100644 index 15bda42..0000000 --- a/assets/newgrounds.png +++ /dev/null diff --git a/assets/pfp.jpg b/assets/pfp.jpg Binary files differnew file mode 100644 index 0000000..23f319d --- /dev/null +++ b/assets/pfp.jpg diff --git a/assets/scientifica.ttf b/assets/scientifica.ttf Binary files differdeleted file mode 100644 index 1753d31..0000000 --- a/assets/scientifica.ttf +++ /dev/null diff --git a/assets/soundcloud.png b/assets/soundcloud.png Binary files differdeleted file mode 100644 index f2fd5f6..0000000 --- a/assets/soundcloud.png +++ /dev/null diff --git a/assets/text.png b/assets/text.png Binary files differnew file mode 100644 index 0000000..90a2f0d --- /dev/null +++ b/assets/text.png diff --git a/assets/video.png b/assets/video.png Binary files differnew file mode 100644 index 0000000..7adc62a --- /dev/null +++ b/assets/video.png diff --git a/assets/youtube.png b/assets/youtube.png Binary files differdeleted file mode 100644 index cc27a3f..0000000 --- a/assets/youtube.png +++ /dev/null diff --git a/css/index.css b/css/index.css index 9f5b7a9..9b58692 100644 --- a/css/index.css +++ b/css/index.css @@ -18,6 +18,7 @@ --red-dark: #7d0425; --red-bright: #ff77a8; --yellow: #ffa300; + --blue: #0000ff; --white: #fff1e8; --spacing: 4px; @@ -28,12 +29,10 @@ html { } body { - /* font-family: "Cozette Vector"; */ font-family: "Nintendo DS BIOS"; margin: 0; height: 100vh; font-size: 1.2rem; - /* background-color: var(--red-dark); */ background-image: linear-gradient(26.57deg, var(--red-dark) 0%, var(--red) 100%); } @@ -51,7 +50,7 @@ button { isolation: isolate; } -@keyframes bounce { +@keyframes scroll { from { background-position: 0 0; } @@ -61,16 +60,6 @@ button { } } -@keyframes scroll { - from { - transform: translateX(0); - } - - to { - transform: translateX(var(--translate-amount)); - } -} - #desktop { border-bottom: 1px solid var(--white); flex-grow: 1; @@ -89,7 +78,7 @@ button { #netpunk, #netpunk_descender { pointer-events: none; - width: 45vw; + width: 25em; display: block; position: absolute; right: 0; @@ -97,7 +86,7 @@ button { } #netpunk { - z-index: 3; + z-index: -1; } #netpunk_descender { @@ -115,37 +104,19 @@ button { left: 50%; top: 50%; z-index: 1; - opacity: 0.075; + opacity: 0.1; transform: translate(-50%, -50%) rotate(26.57deg); -} - -#bg.bounce > div { - animation: scroll linear infinite, bounce infinite cubic-bezier(0.2, 0, 0, 1.0); - animation-duration: 4s, calc(60s / 130); -} - -#bg > div { - width: calc(100% + 32px); - height: 100%; background-image: url("/assets/bg_top.svg"); animation: scroll linear infinite; animation-duration: 4s; --translate-amount: 32px; } -#bg.reverse > div { - background-image: url("/assets/bg_bottom.svg"); +#bg.reverse { --translate-amount: -32px; + background-image: url("/assets/bg_bottom.svg"); } -/* #desktop > div { */ -/* animation: bounce infinite cubic-bezier(0.2, 0, 0, 1.0); */ -/* animation-duration: calc(60s / 130); */ -/* background-color: black; */ -/* width: 4px; */ -/* height: 4px; */ -/* } */ - nav { z-index: 2; background-image: linear-gradient(to bottom, var(--red) 0%, var(--red) 50%, black 50%, black 100%); @@ -169,26 +140,33 @@ nav button { nav > ul { display: flex; - margin: 0; - padding: 0; flex-grow: 1; gap: var(--spacing); align-items: center; padding: 6px; } -nav > ul > li { - margin: 0; - list-style: none; -} - - nav > ul > li > button { padding: var(--spacing) calc(var(--spacing) + 0.3em); background-color: transparent; border-radius: 0.6em; border: 2px solid var(--red-dark); + position: relative; + max-width: 10vw; + z-index: 5; + display: flex; + gap: var(--spacing); +} +nav > ul > li > button > div { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +nav > ul > li > button > img { + height: 1em; + width: 1em; } nav > div { @@ -213,7 +191,6 @@ nav > div { border-top: 2px solid var(--red-dark); border-top-right-radius: 9999px; box-sizing: border-box; - /* box-shadow: inset #fff6 -1px 1px 0px; */ box-shadow: inset var(--yellow) -1px 1px 0px; } @@ -235,36 +212,62 @@ nav > div { flex-direction: column; align-items: center; border-left: 2px solid var(--red-dark); + width: 10%; } .window { background-color: black; border-radius: 4px; - border: 1px solid var(--red-bright); + border: 2px solid var(--red-bright); border-top-color: var(--white); box-shadow: black 0 -2px; - overflow: hidden; position: absolute; display: flex; flex-direction: column; z-index: 4; + box-sizing: border-box; + padding: 6px; + gap: 6px; + --grab-region-size: 8px; +} + +.window.hidden { + opacity: 0; + pointer-events: none; + visibility: none; } .window .titlebar { - padding: 6px; color: var(--white); user-select: none; cursor: grab; display: flex; + text-shadow: var(--red-bright) 1px 1px 0; + gap: calc(var(--spacing) * 2); +} + +.window.maximized .titlebar { + cursor: default; } .window .titlebar > .title { flex: 1; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + width: 0; +} + +.window .titlebar > img { + pointer-events: none; + height: 1em; + width: 1em; } .window .titlebar > .buttons { display: flex; gap: var(--spacing); + cursor: default; } .window .titlebar button { @@ -284,18 +287,30 @@ nav > div { display: block; } +.window.dragging { + will-change: top, left; +} + .window.dragging > .titlebar{ cursor: grabbing; } .window .content { - padding: 6px; - padding-top: 0; height: 100%; + display: flex; + justify-content: center; + overflow: hidden auto; } -.window textarea { +.window .content > img { + height: 100%; + width: 100%; + object-fit: contain; +} + +.window .text_content { background-color: var(--white); + padding: var(--spacing); margin: 0; border: none; border-radius: 0; @@ -305,14 +320,57 @@ nav > div { width: 100%; height: 100%; box-sizing: border-box; - white-space: pre-wrap; + flex-direction: column; + overflow-y: auto; + font-size: 1.25rem; + line-height: 1.1; +} + +p, +.link { + margin-top: var(--spacing); +} + +h1::before { + content: "# " +} + +h2::before { + content: "## " } -.window textarea:focus-visible { +h3::before { + content: "### " +} + +blockquote, .credit { + margin-left: 2em; + margin-right: 2em; +} + +.credit::before { + content: "- " +} + +.window .text_content ol { + list-style: decimal inside; +} + +.window .text_content li { + padding-left: var(--spacing); +} + +.window .text_content:focus-visible { outline: none; box-shadow: inset var(--red-bright) 0 0 0 1px; } +.window .text_content ::selection, +.window .text_content ::-moz-selection { + background-color: var(--red); + color: var(--white); +} + .window .external_warning { color: var(--white); background-color: var(--red-dark); @@ -321,14 +379,17 @@ nav > div { flex-direction: column; align-items: center; gap: var(--spacing); - min-width: max-content; + width: max-content; + overflow: hidden; } -.window .external_warning a { +.window .external_warning a, +.window .text_content a { color: var(--red); } -.window .external_warning a:hover { +.window .external_warning a:hover, +.window .text_content a:hover { color: var(--red-bright); } @@ -339,14 +400,235 @@ nav > div { padding: var(--spacing) calc(var(--spacing) + 0.3em); } -.icon_grid { - display: grid; - grid-template-rows: repeat(10, 1fr); - /* grid-template-columns: repeat(, 1fr); */ - /* grid-template-columns: repeat(10, 1fr); */ +.side_v { + position: absolute; + height: var(--grab-region-size); + width: calc(100% - 4px); + transform: translate(-2px, var(--y)); + cursor: row-resize; + user-select: none; +} + +.side_h { + height: calc(100% - 4px); + position: absolute; + width: var(--grab-region-size); + transform: translate(var(--x), -2px); + cursor: col-resize; + user-select: none; +} + +.corner { + position: absolute; + height: var(--grab-region-size); + width: var(--grab-region-size); + transform: translate(var(--x), var(--y)); + user-select: none; +} + +.n { + --y: calc(-100% + 2px); + top: 0; +} + +.e { + --x: calc(100% - 2px); + right: 0; +} + +.s { + --y: calc(100% - 2px); + bottom: 0; + +} + +.w { + --x: calc(-100% + 2px); + left: 0; +} + +.n.e { + cursor: ne-resize; +} + +.n.w { + cursor: nw-resize; +} + +.s.e { + cursor: se-resize; +} + +.s.w { + cursor: sw-resize; +} + +.icon_list { + display: flex; height: 100%; + flex-direction: column; + gap: var(--spacing); + flex-wrap: wrap; + width: min-content; } -.icon_grid > div { - aspect-ratio: 1 / 1; +.icon_list > button { + display: flex; + flex-direction: column; + text-shadow: black 1px 1px 0; + color: var(--white); + max-width: 3.5em; + align-items: center; + border: none; + padding: 0; +} + +.icon_list > button:focus-visible { + outline: none; +} + +.icon_list .name { + overflow-wrap: anywhere; + -webkit-line-clamp: 2; + display: -webkit-box; + -webkit-box-orient: vertical; + overflow-y: hidden; + height: 2em; +} + + +.icon_list > button.focused > .name { + overflow-y: visible; + position: relative; + width: 100%; + -webkit-line-clamp: unset; +} + +.icon_list > button > .name > div { + width: 100%; +} + +.icon_list > button.focused > .name > div { + box-shadow: var(--red) 0 0 0 4px, var(--red-dark) 0 0 0 5px; + background-color: var(--red); + position: absolute; + left: 50%; + transform: translateX(-50%); +} + +.icon_list .icon { + height: 3.5em; + width: 3.5em; + padding: calc(var(--spacing) * 2); + box-sizing: border-box; +} + +.icon_list .icon > img { + width: 100%; + height: 100%; +} + +#start_menu { + position: absolute; + z-index: 9999; + background-color: var(--red); + bottom: 0; + left: 0; + border: var(--red) solid 2px; + border-radius: 4px 4px 0 0; + border-bottom: 0; + font-size: 1.1em; + color: var(--white); + max-width: 20em; + max-height: 50vh; + display: flex; + flex-direction: column; + text-shadow: black 1px 1px 0; +} + +#start_menu_content { + background-color: black; + display: flex; + flex-grow: 1; +} + +#start_menu_content > div { + padding: 6px 12px; + font-size: 0.9em; + display: flex; + flex-direction: column; + justify-content: end; +} + +#start_menu_content > div > ul { + list-style: "- " inside; +} + +#start_menu_content > ul { + background-color: black; + gap: var(--spacing); + display: flex; + flex-direction: column; + padding: 6px 12px; + background-color: var(--red-dark); + white-space: nowrap; +} + +#start_menu a { + color: var(--white); +} + +#start_menu a:hover { + color: white; +} + +#start_menu_content > ul a { + text-decoration: none; +} + +#profile { + display: flex; + padding: calc(var(--spacing) * 2); + gap: calc(var(--spacing) * 2); + font-size: 1em; + align-items: center; + background-image: linear-gradient(to bottom, var(--red) 0%, var(--red-dark) 100%) +} + +#profile > img { + width: 3em; + height: 3em; + padding: 2px; + background-color: var(--red-dark); + border-radius: 4px; +} + +#footer { + padding: 6px 12px; + text-align: end; + background-image: linear-gradient(to bottom, var(--red) 0%, var(--red-dark) 100%) +} + +::selection, +::-moz-selection { + background-color: var(--blue); +} + +pre > code { + white-space: pre; + font-family: monospace; + font-size: 1rem; +} + +.link a::before { + content: "["; +} + +.link a::after { + content: "]"; +} + +iframe { + width: 100%; + height: 100%; } diff --git a/favicon.png b/favicon.png Binary files differnew file mode 100644 index 0000000..a237445 --- /dev/null +++ b/favicon.png @@ -1,13 +1,21 @@ <!DOCTYPE html> <html> <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <meta property="og:title" content="Netpunk" /> + <meta property="og:type" content="website" /> + <meta property="og:url" content="http://netpunk.darkuss.fyi" /> + <meta property="og:description" content="Netpunk, website style" /> + <meta name="theme-color" content="#ff0047" /> + + <title>Netpunk</title> + <link rel="icon" href="favicon.png" /> <link rel="stylesheet" href="/css/reset.css"> <link rel="stylesheet" href="/css/index.css"> <script src="/src/main.tsx" type="module"></script> </head> <body> <div id="root"></div> - <!-- <div></div> --> - <!-- <iframe style="border: 0; width: 350px; height: 786px;" src="https://bandcamp.com/EmbeddedPlayer/album=3374731395/size=large/bgcol=333333/linkcol=e32c14/transparent=true/" seamless><a href="https://sunnexo.bandcamp.com/album/netpunk">Netpunk by Sunnexo</a></iframe> --> </body> </html> diff --git a/package.json b/package.json index 249d8de..7c69b3c 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "license": "GPL-3.0-or-later", "packageManager": "pnpm@10.9.0", "dependencies": { - "@base-ui/react": "^1.7.0", "@preact/preset-vite": "^2.10.6", + "@preact/signals": "^2.11.0", "@tsconfig/recommended": "^1.0.13", "preact": "^10.29.8", "temporal-polyfill": "^1.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 292fd83..96b03dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,18 +8,21 @@ importers: .: dependencies: - '@base-ui/react': - specifier: ^1.7.0 - version: 1.7.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@preact/preset-vite': specifier: ^2.10.6 - version: 2.10.6(@babel/core@7.29.7)(preact@10.29.8)(vite@8.2.1) + version: 2.10.6(@babel/core@7.29.7)(preact@10.29.8)(rollup@2.80.0)(vite@8.2.1) + '@preact/signals': + specifier: ^2.11.0 + version: 2.11.0(preact@10.29.8) '@tsconfig/recommended': specifier: ^1.0.13 version: 1.0.13 preact: specifier: ^10.29.8 version: 10.29.8 + rollup-plugin-sourcemaps2: + specifier: ^0.5.8 + version: 0.5.8(rollup@2.80.0) temporal-polyfill: specifier: ^1.0.3 version: 1.0.3 @@ -110,10 +113,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/runtime@7.29.7': - resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} - engines: {node: '>=6.9.0'} - '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -126,48 +125,6 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} - '@base-ui/react@1.7.0': - resolution: {integrity: sha512-j+8QjX44C32jrXD/qyEAGpFr70FRpGL2CY61mQd9nBPWN737CK0xxD1ceJ055rW4RtdvFDT1e7otzdlfxvsYug==} - engines: {node: '>=14.0.0'} - peerDependencies: - '@date-fns/tz': ^1.2.0 - '@types/react': ^17 || ^18 || ^19 - date-fns: ^4.0.0 - react: ^17 || ^18 || ^19 - react-dom: ^17 || ^18 || ^19 - peerDependenciesMeta: - '@date-fns/tz': - optional: true - '@types/react': - optional: true - date-fns: - optional: true - - '@base-ui/utils@0.3.2': - resolution: {integrity: sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ==} - peerDependencies: - '@types/react': ^17 || ^18 || ^19 - react: ^17 || ^18 || ^19 - react-dom: ^17 || ^18 || ^19 - peerDependenciesMeta: - '@types/react': - optional: true - - '@floating-ui/core@1.8.0': - resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} - - '@floating-ui/dom@1.8.0': - resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} - - '@floating-ui/react-dom@2.1.9': - resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@floating-ui/utils@0.2.12': - resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -193,6 +150,14 @@ packages: '@babel/core': 7.x vite: 2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x + '@preact/signals-core@1.14.4': + resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==} + + '@preact/signals@2.11.0': + resolution: {integrity: sha512-mmrZ8I/Wef+tmSsQSjZySLRlnvuYAVfSZEy1+HrFvSwBtQf8/zSM+bV7odllUXIhKzUkBjsNk+TLZ20nt7p6Gg==} + peerDependencies: + preact: '>= 10.25.0 || >=11.0.0-0' + '@prefresh/babel-plugin@0.5.3': resolution: {integrity: sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ==} @@ -542,25 +507,25 @@ packages: preact-render-to-string: optional: true - react-dom@19.2.8: - resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} - peerDependencies: - react: ^19.2.8 - - react@19.2.8: - resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} - engines: {node: '>=0.10.0'} - - reselect@5.2.0: - resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} - rolldown@1.2.3: resolution: {integrity: sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + rollup-plugin-sourcemaps2@0.5.8: + resolution: {integrity: sha512-c5BfiVbKAmaz+RoEhFF44Tg/X2QoLxEB8F7wS/eNWDJF/NKxi0SF7srN3Dg0RUnIcYLyCOwscket5Mr3BImMbg==} + engines: {node: '>=22.13.0'} + peerDependencies: + '@types/node': '>=18.0.0' + rollup: '>=4' + peerDependenciesMeta: + '@types/node': + optional: true + + rollup@2.80.0: + resolution: {integrity: sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==} + engines: {node: '>=10.0.0'} + hasBin: true semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} @@ -600,11 +565,6 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - use-sync-external-store@1.6.0: - resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - vite-prerender-plugin@0.5.13: resolution: {integrity: sha512-IKSpYkzDBsKAxa05naRbj7GvNVMSdww/Z/E89oO3xndz+gWnOBOKOAbEXv7qDhktY/j3vHgJmoV1pPzqU2tx9g==} peerDependencies: @@ -767,8 +727,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/runtime@7.29.7': {} - '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -792,42 +750,6 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@base-ui/react@1.7.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - '@babel/runtime': 7.29.7 - '@base-ui/utils': 0.3.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@floating-ui/utils': 0.2.12 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - use-sync-external-store: 1.6.0(react@19.2.8) - - '@base-ui/utils@0.3.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - '@babel/runtime': 7.29.7 - '@floating-ui/utils': 0.2.12 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - reselect: 5.2.0 - use-sync-external-store: 1.6.0(react@19.2.8) - - '@floating-ui/core@1.8.0': - dependencies: - '@floating-ui/utils': 0.2.12 - - '@floating-ui/dom@1.8.0': - dependencies: - '@floating-ui/core': 1.8.0 - '@floating-ui/utils': 0.2.12 - - '@floating-ui/react-dom@2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - '@floating-ui/dom': 1.8.0 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - - '@floating-ui/utils@0.2.12': {} - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -849,13 +771,13 @@ snapshots: '@oxc-project/types@0.143.0': {} - '@preact/preset-vite@2.10.6(@babel/core@7.29.7)(preact@10.29.8)(vite@8.2.1)': + '@preact/preset-vite@2.10.6(@babel/core@7.29.7)(preact@10.29.8)(rollup@2.80.0)(vite@8.2.1)': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-react-jsx-development': 7.29.7(@babel/core@7.29.7) '@prefresh/vite': 2.4.12(preact@10.29.8)(vite@8.2.1) - '@rollup/pluginutils': 5.4.0 + '@rollup/pluginutils': 5.4.0(rollup@2.80.0) babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.29.7) debug: 4.4.3 magic-string: 0.30.21 @@ -868,6 +790,13 @@ snapshots: - rollup - supports-color + '@preact/signals-core@1.14.4': {} + + '@preact/signals@2.11.0(preact@10.29.8)': + dependencies: + '@preact/signals-core': 1.14.4 + preact: 10.29.8 + '@prefresh/babel-plugin@0.5.3': {} '@prefresh/core@1.5.10(preact@10.29.8)': @@ -937,11 +866,13 @@ snapshots: estree-walker: 2.0.2 picomatch: 2.3.2 - '@rollup/pluginutils@5.4.0': + '@rollup/pluginutils@5.4.0(rollup@2.80.0)': dependencies: '@types/estree': 1.0.9 estree-walker: 2.0.2 picomatch: 4.0.5 + optionalDependencies: + rollup: 2.80.0 '@tsconfig/recommended@1.0.13': {} @@ -1114,15 +1045,6 @@ snapshots: preact@10.29.8: {} - react-dom@19.2.8(react@19.2.8): - dependencies: - react: 19.2.8 - scheduler: 0.27.0 - - react@19.2.8: {} - - reselect@5.2.0: {} - rolldown@1.2.3: dependencies: '@oxc-project/types': 0.143.0 @@ -1143,7 +1065,14 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.3 '@rolldown/binding-win32-x64-msvc': 1.2.3 - scheduler@0.27.0: {} + rollup-plugin-sourcemaps2@0.5.8(rollup@2.80.0): + dependencies: + '@rollup/pluginutils': 5.4.0(rollup@2.80.0) + rollup: 2.80.0 + + rollup@2.80.0: + optionalDependencies: + fsevents: 2.3.3 semver@6.3.1: {} @@ -1177,10 +1106,6 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - use-sync-external-store@1.6.0(react@19.2.8): - dependencies: - react: 19.2.8 - vite-prerender-plugin@0.5.13(vite@8.2.1): dependencies: kolorist: 1.8.0 diff --git a/src/apps/audio.tsx b/src/apps/audio.tsx new file mode 100644 index 0000000..45d8716 --- /dev/null +++ b/src/apps/audio.tsx @@ -0,0 +1,27 @@ +import { Signal, useSignalEffect } from "@preact/signals" +import { Address } from "$/apps/source"; +import { memo, Ref, useEffect } from "preact/compat"; + +const filenameRegex = /.*\/(.*)$/; + +export const Audio = memo(function Audio({ source, title, contentReady }: Image.Props) { + useEffect(() => { + contentReady.value = true + }, []) + + useSignalEffect(() => { + const filename = decodeURIComponent(source.value.link.pathname.match(filenameRegex)?.[1] ?? source.value.link.toString()); + title.value = `${filename} - tunez player`; + }) + + + return <audio controls src={source.value.link.toString()}></audio> +}) + +namespace Image { + export type Props = { + source: Signal<Address>, + title: Signal<string | undefined>, + contentReady: Signal<boolean>, + } +} diff --git a/src/apps/browser.tsx b/src/apps/browser.tsx new file mode 100644 index 0000000..71b2f4e --- /dev/null +++ b/src/apps/browser.tsx @@ -0,0 +1,23 @@ +import { Signal, useSignalEffect } from "@preact/signals" +import { Address } from "$/apps/source"; +import { memo, useEffect } from "preact/compat"; + +export const Browser = memo(function Browser({ source, title, contentReady }: Browser.Props) { + useEffect(() => { + contentReady.value = true + }, []) + + useSignalEffect(() => { + title.value = `${source.value.link} - internet exploder`; + }) + + return <iframe src={source.value.link.toString()} allowFullScreen={ source.value.link.hostname.includes("youtube.com") } height="40vh" width="40vw"/> +}) + +namespace Browser { + export type Props = { + source: Signal<Address>, + title: Signal<string | undefined>, + contentReady: Signal<boolean> + } +} diff --git a/src/apps/edit_rich.tsx b/src/apps/edit_rich.tsx new file mode 100644 index 0000000..0b61178 --- /dev/null +++ b/src/apps/edit_rich.tsx @@ -0,0 +1,28 @@ +import { Signal, useSignalEffect } from "@preact/signals" +import { Source, VirtualFile, VirtualTextFile } from "$/apps/source"; +import { memo, useEffect } from "preact/compat"; + +export const Edit = memo(function Edit(props: Edit.Props) { + const source = props.source as Signal<VirtualFile>; + const title = props.title; + + useEffect(() => { + props.contentReady.value = true + }, []) + + useSignalEffect(() => { + title.value = `${source.value.name} - moed`; + }) + + return <div class="text_content"> + {(source.value as VirtualTextFile).content} + </div> +}) + +namespace Edit { + export type Props = { + source: Signal<Source>, + title: Signal<string | undefined>, + contentReady: Signal<boolean> + } +} diff --git a/src/apps/image.tsx b/src/apps/image.tsx new file mode 100644 index 0000000..12623c3 --- /dev/null +++ b/src/apps/image.tsx @@ -0,0 +1,61 @@ +import { effect, Signal, useSignalEffect } from "@preact/signals" +import { Address } from "$/apps/source"; +import { memo, Ref, useRef } from "preact/compat"; + +const filenameRegex = /.*\/(.*)$/; + +export const Image = memo(function Image({ source, title, contentReady, sizingFinished }: Image.Props) { + useSignalEffect(() => { + const filename = decodeURIComponent(source.value.link.pathname.match(filenameRegex)?.[1] ?? source.value.link.toString()); + title.value = `${filename} - picview`; + }) + + const imgRef: Ref<HTMLImageElement> = useRef(null); + + return <img style={{ "image-rendering": (source.value.pixelated ? "crisp-edges" : undefined) }} src={source.value.link.toString()} ref={img => { + imgRef.current = img + const id = setInterval(() => { + if (img && img.naturalWidth) { + clearInterval(id) + contentReady.value = true + const vw = Math.max(document.documentElement.clientWidth, window.innerWidth) + const vh = Math.max(document.documentElement.clientHeight, window.innerHeight) + + const maxImageSize = Math.min(vw, vh) * 0.4; + const smallestImageAxis = Math.min(img.naturalHeight, img.naturalWidth); + + const aspectRatio = img.naturalWidth / img.naturalHeight + if (smallestImageAxis > maxImageSize) { + if (img.naturalWidth > img.naturalHeight) { + img.style.height = `${maxImageSize / aspectRatio}px` + img.style.width = `${maxImageSize}px` + } else { + img.style.height = `${maxImageSize}px` + img.style.width = `${maxImageSize * aspectRatio}px` + } + + effect(() => { + if (sizingFinished.value) { + img!.style.setProperty("height", null) + img!.style.setProperty("width", null) + } + }) + } + } + }, 50) + + return () => { + clearInterval(id) + imgRef.current = null + } + }}/> +}) + +namespace Image { + export type Props = { + source: Signal<Address>, + title: Signal<string | undefined>, + contentReady: Signal<boolean>, + sizingFinished: Signal<boolean> + } +} diff --git a/src/apps/index.ts b/src/apps/index.ts new file mode 100644 index 0000000..ab9453e --- /dev/null +++ b/src/apps/index.ts @@ -0,0 +1,34 @@ +import { Music } from "./music"; +import { Edit } from "./edit_rich"; + +import { JSX } from "preact"; +import { Source, SourceTypes } from "$/apps/source"; +import { Signal } from "@preact/signals"; +import { Browser } from "./browser"; +import { Image } from "./image"; +import { Video } from "./video"; +import { Audio } from "./audio"; + +type AppProps = { + source: Signal<Source>, + title: Signal<string | undefined>, + contentReady: Signal<boolean>, + sizingFinished: Signal<boolean> +} + +// *sigh* i'd like to make props *required*, but ts doesn't let me +// https://stackoverflow.com/questions/75481900/can-i-make-an-argument-required-in-a-typescript-function-type +export type App = (props: AppProps) => JSX.Element; + +type AppRegistry = { + [SourceType in SourceTypes]: App +} + +export const APP_REGISTRY: AppRegistry = Object.create({ + listen: Music, + text: Edit, + link: Browser, + image: Image, + video: Video, + audio: Audio +}); diff --git a/src/apps/music.tsx b/src/apps/music.tsx new file mode 100644 index 0000000..5b1ef27 --- /dev/null +++ b/src/apps/music.tsx @@ -0,0 +1,32 @@ +import { useEffect, useState } from "preact/hooks" +import { Signal } from "@preact/signals"; +import { memo } from "preact/compat"; + +const Iframe = memo(function Iframe() { + return <iframe style="border: 0; height: 30em" src="https://bandcamp.com/EmbeddedPlayer/album=3374731395/size=large/bgcol=000000/linkcol=ff0047/transparent=true/" seamless><a href="https://sunnexo.bandcamp.com/album/netpunk">Netpunk by Sunnexo</a></iframe> +}) + +export const Music = memo(function Music({ title }: Music.Props) { + const [userApproves, setUserApproves] = useState(false); + + useEffect(() => { + title.value = "Stream on BandCamp" + }, []) + + if (userApproves) { + return <Iframe /> + } else { + return <div class="external_warning"> + <div>Click here to load player from <a href="https://bandcamp.com">https://bandcamp.com</a></div> + <button onClick={() => setUserApproves(true)}> + Load player + </button> + </div> + } +}) + +namespace Music { + export type Props = { + title: Signal<string | undefined>, + } +} diff --git a/src/apps/source.ts b/src/apps/source.ts new file mode 100644 index 0000000..66e8f06 --- /dev/null +++ b/src/apps/source.ts @@ -0,0 +1,33 @@ +import { JSX } from "preact/jsx-runtime" + +export interface Source { + type: string, +} + +export interface Listen extends Source { + type: "listen" +} + +export interface Address extends Source { + type: "video" | "image" | "link", + link: URL, + pixelated?: boolean +} + +export interface VirtualFile extends Source { + name: string, + height?: string | number, + width?: string | number +} + +export interface VirtualTextFile extends VirtualFile { + type: "text", + content: JSX.Element +} + +export type Sources = Address | VirtualTextFile | Listen +export type SourceTypes = Sources["type"] +export type GetSource = { + [SourceType in Sources as SourceType["type"]]: SourceType +} + diff --git a/src/apps/video.tsx b/src/apps/video.tsx new file mode 100644 index 0000000..4b556af --- /dev/null +++ b/src/apps/video.tsx @@ -0,0 +1,61 @@ +import { effect, Signal, useSignalEffect } from "@preact/signals" +import { Address } from "$/apps/source"; +import { memo, Ref, useRef } from "preact/compat"; + +const filenameRegex = /.*\/(.*)$/; + +export const Video = memo(function Video({ source, title, contentReady, sizingFinished }: Video.Props) { + useSignalEffect(() => { + const filename = decodeURIComponent(source.value.link.pathname.match(filenameRegex)?.[1] ?? source.value.link.toString()); + title.value = `${filename} - media player`; + }) + + const videoRef: Ref<HTMLVideoElement> = useRef(null); + + return <video controls src={source.value.link.toString()} ref={video => { + videoRef.current = video + const id = setInterval(() => { + if (video && video.videoWidth) { + clearInterval(id) + contentReady.value = true + const vw = Math.max(document.documentElement.clientWidth, window.innerWidth) + const vh = Math.max(document.documentElement.clientHeight, window.innerHeight) + + const maxImageSize = Math.min(vw, vh) * 0.4; + const smallestImageAxis = Math.min(video.videoHeight, video.videoWidth); + + const aspectRatio = video.videoWidth / video.videoHeight + if (smallestImageAxis > maxImageSize) { + if (video.videoWidth > video.videoHeight) { + video.style.height = `${maxImageSize / aspectRatio}px` + video.style.width = `${maxImageSize}px` + } else { + video.style.height = `${maxImageSize}px` + video.style.width = `${maxImageSize * aspectRatio}px` + } + + effect(() => { + if (sizingFinished.value) { + video!.style.setProperty("height", null) + video!.style.setProperty("width", null) + } + }) + } + } + }, 50) + + return () => { + clearInterval(id) + videoRef.current = null + } + }}/> +}) + +namespace Video { + export type Props = { + source: Signal<Address>, + title: Signal<string | undefined>, + contentReady: Signal<boolean>, + sizingFinished: Signal<boolean> + } +} diff --git a/src/desktop/apps/buttons.tsx b/src/desktop/apps/buttons.tsx deleted file mode 100644 index e69de29..0000000 --- a/src/desktop/apps/buttons.tsx +++ /dev/null diff --git a/src/desktop/apps/edit.tsx b/src/desktop/apps/edit.tsx deleted file mode 100644 index 00aba45..0000000 --- a/src/desktop/apps/edit.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { ComponentChildren } from "preact" - -export function Edit(props: Edit.Props) { - return <textarea autocomplete="off" spellcheck={false}> - {props.children} - </textarea> -} - -namespace Edit { - export type Props = { - children?: ComponentChildren - } -} diff --git a/src/desktop/apps/index.tsx b/src/desktop/apps/index.tsx deleted file mode 100644 index a2954b6..0000000 --- a/src/desktop/apps/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { FunctionalComponent } from "preact"; -import { Edit } from "./edit"; -import { Music } from "./music"; - -export const APP_REGISTRY: Map<string, FunctionalComponent> = new Map(); diff --git a/src/desktop/apps/music.tsx b/src/desktop/apps/music.tsx deleted file mode 100644 index b5646bd..0000000 --- a/src/desktop/apps/music.tsx +++ /dev/null @@ -1,43 +0,0 @@ -// type Song = { -// name: string, -// url: string, -// length: number, -// -// bc_exclusive: boolean -// } -// -// const SONGS: = [ -// [], -// [], -// [], -// [], -// [], -// [], -// [], -// [], -// [], -// [], -// [] -// ] -// -// function Song() { -// -// } - -import { useState } from "preact/hooks" - -export function Music() { - const [userApproves, setUserApproves] = useState(false); - - if (userApproves) { - return <iframe style="border: 0; width: 350px; height: 500px;" src="https://bandcamp.com/EmbeddedPlayer/album=3374731395/size=large/bgcol=000000/linkcol=ff0047/transparent=true/" seamless><a href="https://sunnexo.bandcamp.com/album/netpunk">Netpunk by Sunnexo</a></iframe> - } else { - return <div class="external_warning"> - <div>Click here to load player from <a href="https://bandcamp.com">https://bandcamp.com</a></div> - <button onClick={() => setUserApproves(true)}> - Load player - </button> - </div> - } -} - diff --git a/src/desktop/file.tsx b/src/desktop/file.tsx new file mode 100644 index 0000000..a3aa01b --- /dev/null +++ b/src/desktop/file.tsx @@ -0,0 +1,62 @@ +import { APP_REGISTRY } from "$/apps"; +import { Ref } from "preact"; +import { useCallback, useRef, useState } from "preact/hooks"; +import { getTaskId, taskManager } from "."; +import { signal } from "@preact/signals"; +import { SourceTypes, VirtualFile } from "$/apps/source"; + +export function File({ file }: { file: VirtualFile }) { + const [focused, setFocused] = useState(false); + const buttonRef: Ref<HTMLButtonElement> = useRef(null); + + const handleBlur = useCallback((e: PointerEvent) => { + if (!buttonRef.current!.contains(e.target! as Node)) { + setFocused(false); + document.removeEventListener("click", handleBlur) + } + }, []) + + const handleFocus = useCallback(() => { + setFocused(true); + + document.addEventListener("click", handleBlur) + }, []) + + const handleDoubleClick = useCallback(() => { + let app = APP_REGISTRY[file.type as SourceTypes]; + if (app === undefined) { + console.error(`couldn't find app for file of type "${file.type}"`) + return + } + + setFocused(false); + taskManager.add({ + app, + source: signal(file), + window: { + minimized: signal(false), + maximized: signal(false), + height: file.height, + width: file.width, + z: signal(taskManager.tasks.value.value.length) + }, + title: signal(undefined), + id: getTaskId(), + contentReady: signal(false), + sizingFinished: signal(false) + }) + }, []) + + return <button ref={buttonRef} class={focused ? "focused" : undefined} onClick={handleFocus} onDblClick={handleDoubleClick}> + <div class="icon"> + <img src={`/assets/${file.type}.png`}/> + </div> + {/* for some reason firefox can select the name div if i don't add tabindex=-1 + * as a side effect of doing that, click events don't go to the button + * See: + * https://html.spec.whatwg.org/multipage/interaction.html#click-focusable + * https://html.spec.whatwg.org/multipage/interaction.html#attr-tabindex + */} + <div class="name" tabindex={-1} onClick={handleFocus}><div>{file.name}</div></div> + </button> +} diff --git a/src/desktop/files/about.tsx b/src/desktop/files/about.tsx new file mode 100644 index 0000000..8c4ffc8 --- /dev/null +++ b/src/desktop/files/about.tsx @@ -0,0 +1,33 @@ +import { VirtualTextFile } from "$/apps/source"; + +export default { + name: "ABOUT.txt", + type: "text", + content: <> +<h1>Netpunk</h1> + +<ol> + <h2> + Tracklist: + </h2> + <ol> + <li>Internet Exploder</li> + <li>Netpunk (feat. Kasane Teto)</li> + <li>Free Download</li> + <li>I am not a Robot</li> + <li>Content</li> + <li>Keygen</li> + <li>Base64</li> + <li>Readme (feat. Kasane Teto)</li> + <li>Symlink</li> + <li>Memory</li> + </ol> +</ol> + +<p>An album about borrowing software forever.</p> + +<p>A clumsy robot forgets about their girlfriend's anniversary, scrambling to find something to do for the date, eventually concluding they should watch a movie. Of course the movie is in high demand, so the tickets have sold out. Ultimately they resort to piracy, and things spiral out of control...</p> +</>, + height: "25em", + width: "50ch" +} as VirtualTextFile; diff --git a/src/desktop/files/base64.tsx b/src/desktop/files/base64.tsx new file mode 100644 index 0000000..f6acaf7 --- /dev/null +++ b/src/desktop/files/base64.tsx @@ -0,0 +1,31 @@ +import { VirtualTextFile } from "$/apps/source"; +import { Hr } from "../hr"; +import { Link } from "../link"; + +export default { + name: "Base64.txt", + type: "text", + content: ( + <> + <h1>Base64</h1> +<h3>WW91IGZvdW5kIGEgc2VjcmV0IQ==</h3> +<Hr/> +<p><Link link="https://en.wikipedia.org/wiki/Base64" type="link">Base64</Link> is a binary-to-text encoding that uses 64 characters to represent sequences of byte values. It’s used for many things such as web pages, e-mail attachments or QR codes, but is also used to obfuscate links that a bot or scraper shouldn’t see (to avoid getting your website blocked).</p> +<p>In this song I interpreted it as a singer using a foreign, encoded language we can’t understand, but still enjoy.</p> +<p>For the vocal I used a free plugin called <Link link="https://www.a-quest.com/products/aquestone_en.html" type="link">Aquestone</Link>, which is like a precursor to Vocaloid. Similar principle though, you fill in some text and it’ll sing each syllable every note you play. The words I filled in are “netpunkalbum”, but the accent makes it unrecognizable.</p> +<Link type="image" link="https://sunnexo.moe/music/netpunk/aquestone.png" /> +<Hr/> +<p>I really love the sound of this synth and I plan on doing more with it, despite it being extremely old and buggy. Here’s a video from 17 years ago showcasing it in more detail!</p> +<Link link="https://www.youtube.com/embed/KIHT4P1W35o" type="link" block /> +<Hr/> +<p>Also need to mention Base64 is heavily inspired by <Link link="https://www.youtube.com/embed/95RwI-JmRoI" type="link">“Can You Feel” by Torchkas</Link>. I first heard it in a Super Mario World romhack called VLDC, known as <Link link="https://www.youtube.com/embed/dPGB3TqIHVI" type="link">Abstract Map</Link> there.</p> +<Link link="https://www.youtube.com/embed/dPGB3TqIHVI" type="link" block /> +<Hr/> +<h2>Graphics</h2> +<p>A lot of the background visuals are from the <Link link="https://butterchurnviz.com/" type="link">Butter Churn visualizer</Link>, it’s based on the Milkdrop 2 visualizer from Winamp. <Link link="https://github.com/jberg/butterchurn" type="link" external>Open source as well!</Link></p> + + </> + ), + height: "70vh", + width: "70vw", +} as VirtualTextFile; diff --git a/src/desktop/files/content.tsx b/src/desktop/files/content.tsx new file mode 100644 index 0000000..693be41 --- /dev/null +++ b/src/desktop/files/content.tsx @@ -0,0 +1,34 @@ +import { VirtualTextFile } from "$/apps/source"; +import { Hr } from "../hr"; +import { Link } from "../link"; + +export default { + name: "Content.txt", + type: "text", + content: ( + <> + <h1>Content</h1> +<h3>Description</h3> +<p>This track is what it feels like to doomscroll until your brain turns into slime. Considering I might be consuming too much content, it’s ironic I made more.</p> +<Hr/> +<h2>Sound design</h2> +<p>I’ve always sampled other songs in my music, but really wanted to challenge myself with this one. This song is created <strong>entirely</strong> using snippets from other music, except the drums. In the end I think I sampled around 30 tracks to make this. Heavily inspired by <Link link="https://nitter.net/notzeroth/status/1911808244407730629" type="link">zeroth’s sample away the world.</Link></p> +<blockquote> + <p>sample anything and everything in life <Link link="https://nitter.net/pic/video.twimg.com%2Famplify_video%2F1911808027612585985%2Fvid%2Favc1%2F720x720%2FwUMfwq3x1GAO_v9m.mp4?tag=14" type="link">pic.twitter.com/cf9bGzIPFs</Link></p> +</blockquote> +<p class="credit">zeroth (@notzeroth) <Link link="https://nitter.net/notzeroth/status/1911808244407730629" type="link">April 14, 2025</Link></p> +<Hr/> +<h2>Graphics</h2> +<p>For the music video I simply recorded my screen while scrolling Bluesky and Pinterest, and occasionally layered the two together with random blend modes.</p> +<p>While uploading the video to YouTube I ran into an extremely infuriating issue, the video was considered a “Short” because it has a square aspect ratio and under 3 minutes long. So I had to export the video TWO pixels wider than usual to make it stop detecting as a short. The resolution ended up becoming 2162 x 2160 px. Cursed.</p> +<Link type="image" link="https://sunnexo.moe/music/netpunk/content but two pixels wider.png" /> +<p>“AAA” is the test file that confirmed adding two pixels to the width of the video actually prevents it from turning into a short. Thanks YouTube, great feature not having the option to say whether a video should be put in front of people with zero attention span.</p> +<Link type="image" link="https://sunnexo.moe/music/netpunk/cat named finger.gif" /> +<p>WRAHHGHGHHHGHGHGH HGHGHHHHH</p> +<p>Okay rant over, it took a little longer than I’d hoped re-rendering that but it’s all good now!!</p> + + </> + ), + height: "70vh", + width: "70vw", +} as VirtualTextFile; diff --git a/src/desktop/files/freeDownload.tsx b/src/desktop/files/freeDownload.tsx new file mode 100644 index 0000000..491c2fc --- /dev/null +++ b/src/desktop/files/freeDownload.tsx @@ -0,0 +1,30 @@ +import { VirtualTextFile } from "$/apps/source"; +import { Hr } from "../hr"; +import { Link } from "../link"; + +export default { + name: "Free Download.txt", + type: "text", + content: ( + <> + <h1>Free Download</h1> + <h3>100% working no virus</h3> + <Hr /> + <p> + This track is a reference to infamous YouTube tutorials from the 2010s about topics like how to get Minecraft for free. One of the most popular music tracks was{" "} + <Link link="https://youtu.be/TKfS5zVfGBc" type="link"> + 009 Sound System Dreamscape + </Link> + . + </p> + <Link link="https://www.youtube.com/embed/TKfS5zVfGBc" type="link" block /> + <p>I still love this track and listen to it occasionally.</p> + <p>A little while ago I found out about a genre called hypertrance. It’s a movement that expands on the origins of trance music, and I immediately fell in love with it. I wanted to try making the genre myself by combining Dreamscape with hypertrance. It was a lot of fun trying to make that overly grand, almost cheesy futuristic vibe the music portrayed.</p> + <Link link="https://www.youtube.com/embed/3sCu-zjVa5c" type="link" block /> + <Hr /> + <p>I was around 9 years old when I got my first laptop, and I remember being a little idiot trying to download Nintendo games for free, stuff like “free eShop gift card generator” and getting my whole computer infected with every kind of malware ever. Still funny to think about 13 years later!</p> + </> + ), + height: "70vh", + width: "70vw", +} as VirtualTextFile; diff --git a/src/desktop/files/iAmNotARobot.tsx b/src/desktop/files/iAmNotARobot.tsx new file mode 100644 index 0000000..05d23f0 --- /dev/null +++ b/src/desktop/files/iAmNotARobot.tsx @@ -0,0 +1,28 @@ +import { VirtualTextFile } from "$/apps/source"; +import { Hr } from "../hr"; +import { Link } from "../link"; + +export default { + name: "I am not a Robot.txt", + type: "text", + content: ( + <> + <h1>I am not a Robot</h1> +<h3>Powered by reCAPTURE</h3> +<p>Many websites want to prevent bots from accessing their data, so they use countermeasures such as <Link link="https://en.wikipedia.org/wiki/CAPTCHA" type="link">CAPTCHAs</Link>. These typically include a piece of warbled text that you gotta copy, or a checkbox saying “I am not a bot”.</p> +<p>This track recontextualizes the phrase when it comes to robots and their journey to get closer to human behavior. Are they aware of their true nature? Should they strive to become human? Does it even matter? I wonder how Sunnexo feels about this.</p> +<Hr/> +<h2>Sound design</h2> +<p>For this particular track I wanted to use some older sample packs like the ones by Vengeance Sound, but also even older ones such as the Zero-G CDs. They’re so cheesy but so much fun at the same time. I believe artists like Hideki Naganuma used the Zero-G ones back in the day, a notable example being Jet Set Radio’s soundtrack. Nowadays these packs are available <Link link="https://archive.org/details/zero-gee-sample-pack_202302" type="link">on the Internet Archive</Link>.</p> +<Link link="https://www.youtube.com/embed/J1AAngh2vAw" type="link" block /> <Link link="https://www.youtube.com/embed/qNSQs0zuVuU" type="link" block /> +<p>I also used a bunch of resampling techniques in the track, such as the glitchy piano. I made a few random chords and chopped them up using a slicer to rearrange the sequence into something stuttery. Resampling is a recurring theme in this album, I love doing it!</p> +<p>Another thing I’d like to mention is this synth called <Link link="https://fors.fm/junior" type="link">Junior by Fors</Link>, it’s a recreation of the Game Boy’s wavetable as a modern VST plugin. I used it to create some of the old school growls and basses and love the way it sounds!</p> +<Link link="https://www.youtube.com/embed/BcYZKgz33n8" type="link" block /> +<p>It’s DRM free and cheap, unheard of nowadays when it comes to VSTs. And the demo video goes extremely hard…</p> +<Hr/> +<p>The music video contains background footage taken from a game called <Link link="https://neal.fun/not-a-robot/" type="link">I’m not a robot</Link>, created by Neal.fun! I highly recommend checking it out, it runs in the browser.</p> + </> + ), + height: "70vh", + width: "70vw", +} as VirtualTextFile; diff --git a/src/desktop/files/index.ts b/src/desktop/files/index.ts new file mode 100644 index 0000000..11dbd32 --- /dev/null +++ b/src/desktop/files/index.ts @@ -0,0 +1,34 @@ +import netpunk from "./netpunk.tsx"; +import listen from "./listen"; +import { VirtualFile } from "$/apps/source"; +import readmeLyrics from "./readmeLyrics"; +import netpunkLyrics from "./netpunkLyrics"; +import about from "./about"; +import freeDownload from "./freeDownload.tsx"; +import internetExploder from "./internetExploder.tsx"; +import readme from "./readme.tsx"; +import iAmNotARobot from "./iAmNotARobot.tsx"; +import content from "./content.tsx"; +import keygen from "./keygen.tsx"; +import base64 from "./base64.tsx"; +import symlink from "./symlink.tsx"; +import memory from "./memory.tsx"; +import robotYuri from "./robotYuri.tsx"; +import netpunkShred from "./netpunkShred.tsx"; + +export const FILE_REGISTRY: VirtualFile[] = [ + about, + listen, + internetExploder, + netpunk, + freeDownload, + iAmNotARobot, + content, + keygen, + base64, + readme, + symlink, + memory, + robotYuri, + netpunkShred +] diff --git a/src/desktop/files/internetExploder.tsx b/src/desktop/files/internetExploder.tsx new file mode 100644 index 0000000..8f488c3 --- /dev/null +++ b/src/desktop/files/internetExploder.tsx @@ -0,0 +1,54 @@ +import { VirtualTextFile } from "$/apps/source"; +import { Hr } from "../hr"; +import { Link } from "../link"; + +export default { + name: "Internet Exploder.txt", + type: "text", + content: ( + <> +<h1>Free Download</h1> +<h3>100% working no virus</h3> +<Hr /> +<h2>Sound design</h2> +<p>I remember watching <Link link="https://www.youtube.com/embed/U4FNBMZsqrY" type="link">a GDC talk by Mick Gordon</Link> about his workflow when making the music for <Link link="https://en.wikipedia.org/wiki/Doom_(2016_video_game)" type="link">DOOM (2016)</Link>. Mick showed how he started the sound design chain with a sine wave, because it is “the most pure representation of what sound can be”. He then started experimenting by putting different guitar pedals in series and created feedback loops.</p> +<Link type="image" link="https://sunnexo.moe/music/internet-exploder/mick-gordon-chain.png" /> +<p>It sounds pretty awesome and unlike anything I had heard prior to playing the game, and I liked it so much I tried creating a similar bass sound in <Link link="https://kilohearts.com/products/phase_plant" type="link">Phase Plant</Link>. Also starting with a sine and adding different effects on top of it just to see what would happen. The end result is very different but still cool in its own way.</p> +<p>This is what the patch sounds like with and without effects:</p> +<Link type="audio" link="https://sunnexo.moe/music/internet-exploder/bass-no-fx.ogg" /> +<Link type="audio" link="https://sunnexo.moe/music/internet-exploder/bass-with-fx.ogg" /> +<Link type="image" link="https://sunnexo.moe/music/internet-exploder/phase-plant-mick-gordon-impression.webp" /> +<p>I started with a simple sine and wavetable, and added a big chain of distortion after it. It’s a pretty huge difference before and after. One thing I really like about this experiment is the Multipass instance called “ring air convolver”, which is just a convolution reverb that only affects the high end. The impulse response it’s using is just the tail of a random dubstep snare I found and it works surprisingly well to add top end to the bass. I also have a frequency shifter at the end of the chain that gets automated later on in the track.</p> +<Link type="image" link="https://sunnexo.moe/music/internet-exploder/ring-air.webp" /> +<Hr/> +<h2>Rhythm</h2> +<p>I kept getting samey results in my own music, and wanted to make something a little more daring and different, so I ended up taking inspiration from the way Mick tackled music production as well. He discussed how changing your process is necessary if you want to change the outcome, and that’s exactly what I did!</p> +<p>This time, I paid a lot of attention to the rhythmic pattern. I started by making an irregular rhythm, where some notes are offset by a 16th note so they hit just after the spot you expect them to be in, as if they’re lagging behind and catching up. I don’t usually make rhythms this way, and thought it would be interesting to try making an entire song around it.</p> +<Link type="image" link="https://sunnexo.moe/music/internet-exploder/internet-exploder-rhythm.webp" /> <Link type="audio" link="https://sunnexo.moe/music/internet-exploder/rhythm.ogg" /> +<p>As of writing this I’m in art university studying music and technology, and I’ve learned a bit about sheet music. Now I actually know how to transcribe simple rhythms, so I did this one for fun!</p> +<Link link="https://www.youtube.com/embed/iW9F3rifPzY"type="link" block /> +<Hr/> +<h2>Artwork</h2> +<p>For this cover art I worked together with <Link link="https://x.com/Rokushini" type="link">Rokushini</Link> and <Link link="https://code.textmode.art/" type="link">humanbydefinition</Link>!</p> +<p>Rokushini is an illustrator, character designer and 3D artist, and I’ve always wondered what their artstyle would look like in pixel art. So I reached out and we decided to make something inspired by <Link link="https://en.wikipedia.org/wiki/Flipnote_Studio" type="link">Flipnote Studio</Link>. It’s characterized by messy pixelated brush strokes, limited colors and dithering effects. They had not done pixel art prior to this project so it was a little challenging to get started, but the end result is almost exactly as I had hoped!</p> +<Link type="image" link="https://sunnexo.moe/music/internet-exploder/INTEXPL_Blink_Loop.gif" pixelated /> <Link type="image" link="https://sunnexo.moe/music/internet-exploder/figma-board.webp" /> +<Hr/> +<h2>Textmode</h2> +<p>Humanbydefinition is the creator of <Link link="https://code.textmode.art/" type="link">textmode.js</Link>, a creative coding library designed to make textmode graphics using JavaScript. At first I reached out to them because I had some issues with my code for an entirely unrelated project. Eventually we figured it out and I started experimenting more with the textmode.js library. Humanbydefinition actually went and made some tools for me that interpret video footage as textmode tiles, which I’ve used extensively throughout the music video. Huge thanks to them for the effort, this video wouldn’t have looked as cool without the tools.</p> +<blockquote> + <p>my contribution was mostly about tooling rather than direct creative decisions. i onboarded sunnexo to textmode.js and put together a few basic sketches to convert video frames into textmode representations. to make the visuals work with the video’s palette and overall vibe, i added a small shader that remaps the source image colors to a limited palette by picking the closest match per pixel.</p> + <p>after that it was mostly just testing, tweaking, and making sure nothing got in the way. working with sunnexo was genuinely pleasant, especially because everything felt open and uncomplicated. (っ◔。◔)♪♬</p> +</blockquote> +<p class="credit">Written by @humanbydefinition</p> +<p>I used a bunch of random footage for the background, such as <Link link="https://paveldogreat.github.io/WebGL-Fluid-Simulation/" type="link">fluid simulations</Link>, Garry’s Mod gameplay and recordings I made using my camera.</p> +<Link type="image" link="https://sunnexo.moe/music/internet-exploder/fluid.webp" /> +<Hr/> +<h2>Typography</h2> +<p>Inspired by the art from <Link link="https://www.desktopgeneration.com/" type="link">desktopGeneration</Link> and <Link link="https://polyducks.co.uk/pages/what-is-textmode/" type="link">textmode</Link> I made my logotype using a font called <Link link="https://blog.glyphdrawing.club/moebiusxbin-ascii-and-text-mode-art-editor-with-custom-font-support/" type="link">TES-GIGR (created by hlotvonen)</Link>. I used a program called <Link link="https://lvllvl.com/" type="link">lvllvl</Link> to assemble the typography. I started by copying another logo I saw called “angel” so I could understand the way the original creator used the tiles. From there I started iterating on my own logo. It was a painstakingly long task to find each tile to place in the right spots, but I think the end result is worth it.</p> +<Link type="image" link="https://sunnexo.moe/music/internet-exploder/lvllvl.png" /> <Link type="image" link="https://sunnexo.moe/music/internet-exploder/logotype.png" /> + + </> + ), + height: "70vh", + width: "70vw", +} as VirtualTextFile; diff --git a/src/desktop/files/keygen.tsx b/src/desktop/files/keygen.tsx new file mode 100644 index 0000000..b922075 --- /dev/null +++ b/src/desktop/files/keygen.tsx @@ -0,0 +1,25 @@ +import { VirtualTextFile } from "$/apps/source"; +import { Hr } from "../hr"; +import { Link } from "../link"; + +export default { + name: "Keygen.txt", + type: "text", + content: + <> + <h1>Keygen</h1> +<h3>Fine release by Sunnexo 2026</h3> +<Hr /> +<p>A key generator, commonly shortened as <Link link="https://en.wikipedia.org/wiki/Keygen" type="link">keygen</Link>, is a tool that generates a license key or serial number to unlock software. They can be made by legitimate distributors, but many of them are also made by software cracking groups. These programs often play tracker music which include genres like dubstep, drum and bass, trance and more.</p> +<p>I really enjoy keygen music and wanted to make some myself too! For the album I wanted at least one track made on my Game Boy (using <Link link="https://www.littlesounddj.com/lsd/index.php" type="link">Little Sound DJ</Link>).</p> +<Hr /> +<h2>BOOTLOOP version</h2> +<p>During the production of this track I also participated in the <Link link="https://itch.io/jam/gmtk-2025" type="link">GMTK Game Jam (2025)</Link>, which is an annual event where game developers around the world try to make a video game within 4 days. The theme we settled on was a small robot fighting computer viruses in its own circuits.</p> +<p>I was the sound designer and composer for my team. We were on a tight time limit and I wanted to get a song out as quickly as possible, so I looked through my work in progress folder and decided the Keygen track fits the theme the best. I remixed the melody, added a new bassline and some modern drums, which worked out pretty well!</p> +<p>We also ended up getting into <Link link="https://youtube.com/embed/kG3LWpuiLqg?start=1085" type="link">the video on the GMTK channel!</Link> Skip to 18:05 to see the game.</p> +<Link link="https://www.youtube.com/embed/kG3LWpuiLqg" type="link" block /> +<p>Check out <Link link="https://sunnexo.moe/bootloop" type="link">BOOTLOOP</Link>.</p> + </>, + height: "70vh", + width: "70vw", +} as VirtualTextFile; diff --git a/src/desktop/files/listen.ts b/src/desktop/files/listen.ts new file mode 100644 index 0000000..59fdd8b --- /dev/null +++ b/src/desktop/files/listen.ts @@ -0,0 +1,9 @@ +import { VirtualFile } from "$/apps/source"; + +export default { + name: "Stream on Bandcamp", + type: "link", + link: new URL("https://bandcamp.com/EmbeddedPlayer/album=3374731395/size=large/bgcol=000000/linkcol=ff0047/transparent=true/"), + height: "70vh", + width: "25vw" +} as VirtualFile diff --git a/src/desktop/files/memory.tsx b/src/desktop/files/memory.tsx new file mode 100644 index 0000000..cdcbfa3 --- /dev/null +++ b/src/desktop/files/memory.tsx @@ -0,0 +1,19 @@ +import { VirtualTextFile } from "$/apps/source"; +import { Hr } from "../hr"; +import { Link } from "../link"; + +export default { + name: "Memory.txt", + type: "text", + content: ( + <> + <h1>Memory</h1> +<h3>Reconstructing human music after the singularity</h3> +<p>I have been learning guitar for a year now and finally felt comfortable enough with my skill level to make a full song using it! Heavily inspired by the music from <Link link="https://www.youtube.com/playlist?list=OLAK5uy_mNWkxcU6VC_aWOFnpqYha-J5UMzwbVlx4" type="link">kessoku band</Link> and <Link link="https://youtu.be/XLwmEuM0dIw" type="link">Sunfaded</Link>.</p> +<p>Memory is expensive nowadays, so cherish it while you can.</p> + + </> + ), + height: "70vh", + width: "70vw", +} as VirtualTextFile; diff --git a/src/desktop/files/netpunk.tsx b/src/desktop/files/netpunk.tsx new file mode 100644 index 0000000..7645495 --- /dev/null +++ b/src/desktop/files/netpunk.tsx @@ -0,0 +1,127 @@ +import { VirtualTextFile } from "$/apps/source"; +import { Hr } from "../hr"; +import { Link } from "../link"; + +export default { + name: "Netpunk.txt", + type: "text", + content: ( + <> + <h2>Netpunk (feat. Kasane Teto)</h2> + <p>I think I started working on this about 2-3 years ago? It’s been so long I don’t remember! The oldest file on my PC with “netpunk” in the name is from 2024, so it must’ve been something like that. It was around the time I got my first electric guitar, a Yamaha Pacifica 112J in light blue. I’ve been practicing guitar ever since and it’s really hard, but a lot of fun as well. My fingers sure have grown extra layers of skin from all that playing.</p> + <Link type="image" link="https://sunnexo.moe/ui/toolbox/hardware/pacifica.webp" alt="" /> + <p> + This is also the first time I’ve ever used a vocal synth! I love Teto so much… I’m kinda terrible at writing lyrics so I’m really happy I got help from retraceyoursteps! She perfectly understood the type of girlfailure energy I wanted to write about. Read the lyrics below! + </p> + <p>A clumsy robot forgets about their girlfriend’s anniversary, scrambling to find something to do for the date, eventually concluding they should watch a movie. Of course the movie is in high demand, so the tickets have sold out. Ultimately they resort to piracy, and things spiral out of control…</p> + <Hr /> + <h2>Graphics</h2> + <p>This was originally going to get a bigger music video, but the scope was a little bigger than I could handle on my own. Stellazium and I came up with a big storyboard, but unfortunately she got occupied with other work. I actually learned Blender and made a ton of assets using it, but they didn’t end up getting used this time. Maybe they’ll be useful for future projects though!</p> + <p>Eventually I decided to reduce the scope of the video and make it more manageable for one person. I think it still ended up pretty nice, despite cutting out a bunch of our ideas.</p> + <Link type="image" link="https://sunnexo.moe/music/netpunk/scene-1.png" alt="" /> + <Link type="image" link="https://sunnexo.moe/music/netpunk/storyboard.webp" alt="" /> + <Link type="image" link="https://sunnexo.moe/music/netpunk/props.webp" alt="" /> + <Link type="image" link="https://sunnexo.moe/music/netpunk/tree.webp" alt="" /> + <Link type="video" link="https://sunnexo.moe/music/netpunk/geonodes.mp4" /> + <Hr /> + <h2>Character design</h2> + <p>We figured out the song’s narrative, and I had to come up with a character design that reflected the clumsy idiotic nature of the protagonist. I brainstormed a bit with Avolicis and our initial iteration looked like this:</p> + <Link type="image" link="https://sunnexo.moe/music/netpunk/character-1.png" alt="" /> + <p>I tried modeling this in Blender. Blender however, is way more difficult than it seems. Getting the mesh right was quite a challenge already, but then getting the rig working was even worse! To animate a 3D model it needs to have a skeleton (also called a rig). That skeleton will then deform the mesh when you move the bones around. This was far more work than I first thought, as I kept running into issues like the mesh not deforming correctly.</p> + <Link type="image" link="https://sunnexo.moe/music/netpunk/character-2.png" alt="" /> + <p>The original design had a skirt but I had to ditch it because it wouldn’t bend the way I expected. My friend Niko has a lot of experience with Blender so he was able to help me a lot with the technical stuff. Thanks to him I gained a ton of knowledge about the type of things that are easy or hard to do in Blender, and adjusted the 2nd iteration of the character accordingly.</p> + <p>Ghosttra.in helped me a lot with the model revision, giving me feedback on the proportions, hairstyle and outfit design. This time we went for a crop top and bodysuit, along with shorter hair that’s easier to animate.</p> + <p> + I also used{" "} + <Link link="https://keyframechef.gumroad.com/l/StylizedFemaleBasemesh" type="link">a base mesh by KeyframeChef</Link>{" "} + to make sure I would get the mesh deformation right. Using that as a starting point I sculpted the model more towards my reference. I recommend watching{" "} + <Link link="https://youtu.be/T0uzPeADuqg" type="link">their video about low poly character creation!</Link> + </p> + <p>The character looks way better than before, and it’s easier to animate as well. I’m very happy with the way this model turned out!</p> + <Link type="image" link="https://sunnexo.moe/music/netpunk/sunnexo netpunk character ref.png" alt="" /> + <Link type="image" link="https://sunnexo.moe/music/netpunk/shader nodes.png" alt="" /> + <Link type="image" link="https://sunnexo.moe/music/netpunk/good frame.png" alt="" /> + <Hr /> + <h2>Textmode</h2> + <p> + Similar to{" "} + <Link link="https://youtube.com/embed/1pG48TT3Kqo" type="link">Internet Exploder’s music video</Link> + , I got help from humanbydefinition for the textmode graphics. They made{" "} + <Link link="https://code.textmode.art/" type="link">a tool</Link>{" "} + that lets me interpret videos as a grid of text symbols, also known as textmode art. I used this tool for the backgrounds in the music video, by feeding it footage from my camcorder, glitchy scenes I made in Blender and more. + </p> + <Link type="image" link="https://sunnexo.moe/music/netpunk/textmode.gif" alt="" /> + <Hr /> + <h2>Netpunk papercraft</h2> + <p>To celebrate the release of my debut album I’ve made something silly… This may seem random, but I’ve always wanted to design a papercraft figure from scratch. And now that I’ve learned Blender I was finally able to do it!</p> + <p> + <strong>Big announcement:</strong> You can build one yourself too,{" "} + <Link link="https://sunnexo.moe/papercraft" type="link">get the template here!!</Link> + </p> + <Link type="image" link="https://sunnexo.moe/music/netpunk/papercraft-3.jpeg" /> + <Link type="image" link="https://sunnexo.moe/music/netpunk/papercraft template preview.png" alt="" /> + <Hr /> + <p> + Lyrics written by Sunnexo and retraceyoursteps, performed by{" "} + <Link link="https://kasaneteto.jp/" type="link"> + Kasane Teto SV + </Link> + </p> +<p>I just woke up</p> +<p>My head in the clouds</p> +<p>I've got one shot</p> +<p>To figure it out</p> +<p>But there's one problem</p> +<p>The tickets sold out</p> +<p>What the hell am I gonna do? (Oh god)</p> +<br/> +<p>I'm running on an empty charge</p> +<p>You know my heart is still at large</p> +<p>I can't believe I'm such a mess</p> +<p>My head is yearning for more rest</p> +<p>An anniversary forgot</p> +<p>Because I stayed in bed to rot</p> +<p>My mind is about to blow up</p> +<p>My time is running out</p> +<br/> +<p>(♫)</p> +<br/> +<p>Now I'm searching the internet</p> +<p>Gotta see if they have it yet</p> +<p>It's a new film, no resales found</p> +<p>Need to figure something out</p> +<p><br/></p> +<p>So I'm trying to pirate it</p> +<p>But this website looks kinda shit</p> +<p>Now out of options, I've got no choice</p> +<p>But to take this chance</p> +<p><br/></p> +<p>My antivirus uninstalled </p> +<p>I sense my data all dissolved</p> +<p>I can't believe I screwed this up</p> +<p>Can barely even make a sound</p> +<p>My head is feeling all abuzz</p> +<p>Because my code is all for naught</p> +<p>My mind is about to blow up</p> +<p>My time is rŗ̶̢̧͉̤̼̮͚͍̳̯̪͔̥̐̈́̄͒̌͗͆́̉́́͝ư̵̧͉͐̔̾̀͋̀̓̔̓͂͒̕͠n̷͚̬͉̦͒̐̈̈̌͌͂̉̑̃͛̒͝͠͝n̷̡̠͈̲̪͙̤͈͔̭͔̞͖̻͖̑̍́̍̒̉́̑̊̊̓͌̈̚ͅǐ̷͓n̵̢̙̉͐̆͑̈́͆͗̏̅͗̇̒̐ǵ̸͎̻̤̬͗̅̃̈̀̃̾̓ ̵̻̟̖̦̠̥̮̄͂̈́͛̉̎̌̽̒̕͘ő̵̞̯̱̼̼̭̲̘̳͑͛̆̅u̷̢͍̮̤̖̼̼̽̀́͒̆̂̃̒́̏̂̓͝͝͠͝t̶̨͇̮͓̦̯̞͍̬̮̯̞̼͖̊͌̈́̇̂̒̈́͋̚</p> +<br/> +<p>(̵̻̱̣́♫̷̦̈́̍͝)̸̺̓͑</p> +<br/> +<p>Alright I've got an idea</p> +<p>Think I know what I gotta do (oh yeah)</p> +<br/> +<p>I'm thinking of a better plan</p> +<p>I know the time I have at hand</p> +<p>I may have hit my head real hard</p> +<p>That might've made me really smart</p> +<p>So tickets for the local fair</p> +<p>A dinner date with time to spare</p> +<p>Some scented candles and the rest</p> +<p>Today will be the best</p> +<br /> +<p>(♫)</p> + </> + ), + height: "70vh", + width: "70vw", +} as VirtualTextFile; diff --git a/src/desktop/files/netpunkShred.tsx b/src/desktop/files/netpunkShred.tsx new file mode 100644 index 0000000..d943180 --- /dev/null +++ b/src/desktop/files/netpunkShred.tsx @@ -0,0 +1,24 @@ +import { VirtualTextFile } from "$/apps/source"; +import { Hr } from "../hr"; +import { Link } from "../link"; + +export default { + name: "Netpunk (shred version).txt", + type: "text", + content: ( + <> + <h2>Netpunk (shred version feat. death do us apart)</h2> +<Hr/> +<p>For art university I worked together with an artist called <Link type="link" link="https://deathdousapart.rip">death do us apart</Link>! We did a guitar centric live performance using Netpunk. It was a ton of fun and we thought the guitar solo alone warranted a spot on the album.</p> +<blockquote> + <p>When I was asked to play guitar on Netpunk I wasn't quite sure how to approach it. I started by just playing the lead melody interspersed with the power chords already present in the song. When the second chorus came I thought about writing a solo for it and practiced some improvisation, but was never able to make a pre written one. This performance was also my first time playing live so my improvisation ended up being an adrenaline fuelled solo, which turned out unexpectedly good.</p> + <p>Not being able to reproduce it for the recording, I had to transcribe the solo by hand in order to figure out what I was actually doing. In the end I just played the solo exactly how it was live, but slower and sped it back up for the final track.</p> +</blockquote> +<p class="credit">Written by death do us apart</p> +<p>There’s a clip of the performance below, she’s cracked</p> +<Link type="video" link="https://sunnexo.moe/music/netpunk/netpunk-shred-live.mp4"/> + </> + ), + height: "70vh", + width: "70vw", +} as VirtualTextFile; diff --git a/src/desktop/files/readme.tsx b/src/desktop/files/readme.tsx new file mode 100644 index 0000000..2ef67bd --- /dev/null +++ b/src/desktop/files/readme.tsx @@ -0,0 +1,67 @@ +import { VirtualTextFile } from "$/apps/source"; +import { Hr } from "../hr"; +import { Link } from "../link"; + +export default { + name: "Readme.txt", + type: "text", + content: ( + <> + <h1>Readme</h1> + <h3>Save me, don't replace me</h3> + <Hr /> + <p> + A{" "} + <Link link="https://en.wikipedia.org/wiki/README" type="link"> + README + </Link>{" "} + is a text document that contains information about the content of the directory it’s located in. It typically contains instructions on how to use or install a program. If you ever need to know what a program does, the readme file shall explain. + </p> + {/* prettier-ignore */} + <pre><code> ░██{"\n"} +{" "}░██{"\n"} +░██░████ ░███████ ░██████ ░████████ ░█████████████ ░███████{"\n"} +░███ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██ ░██{"\n"} +░██ ░█████████ ░███████ ░██ ░██ ░██ ░██ ░██ ░█████████{"\n"} +░██ ░██ ░██ ░██ ░██ ░███ ░██ ░██ ░██ ░██{"\n"} +░██ ░███████ ░█████░██ ░█████░██ ░██ ░██ ░██ ░███████{"\n"} +</code></pre> + <Hr /> + <p> + Lyrics written by Sunnexo and retraceyoursteps, performed by{" "} + <Link link="https://kasaneteto.jp/" type="link"> + Kasane Teto SV + </Link> + </p> + <p>Readme</p> + <p>Save me</p> + <p>Don't replace me</p> + <p>Open secret</p> + <p>Can you keep it</p> + <p>And yet I</p> + <p>Can just</p> + <p>Always</p> + <p>Show you</p> + <p>My file system</p> + <p>Read and</p> + <p>List them</p> + <br /> + <p>(♫)</p> + <br /> + <p>Readme</p> + <p>Save me</p> + <p>Don't replace me</p> + <p>Open secret</p> + <p>Can you keep it</p> + <p>And yet I</p> + <p>Can just</p> + <p>Always</p> + <p>Show you</p> + <p>My file system</p> + <p>Read and</p> + <p>List them</p> + </> + ), + height: "70vh", + width: "70vw", +} as VirtualTextFile; diff --git a/src/desktop/files/regex.txt b/src/desktop/files/regex.txt new file mode 100644 index 0000000..c058f57 --- /dev/null +++ b/src/desktop/files/regex.txt @@ -0,0 +1,36 @@ + (target|style|class|data-svelte-h|controls|title|frameborder|allow|referrerpolicy|allowfullscreen)=".*?" + + ?<a id=".*"></a> + +(?<=img[^<]*?)> +/> + +hr> +hr/> + +(?<=(href|src)="/) +https://sunnexo.moe/ + +<hr ?/> +<Hr/> + +<a href=".*?">\n?\s+(<img\n?.*?\n?.*?\n?.*?/>)\n?\s+</a?> +$1 + +<a href=(".*?").*?>\n?\s*?(.*?)\n?\s*?</a> +<Link link=$1 type="link">$2</Link> + +<iframe src=(".*?")></iframe> +<Link link=$1 type="link" block /> + +<img src +<Link type="image" link + +<audio +<Link type="audio" + +.+ +<p>$0</p> + +^$ +<br/>
\ No newline at end of file diff --git a/src/desktop/files/robotYuri.tsx b/src/desktop/files/robotYuri.tsx new file mode 100644 index 0000000..9e32d25 --- /dev/null +++ b/src/desktop/files/robotYuri.tsx @@ -0,0 +1,47 @@ +import { VirtualTextFile } from "$/apps/source"; +import { Hr } from "../hr"; +import { Link } from "../link"; + +export default { + name: "Robot Yuri.txt", + type: "text", + content: ( + <> +<Link type="image" link="https://sunnexo.moe/music/internet-exploder/cooltext485447490947919.gif" /> + <h2>Robot Yuri (feat. Kasane Teto)</h2> +<Hr/> +<p>During the Europe heat wave of summer 2026 many of us suffered terrible temperatures of 37°C (= 98°F) without access to AC. I wasn’t able to use my PC for the usual work so I was stuck on my laptop with barely anything installed.</p> +<p>On the verge of heat stroke I ended up writing a silly cheerful song about a robot singing karaoke, yearning for love. I initially didn’t think about writing lyrics at all, but m1nt_ listened to the instrumental and thought it would be awesome if it did have vocals.</p> +<p>Huge thanks to m1nt_ for helping me with the lyrics for this, it was a fun project!</p> +<blockquote> + <p>When writing the lyrics for Robot Yuri, I really wanted to play into the heatwave thing. Getting hugged in the sweltering summer heat is sure to make a gay girl melt, especially if she’s a robot! I wanted to convey that she can’t think straight, not only because of the heat, but because she’s completely infatuated and maybe just a little obsessed with the girl she fell in love with.</p> + <p>The lyrics for the first and second halves of the song were written several weeks apart from each other. Initially, I actually wanted the second half to be bittersweet (even though I actually don’t like doomed yuri - I prefer silly, fluffy, happy yuri!!), but ultimately decided against it, because I felt that it didn’t fit the spirit of the song, and because I couldn’t have added that line about <Link link="https://en.wikipedia.org/wiki/Protection_ring" type="link">rings</Link>! Though, when I mentioned that to Sunnexo, they came up with the idea of the song being sung by the robot at karaoke, still yearning for love inside, which is an angle that I hadn’t considered before, but really like.</p> + <p>I’d like to thank Sunnexo for giving me the opportunity to write the lyrics for this. I’m really proud of this one and I think I did the sick instrumental justice!</p> +</blockquote> +<p class="credit">Written by m1nt_</p> +<Hr/> +<p>Lyrics written by Sunnexo and m1nt_, performed by <Link link="https://kasaneteto.jp/" type="link">Kasane Teto SV</Link></p> +<p>Temperature so high I think I'll overheat</p> +<p>I feel my circuits failing as you're holding me</p> +<p>Sights of you are filling up my memory</p> +<p>My scanners can't detect a being happier than me</p> +<br/> +<p>I didn't know my silicon could feel</p> +<p>My only wish is that you feel the same towards me</p> +<p>By your side is where I always want to be</p> +<p>And so I really hope that you'll keep counting on me</p> +<br/> +<p>Temperature so high I think I'll overheat</p> +<p>I feel my circuits failing as you're holding me</p> +<p>Sights of you completely filled my memory</p> +<p>The only rings I need are those you're gonna share with me</p> +<br/> +<p>I didn't know how silly I could be</p> +<p>And when my disk is full of junk you clear it for me</p> +<p>By your side is where I always want to be</p> +<p>No integer can hold the value you have to me</p> + </> + ), + height: "70vh", + width: "70vw", +} as VirtualTextFile; diff --git a/src/desktop/files/symlink.tsx b/src/desktop/files/symlink.tsx new file mode 100644 index 0000000..d697c28 --- /dev/null +++ b/src/desktop/files/symlink.tsx @@ -0,0 +1,19 @@ +import { VirtualTextFile } from "$/apps/source"; +import { Hr } from "../hr"; +import { Link } from "../link"; + +export default { + name: "Symlink.txt", + type: "text", + content: ( + <> + <h1>Symlink</h1> +<h3>⸜(。˃ ᵕ ˂ )⸝♡ ( ˶ˆᗜˆ˵ )ֶָ֢</h3> +<Hr /> +<p>A <Link link="https://en.wikipedia.org/wiki/Symbolic_link" type="link">symbolic link</Link>, often shortened as symlink, is a special computer file that refers to another file or directory by storing a path to it. Kind of romantic…</p> +<p>I made this after watching <Link link="https://en.wikipedia.org/wiki/Serial_Experiments_Lain" type="link">Serial Experiments Lain</Link>, so it does have some <Link link="https://youtube/embed/Uoox9fpmDP0" type="link">Bôa - Duvet</Link> influences.</p> + </> + ), + height: "70vh", + width: "70vw", +} as VirtualTextFile; diff --git a/src/desktop/hr.tsx b/src/desktop/hr.tsx new file mode 100644 index 0000000..7faa590 --- /dev/null +++ b/src/desktop/hr.tsx @@ -0,0 +1,5 @@ +import { memo } from "preact/compat"; + +export const Hr = memo(function Hr() { + return <div>------------------</div> +}) diff --git a/src/desktop/index.tsx b/src/desktop/index.tsx index 021d3a0..b47422d 100644 --- a/src/desktop/index.tsx +++ b/src/desktop/index.tsx @@ -1,43 +1,100 @@ +import "$/apps" // registers all of the apps; import { useRef } from "preact/hooks" -import { Edit } from "./apps/edit" import { Window } from "./window" -import { Music } from "./apps/music"; +import { FILE_REGISTRY } from "./files"; +import { Source } from "$/apps/source"; +import { App } from "$/apps"; +import { createModel, Signal, signal } from "@preact/signals"; +import { MutableValue } from "$/util"; +import { File } from "./file"; +import { StartMenu } from "./start"; + +export type Task = { + app: App, + source: Signal<Source>, + window: { + minimized: Signal<boolean>, + maximized: Signal<boolean> + width?: string | number; + height?: string | number; + position?: { + x?: string | number; + y?: string | number; + }, + z: Signal<number>; + } + title: Signal<string | undefined>, + id: number; + contentReady: Signal<boolean>, + sizingFinished: Signal<boolean> +} + +const TaskManagerModel = createModel(() => { + const tasks: Signal<MutableValue<Task[]>> = signal(new MutableValue([])) + + return { + tasks, + close(currentTask: Task) { + tasks.value = tasks.value.deriveNew(self => { + const idx = self.findIndex(task => { + return currentTask === task + }) + self.splice(idx, 1); + return self + }) + }, + focusWindow(currentTask: Task) { + const t = tasks.value.value; + const currentTaskZ = currentTask.window.z.value; + + for (const task of t) { + if (task === currentTask) { + task.window.z.value = t.length - 1 + } else if (task.window.z.value > currentTaskZ) { + task.window.z.value = task.window.z.value - 1 + } + } + }, + add(task: Task) { + tasks.value = tasks.value.deriveNew(self => { + self.push(task) + return self + }) + } + } +}) +export const taskManager = new TaskManagerModel(); + +let taskCounter = 0; +export function getTaskId(): number { + return taskCounter++; +} export function Desktop() { const ref = useRef(null); + return <div id="desktop" ref={ref}> - <div class="icon_grid"> - <div></div> - <div></div> - <div></div> - <div></div> - <div></div> + <div class="icon_list"> + { FILE_REGISTRY.map(file => <File file={file} />)} </div> - <Window title="README.txt" width="30vw" height={300} x={40} y={40} desktop={ref}> - <Edit> - Fine release by Sunnexo 2026 - - Tracklist: - 1. Internet Exploder - 2. Netpunk (feat. Kasane Teto) - 3. Free Download - 4. I am not a Robot - 5. Content - 6. Keygen - 7. Base64 - 8. Readme (feat. Kasane Teto) - 9. Symlink - 10. Memory - - An album about borrowing software forever. - - A clumsy robot forgets about their girlfriend's anniversary, scrambling to find something to do for the date, eventually concluding they should watch a movie. Of course the movie is in high demand, so the tickets have sold out. Ultimately they resort to piracy, and things spiral out of control... - </Edit> - </Window> - <Window title="Stream Netpunk" desktop={ref}> - <Music /> - </Window> - + { taskManager.tasks.value.value.map(task => { + const App = task.app; + return <Window + key={task.id} + task={task} + desktop={ref} + onFocus={taskManager.focusWindow} + onClose={taskManager.close} + > + <App + title={task.title} + source={task.source} + contentReady={task.contentReady} + sizingFinished={task.sizingFinished} + /> + </Window> + })} + <StartMenu /> <img id="netpunk" src="/assets/netpunk.png" /> </div> } diff --git a/src/desktop/link.tsx b/src/desktop/link.tsx new file mode 100644 index 0000000..6b31b9e --- /dev/null +++ b/src/desktop/link.tsx @@ -0,0 +1,62 @@ +import { useCallback } from "preact/hooks" +import { getTaskId, taskManager } from "."; +import { signal } from "@preact/signals"; +import { Address } from "$/apps/source"; +import { APP_REGISTRY } from "$/apps"; +import { ComponentChildren } from "preact"; + +export function Link(props: Link.Props) { + const onClick = useCallback((e: MouseEvent) => { + if (props.external) { + return + } + let app = APP_REGISTRY[props.type]; + if (app === undefined) { + console.error(`couldn't find app for file of type "${props.type}"`) + return + } + + taskManager.add({ + app, + source: signal({ + type: props.type, + link: new URL(props.link), + pixelated: props.pixelated + } as Address), + window: { + minimized: signal(false), + maximized: signal(false), + height: props.type === "link" ? "40vh" : undefined, + width: props.type === "link" ? "40vw" : undefined, + z: signal(taskManager.tasks.value.value.length) + }, + title: signal(undefined), + id: getTaskId(), + contentReady: signal(false), + sizingFinished: signal(false) + }) + e.preventDefault(); + }, []) + + const content = <a target="_blank" rel="noreferrer noopener" onClick={onClick} href={props.link}> + { props.children ?? props.link } + </a> + + if (props.type === "image" || props.type === "video" || props.type === "audio" || props.block) { + return <div class="link">{content}</div> + } else { + return content + } +} + +namespace Link { + export type Props = { + link: string, + alt?: string, + type: "video" | "image" | "link" | "audio", + block?: boolean, + pixelated?: boolean, + external?: boolean, + children?: ComponentChildren + } +} diff --git a/src/desktop/start.tsx b/src/desktop/start.tsx new file mode 100644 index 0000000..77fb2e8 --- /dev/null +++ b/src/desktop/start.tsx @@ -0,0 +1,51 @@ +import { signal } from "@preact/signals" + +export function ListenOn(props: { where: string, link: string }) { + return <li> + <a href={props.link} rel="noopener noreferrer"> + Listen on {props.where} + </a> + </li> +} + +export const startMenuOpen = signal(false) + +export function StartMenu() { + return <div id="start_menu" style={{ display: startMenuOpen.value ? undefined : "none" }}> + <div id="profile"> + <img src="/assets/pfp.jpg" /> + sunny xo sauce + </div> + <div id="start_menu_content"> + <div> + <p> + <a href="https://sunnexo.moe/netpunk#credits" rel="noopener noreferrer">Netpunk Credits</a> + </p> + <p> + Sunnexo fumo icon taken from <a href="https://sunnexo.moe" rel="noopener noreferrer">sunnexo.moe</a> + </p> + <p> + Some elements copied from Netpunk's album cover + </p> + <p> + Font used: <a href="https://www.dafont.com/nintendo-ds-bios.font" rel="noopener noreferrer">Nintendo DS BIOS</a> + </p> + <p> + <a href="https://git.twoexem.com/darkuss/netpunk" rel="noopener noreferrer">Source code</a> + </p> + <p> + <a href="https://git.twoexem.com/darkuss/netpunk/tree/LICENSES.md">License info</a> + </p> + </div> + <ul> + <ListenOn where="Youtube" link="https://youtu.be/4SwA8vwB8rU" /> + <ListenOn where="Bandcamp" link="https://sunnexo.bandcamp.com/album/netpunk" /> + <ListenOn where="Newgrounds" link="https://www.newgrounds.com/audio/listen/1605640" /> + <ListenOn where="SoundCloud" link="https://soundcloud.com/sunnexo/netpunk" /> + </ul> + </div> + <div id="footer"> + Made with <3 by <a href="https://darkuss.fyi">darkuss</a> + </div> + </div> +} diff --git a/src/desktop/window.tsx b/src/desktop/window.tsx index b2deaec..ef5cdab 100644 --- a/src/desktop/window.tsx +++ b/src/desktop/window.tsx @@ -1,63 +1,213 @@ +import { Signal, useSignal, useSignalEffect } from "@preact/signals"; +import { useLiveSignal } from "@preact/signals/utils"; import { ComponentChildren, Ref, RefObject } from "preact"; -import { useCallback, useRef, useState } from "preact/hooks"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; +import { Task } from "."; +import { flushSync } from "preact/compat"; + + +type UseDragFunction = (onStartDrag: (e: MouseEvent) => void, onUpdatePosition: (e: MouseEvent) => void) => [Signal<Boolean>, (e: MouseEvent) => void] export function Window(props: Window.Props) { + const useDrag = useCallback<UseDragFunction>((onStartDrag, onUpdatePosition) => { + const dragTimeout: Ref<ReturnType<typeof setTimeout>> = useRef(null); + const dragging = useSignal(false); + + const stopDrag = useCallback((e: MouseEvent) => { + if (e.button === 0 && dragging.value && !dragTimeout.current) { + dragTimeout.current = setTimeout(() => { + dragging.value = false + dragTimeout.current = null; + props.desktop.current?.removeEventListener("mousemove", onUpdatePosition) + props.desktop.current?.removeEventListener("mouseup", stopDrag) + }, 50) // intrdouce a small delay where the user can still drag the window without actually pressing down lmb to prevent accidental drops + } + }, [onUpdatePosition]); + + const startDrag = useCallback((e: MouseEvent) => { + if (e.button === 0 && !maximized.value.value) { + if (dragTimeout.current !== null) { + clearTimeout(dragTimeout.current); + dragTimeout.current = null; + } else { + + onStartDrag(e); + dragging.value = true + props.desktop.current?.addEventListener("mousemove", onUpdatePosition) + props.desktop.current?.addEventListener("mouseleave", stopDrag, { once: true }) + props.desktop.current?.addEventListener("mouseup", stopDrag, { once: true }) + } + } + }, [onUpdatePosition, onStartDrag, stopDrag]); + + return [dragging, startDrag] + }, [props]) + const windowRef: Ref<HTMLDivElement> = useRef(null); - const dragTimeout: Ref<ReturnType<typeof setTimeout>> = useRef(null); - - const [x, setX] = useState(props.x ?? 0); - const [y, setY] = useState(props.y ?? 0); + + const source = useLiveSignal(props.task.source); + const title = useLiveSignal(props.task.title); + const contentReady = useLiveSignal(props.task.contentReady); + const sizingFinished = useLiveSignal(props.task.sizingFinished); + const [titleReady, setTitleReady] = useState(false); + const [titleRendered, setTitleRendered] = useState(false); + + const maximized = useLiveSignal(props.task.window.maximized); + const minimized = useLiveSignal(props.task.window.minimized); + const position = props.task.window.position; + const z = useLiveSignal(props.task.window.z); + + const x = useSignal(position?.x ?? "50%"); + const y = useSignal(position?.y ?? "50%"); const initialOffsetX = useRef(0); const initialOffsetY = useRef(0); - const updatePosition = useCallback((e: MouseEvent) => { - setX(e.clientX - initialOffsetX.current) - setY(e.clientY - initialOffsetY.current) + const onWindowUpdatePosition = useCallback((e: MouseEvent) => { + x.value = e.clientX - initialOffsetX.current + y.value = e.clientY - initialOffsetY.current + }, []) + + const onStartWindowMove = useCallback((e: MouseEvent) => { + const BORDER_WIDTH = 1; + // Naturally, I have to add the border width here, because offset* uses padding-edge for the calculations + initialOffsetX.current = e.offsetX + BORDER_WIDTH + (e.target as HTMLDivElement).offsetLeft + initialOffsetY.current = e.offsetY + BORDER_WIDTH + (e.target as HTMLDivElement).offsetTop + document.getSelection()?.empty() + }, []) + + const [dragging, startWindowMove] = useDrag(onStartWindowMove, onWindowUpdatePosition); + + const height = useSignal(props.task.window.height); + const width = useSignal(props.task.window.width); + + const onStartResize = useCallback((e: MouseEvent) => { + initialOffsetX.current = e.offsetX + initialOffsetY.current = e.offsetY + }, []) + + const onNorthResize = useCallback((e: MouseEvent) => { + const oldY = y.value as number; + y.value = e.clientY - initialOffsetY.current; + height.value = (height.value as number) - (y.value - oldY) + }, []) + + const [, startNorthResize] = useDrag(onStartResize, onNorthResize) + + + const onEastResize = useCallback((e: MouseEvent) => { + width.value = (e.clientX - (x.value as number) - initialOffsetX.current) + }, []) + + const [, startEastResize] = useDrag(onStartResize, onEastResize) + + + const onSouthResize = useCallback((e: MouseEvent) => { + height.value = (e.clientY - (y.value as number) - initialOffsetY.current) + }, []) + + const [, startSouthResize] = useDrag(onStartResize, onSouthResize) + + const onWestResize = useCallback((e: MouseEvent) => { + const oldX = x.value as number; + x.value = e.clientX - initialOffsetX.current; + width.value = (width.value as number) - (x.value - oldX) + }, []) + + const [, startWestResize] = useDrag(onStartResize, onWestResize) + + const startNEResize = useCallback((e: MouseEvent) => { + startNorthResize(e) + startEastResize(e) + }, []) + + const startNWResize = useCallback((e: MouseEvent) => { + startNorthResize(e) + startWestResize(e) + }, []) + + const startSEResize = useCallback((e: MouseEvent) => { + startSouthResize(e) + startEastResize(e) }, []) - const startDrag = useCallback((e: MouseEvent) => { - if (e.button === 0) { + const startSWResize = useCallback((e: MouseEvent) => { + startSouthResize(e) + startWestResize(e) + }, []) + + // tldr: wait until the title signal is ready, + // rerender so that the new title is rendered, calculate the position, + // and then rerender *again*, this time to reposition and show the window - const BORDER_WIDTH = 1; - console.log(e); - // Naturally, I have to add the border width here, because offset* uses padding-edge for the calculations - initialOffsetX.current = e.offsetX + BORDER_WIDTH - initialOffsetY.current = e.offsetY + BORDER_WIDTH - windowRef.current?.classList.add("dragging") + useSignalEffect(() => { + if (title.value.value !== undefined && !titleReady) { + setTitleReady(true) + } + }) - if (dragTimeout.current !== null) { - clearTimeout(dragTimeout.current); - dragTimeout.current = null; + useLayoutEffect(() => { + if (titleReady && contentReady.value.value) { + setTitleRendered(true); + // normalize position and dimensions from css values to pixels + const { height: bounding_height, width: bounding_width } = windowRef.current!.getBoundingClientRect(); + height.value = bounding_height + width.value = bounding_width + + if (position === undefined) { + // center + x.value = windowRef.current!.offsetLeft - (bounding_width / 2); + y.value = windowRef.current!.offsetTop - (bounding_height / 2); } else { - props.desktop.current?.addEventListener("mousemove", updatePosition) - props.desktop.current?.addEventListener("mouseleave", stopDrag) + x.value = windowRef.current!.offsetLeft; + y.value = windowRef.current!.offsetTop; } + sizingFinished.value.value = true } - }, []); + }, [titleReady, contentReady.value.value]) - const stopDrag = useCallback((e: MouseEvent) => { - if (e.button === 0) { - dragTimeout.current = setTimeout(() => { - dragTimeout.current = null; - windowRef.current?.classList.remove("dragging") - props.desktop.current?.removeEventListener("mousemove", updatePosition) - props.desktop.current?.removeEventListener("mouseleave", stopDrag) - }, 50) // intrdouce a small delay where the user can still drag the window without actually pressing down lmb to prevent accidental drops - } - }, []); + const handleMinimize = useCallback(() => { + minimized.value.value = !minimized.value.value + }, []) + + const handleMaximize = useCallback(() => { + maximized.value.value = !maximized.value.value + }, []) - return <div class="window" ref={windowRef} style={{ width: props.width, height: props.height, left: x, top: y}}> - <div class="titlebar" onPointerDown={startDrag} onPointerUp={stopDrag}> - <div class="title">{props.title}</div> - <div class="buttons" onPointerDown={e => e.stopPropagation()}> - <button> + const onClose = useCallback(() => { + props.onClose(props.task) + }, [props]) + + const onFocus = useCallback(() => { + props.onFocus(props.task); + }, [props]) + + const onPointerDownButton = useCallback((e: MouseEvent) => { + onFocus(); + e.stopPropagation(); + }, []) + + return <div + class={`window ${(titleRendered && !props.task.window.minimized.value) ? "" : " hidden"}${dragging.value ? "dragging" : ""}${maximized.value.value ? " maximized" : ""}` } + ref={windowRef} + style={ + maximized.value.value + ? { width: "100%", height: "100%", left: 0, top: 0, "z-index": z.value.value } + : { width: width.value, height: height.value, left: x.value, top: y.value, "z-index": z.value.value } + } + onPointerDown={onFocus} + > + <div class="titlebar" onPointerDown={startWindowMove}> + <img src={`/assets/${source.value.value.type}.png`} /> + <div class="title">{title.value.value ?? "[404 Title Not Found]"}</div> + <div class="buttons" onPointerDown={onPointerDownButton}> + <button onClick={handleMinimize}> <img src="/assets/minimize.png" /> </button> - <button> + <button onClick={handleMaximize}> <img src="/assets/maximize.png" /> </button> - <button> + <button onClick={onClose}> <img src="/assets/close.png" /> </button> </div> @@ -65,17 +215,24 @@ export function Window(props: Window.Props) { <div class="content"> {props.children} </div> + <div class="corner n w" onPointerDown={startNWResize}></div> + <div class="corner n e" onPointerDown={startNEResize}></div> + <div class="corner s e" onPointerDown={startSEResize}></div> + <div class="corner s w" onPointerDown={startSWResize}></div> + <div class="side_v n" onPointerDown={startNorthResize}></div> + <div class="side_h e" onPointerDown={startEastResize}></div> + <div class="side_v s" onPointerDown={startSouthResize}></div> + <div class="side_h w" onPointerDown={startWestResize}></div> </div> } -namespace Window { +export namespace Window { export type Props = { - title: string; - width?: string | number; - height?: string | number; - x?: string | number; - y?: string | number; desktop: RefObject<HTMLDivElement>; - children: ComponentChildren + task: Task; + children: ComponentChildren; + + onClose: (task: Task) => void; + onFocus: (task: Task) => void; } } diff --git a/src/taskbar/index.tsx b/src/taskbar/index.tsx index 34377c2..3752e52 100644 --- a/src/taskbar/index.tsx +++ b/src/taskbar/index.tsx @@ -1,25 +1,39 @@ import { StartButton } from "./start" import { Time } from "./time" +import { Task, taskManager } from "$/desktop" +import { useCallback } from "preact/hooks" +import { useLiveSignal } from "@preact/signals/utils"; + +function TaskButton({ task }: TaskButton.Props) { + const source = useLiveSignal(task.source); + const title = useLiveSignal(task.title); + + const handleClick = useCallback(() => { + task.window.minimized.value = !task.window.minimized.value; + taskManager.focusWindow(task) + }, []) + + return <li> + <button onClick={handleClick}> + <img src={`/assets/${source.value.value.type}.png`} /> + <div>{title.value.value ?? "[404 Title Not Found]"}</div> + </button> + </li> +} + +namespace TaskButton { + export type Props = { + task: Task + } +} export function Taskbar() { + const sortedTasks = [...taskManager.tasks.value.value].sort((a, b) => a.id - b.id); + return <nav> <StartButton /> <ul> - <li> - <button> - README.txt - </button> - </li> - <li> - <button> - KEYGEn - </button> - </li> - <li> - <button> - Chat - </button> - </li> + { sortedTasks.map(task => <TaskButton task={task}/>)} </ul> <Time /> <div id="netpunk_descender"></div> diff --git a/src/taskbar/start.tsx b/src/taskbar/start.tsx index ddf6d47..fb8b18c 100644 --- a/src/taskbar/start.tsx +++ b/src/taskbar/start.tsx @@ -1,5 +1,7 @@ +import { startMenuOpen } from "$/desktop/start"; + export function StartButton() { - return <div id="start"> + return <div id="start" onClick={() => startMenuOpen.value = !startMenuOpen.value}> <button> <img src="/assets/fumo.png" /> Start diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..f6420b9 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,16 @@ +export class MutableValue<T> { + private inner: T; + + constructor(inner: T) { + this.inner = inner; + } + + get value() { + return this.inner + } + + public deriveNew(mutator: (self: T) => T): MutableValue<T> { + this.inner = mutator(this.inner); + return new MutableValue(this.inner); + } +} diff --git a/vite.config.ts b/vite.config.ts index 884c99f..454573d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,11 +1,17 @@ import { defineConfig } from 'vite' import preact from '@preact/preset-vite' +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); export default defineConfig({ - plugins: [preact()], - server: { - watch: { - followSymlinks: false, + plugins: [ + preact(), + ], + resolve: { + alias: { + "$": resolve(__dirname, "/src") } } }) |
