Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97c0b7c5ed | ||
|
|
ad70bdc5b6 | ||
|
|
a7c03d2327 | ||
|
|
673948bce9 | ||
|
|
7a3cdcc1da | ||
|
|
44469bd9f2 | ||
|
|
00060ede92 | ||
|
|
2204709c0e | ||
|
|
3e39bda014 | ||
|
|
05db0b693e | ||
|
|
4543680a92 | ||
|
|
858142f09a | ||
|
|
830c34855c | ||
|
|
a7f2d527dc | ||
|
|
e72a61b317 | ||
|
|
9339416c1b | ||
|
|
753bdb5c0a | ||
|
|
bfeaca5f09 | ||
|
|
10e3a7fc40 | ||
|
|
1ed1d47b07 | ||
|
|
d1e249b51d | ||
|
|
7ce8674fea | ||
|
|
36dd770ce4 | ||
|
|
eadc0b119d | ||
|
|
44c60f7069 | ||
|
|
b58407d358 | ||
|
|
9386c1d158 | ||
|
|
3853e8fa20 | ||
|
|
cfbaf56159 | ||
|
|
014e64d174 | ||
|
|
1f7c8f0ed4 | ||
|
|
4ac1fd9574 | ||
|
|
dd084afb61 | ||
|
|
3c69f73dc6 | ||
|
|
92ad0a50f9 | ||
|
|
4939d24f62 | ||
|
|
2bd1e4a512 | ||
|
|
2a1bcfc3d2 | ||
|
|
c509c770f6 | ||
|
|
a76d959431 | ||
|
|
0cdee66c1d | ||
|
|
a39ad3b464 | ||
|
|
099b876032 | ||
|
|
b2d297fca8 | ||
|
|
432010e656 | ||
|
|
f16d54aa46 | ||
|
|
ff9cdcab1d | ||
|
|
a09f2f8d3f | ||
|
|
309452f16e | ||
|
|
c5f9981c86 | ||
|
|
b62947ae0c | ||
|
|
93f97463e6 |
@@ -0,0 +1,10 @@
|
||||
# .dockerignore
|
||||
/.git
|
||||
/.nuxt
|
||||
/.output
|
||||
/node_modules
|
||||
|
||||
.gitignore
|
||||
README.md
|
||||
|
||||
.stylelintcache
|
||||
@@ -0,0 +1,27 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Cache
|
||||
.stylelintcache
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -1,73 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
|
||||
|
||||
Copyright 2023 enderman.ch
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,3 +1,75 @@
|
||||
# index
|
||||
# Nuxt 3 Minimal Starter
|
||||
|
||||
Landing page in Nuxt 3.
|
||||
Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to install the dependencies:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
```
|
||||
|
||||
## Development Server
|
||||
|
||||
Start the development server on `http://localhost:3000`:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run dev
|
||||
|
||||
# pnpm
|
||||
pnpm run dev
|
||||
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm run build
|
||||
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run preview
|
||||
|
||||
# pnpm
|
||||
pnpm run preview
|
||||
|
||||
# yarn
|
||||
yarn preview
|
||||
|
||||
# bun
|
||||
bun run preview
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
index:
|
||||
container_name: frontend-index
|
||||
image: endermanch/index:latest
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- HOST=0.0.0.0
|
||||
build:
|
||||
context: ./src
|
||||
ports:
|
||||
- "127.0.0.1:3670:3000"
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,31 @@
|
||||
FROM oven/bun:alpine AS base
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# install dependencies into temp directory
|
||||
# this will cache them and speed up future builds
|
||||
FROM base AS install
|
||||
RUN mkdir -p /temp
|
||||
|
||||
COPY package.json bun.lockb /temp/
|
||||
RUN cd /temp && bun install --production
|
||||
|
||||
# copy node_modules from temp directory
|
||||
# then copy all (non-ignored) project files into the image
|
||||
FROM base AS prerelease
|
||||
COPY --from=install /temp/node_modules node_modules
|
||||
COPY . .
|
||||
|
||||
ENV NODE_ENV=production
|
||||
RUN bun test
|
||||
RUN bun --bun run build
|
||||
|
||||
# copy production dependencies and source code into final image
|
||||
FROM base AS release
|
||||
COPY --from=install /temp/node_modules node_modules
|
||||
COPY --from=prerelease /usr/src/app/.output .
|
||||
COPY --from=prerelease /usr/src/app/package.json .
|
||||
|
||||
ENV HOST=0.0.0.0
|
||||
USER bun
|
||||
|
||||
ENTRYPOINT [ "bun", "run", "server/index.mjs" ]
|
||||
@@ -0,0 +1,5 @@
|
||||
import config from '../config'
|
||||
|
||||
export default defineAppConfig({
|
||||
...config,
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import 'iconify-icon'
|
||||
|
||||
const config = useAppConfig()
|
||||
const route = useRoute()
|
||||
|
||||
const { reader, animate } = storeToRefs(usePageStore())
|
||||
|
||||
useHead({
|
||||
titleTemplate: (chunk?) => {
|
||||
if (route.fullPath === '/') return config.title.full
|
||||
if (route.fullPath.split('/').length === 2)
|
||||
switch (route.fullPath.split('/')[1]) {
|
||||
case 'blog':
|
||||
return 'The Ender Chest'
|
||||
default:
|
||||
return `${chunk} – ${config.title.short}`
|
||||
}
|
||||
|
||||
return chunk!
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
id="ender-layout"
|
||||
class="flex flex-col lm:justify-center lt:justify-center items-center h-full"
|
||||
>
|
||||
<ClientOnly>
|
||||
<LazySwipeControls v-if="!reader" class="block sm:hidden" />
|
||||
</ClientOnly>
|
||||
|
||||
<NuxtLoadingIndicator />
|
||||
<NuxtLayout name="card">
|
||||
<template #footer>
|
||||
<footer
|
||||
v-if="!reader"
|
||||
class="lm:static sm:fixed sm:bottom-0 sm:left-0 sm:right-0 mx-auto w-auto lm:mb-0 sm:mb-2 pass-through text-center"
|
||||
>
|
||||
<small
|
||||
class="lm:text-inherit sm:accent-text sm:drop-shadow-lg sm:transition-all lm:opacity-100 sm:opacity-25 sm:hover:opacity-100"
|
||||
>
|
||||
© 2018-{{ new Date().getFullYear() }},
|
||||
<a class="sm:link-dark" :href="config.url">Enderman</a>. All rights
|
||||
reserved.
|
||||
<wbr />
|
||||
<sub class="whitespace-nowrap">
|
||||
β{{ config.build.version ? config.build.version : '?.?.?' }} ({{
|
||||
config.build.date ? config.build.date : '1970-01-01'
|
||||
}})
|
||||
</sub>
|
||||
</small>
|
||||
</footer>
|
||||
</template>
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
|
||||
<ClientOnly>
|
||||
<Portal v-model="animate" layout="#ender-layout" randomize fade />
|
||||
</ClientOnly>
|
||||
</template>
|
||||
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,39 @@
|
||||
@counter-style do {
|
||||
system: fixed;
|
||||
symbols: "\1F4BB" "\1F4CC" "\1F47B" "\1F4F7";
|
||||
suffix: " ";
|
||||
}
|
||||
|
||||
@counter-style enjoy {
|
||||
system: fixed;
|
||||
symbols: "\2728" "\1F4A1" "\1F4BE" "\1F3A5" "\1F4D5" "\1F30D" "\2615" "\26C4";
|
||||
suffix: " ";
|
||||
}
|
||||
|
||||
@counter-style faq {
|
||||
system: cyclic;
|
||||
symbols: "\2753" "\1F4A1";
|
||||
suffix: " ";
|
||||
}
|
||||
|
||||
.list-style-type {
|
||||
&-none {
|
||||
list-style-type: none !important;
|
||||
}
|
||||
|
||||
&-do {
|
||||
list-style-type: do !important;
|
||||
}
|
||||
|
||||
&-enjoy {
|
||||
list-style-type: enjoy !important;
|
||||
}
|
||||
|
||||
&-faq {
|
||||
list-style-type: faq !important;
|
||||
|
||||
> li:nth-child(2n):not(:last-child) {
|
||||
@apply mb-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
@use 'animate.css';
|
||||
|
||||
// Modules.
|
||||
@use 'transitions';
|
||||
@use 'lists';
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@font-face {
|
||||
font-family: Enchant;
|
||||
src:
|
||||
url("~/assets/fonts/enchant/enchant.woff") format('woff'),
|
||||
url("~/assets/fonts/enchant/enchant.woff2") format('woff2')
|
||||
}
|
||||
|
||||
// CSS Variables.
|
||||
:root {
|
||||
--animate-duration: 1s;
|
||||
--animate-delay: 0.75s;
|
||||
--animate-repeat: 1;
|
||||
}
|
||||
|
||||
@-moz-document url-prefix() {
|
||||
html {
|
||||
min-height: 128px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(255 255 255 / 15%) rgb(0 0 0 / 20%);
|
||||
|
||||
&.light {
|
||||
scrollbar-color: rgb(0 0 0 / 15%) rgb(255 255 255 / 20%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The HTML page.
|
||||
html {
|
||||
height: 100dvh;
|
||||
font-size: 14px;
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
border-radius: 8px;
|
||||
background-color: rgb(0 0 0 / 20%);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 8px;
|
||||
background-color: rgb(255 255 255 / 15%);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
border-radius: 3px;
|
||||
background-color: rgb(255 255 255 / 15%);
|
||||
}
|
||||
|
||||
&.light {
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: rgb(255 255 255 / 15%);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(0 0 0 / 30%);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background-color: rgb(0 0 0 / 30%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The body element.
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
position: sticky;
|
||||
|
||||
background-color: rgb(6 25 28);
|
||||
background-image: url('~/assets/images/textures/sky.png');
|
||||
background-attachment: fixed;
|
||||
background-blend-mode: multiply;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
image-rendering: pixelated;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
color: white;
|
||||
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
h1, h2, h3, h4, h5, h6,
|
||||
.text-h1, .text-h2, .text-h3, .text-h4, .text-h5, .text-h6 {
|
||||
@apply font-serif;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h1, .text-h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
h2, .text-h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
h3, .text-h3 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
h4, .text-h4 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
h5, .text-h5 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
h6, .text-h6 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply no-underline transition-all;
|
||||
|
||||
color: cornflowerblue;
|
||||
|
||||
&:hover {
|
||||
color: royalblue;
|
||||
}
|
||||
}
|
||||
|
||||
center {
|
||||
text-align: center;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
:not(nav) {
|
||||
> ul {
|
||||
@apply ps-6;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
> ol {
|
||||
@apply ps-6;
|
||||
list-style-type: decimal;
|
||||
}
|
||||
}
|
||||
|
||||
:where(section, article, aside).page {
|
||||
:is(div, p):not(:last-child) {
|
||||
@apply mb-4;
|
||||
}
|
||||
|
||||
:is(h1, h2, h3, h4, h5, h6) {
|
||||
a {
|
||||
@apply text-inherit no-underline transition-all;
|
||||
|
||||
&:hover {
|
||||
color: indianred;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
@apply mb-2;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
:where(ul, ol) {
|
||||
&:not(:last-child) {
|
||||
@apply mb-4;
|
||||
}
|
||||
|
||||
li:not(:last-child) {
|
||||
@apply mb-1;
|
||||
}
|
||||
}
|
||||
|
||||
code:not(pre *) {
|
||||
@apply px-1 py-0.5 break-words cursor-pointer rounded-lg transition-ease;
|
||||
|
||||
background-color: rgb(83 35 162 / 10%);
|
||||
|
||||
&:hover {
|
||||
background-color: rgb(138 71 245 / 20%);
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
@apply max-w-full max-h-[400px];
|
||||
}
|
||||
|
||||
blockquote {
|
||||
@apply px-4 py-3 mb-4 border-l-4 rounded-r-xl transition-ease;
|
||||
|
||||
border-left-color: rgb(153 153 255 / 60%);
|
||||
background-color: rgb(153 153 255 / 10%);
|
||||
|
||||
&:hover {
|
||||
border-left-color: rgb(153 153 255 / 100%);
|
||||
background-color: rgb(153 153 255 / 15%);
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
@apply relative font-small overflow-auto min-h-14 max-h-[80rem] rounded-xl p-2 mb-4;
|
||||
|
||||
background-color: rgb(83 35 162 / 5%);
|
||||
|
||||
&:hover > .absolute {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
> code {
|
||||
@apply scroll-p-2;
|
||||
|
||||
counter-reset: step;
|
||||
counter-increment: step 0;
|
||||
|
||||
.line::before {
|
||||
@apply inline-block w-4 mr-6 text-right;
|
||||
|
||||
content: counter(step);
|
||||
counter-increment: step;
|
||||
|
||||
color: rgb(238 246 250 / 40%);
|
||||
}
|
||||
|
||||
> span span:last-child {
|
||||
@apply me-4;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-action {
|
||||
background-color: rgb(153 153 255 / 10%);
|
||||
border-color: rgb(153 153 255 / 60%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Entry point (Vue mount).
|
||||
#ender-app {
|
||||
flex: 1 0 auto;
|
||||
image-rendering: auto;
|
||||
}
|
||||
|
||||
// Helper classes that don't exist in Bootstrap.
|
||||
.overlay {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
.pass-through {
|
||||
pointer-events: none;
|
||||
|
||||
> * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
ruby {
|
||||
ruby-position: under;
|
||||
|
||||
ruby {
|
||||
ruby-position: over;
|
||||
}
|
||||
}
|
||||
|
||||
rt {
|
||||
ruby-align: space-around;
|
||||
}
|
||||
|
||||
.font-small {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
// Query-overridable classes.
|
||||
.dimensions {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.accent-background {
|
||||
background-color: rgb(0 0 0 / 50%);
|
||||
}
|
||||
|
||||
.accent-overlay-background {
|
||||
background-color: rgb(0 0 0 / 90%);
|
||||
}
|
||||
|
||||
.accent-text {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.accent-text-shadow {
|
||||
text-shadow: black 2px 3px 4px;
|
||||
}
|
||||
|
||||
.drop-shadow,
|
||||
.accent-drop-shadow {
|
||||
filter: drop-shadow(1px 2px 3px black);
|
||||
}
|
||||
|
||||
.link-dark {
|
||||
@apply text-inherit no-underline transition-all;
|
||||
|
||||
color: cornflowerblue;
|
||||
|
||||
&:hover {
|
||||
color: royalblue;
|
||||
}
|
||||
}
|
||||
|
||||
.accent-fade {
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
black,
|
||||
rgb(0 0 0 / 50%) 75%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.fade-mask {
|
||||
mask-image: linear-gradient(to bottom, transparent, black 3%, black 97%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.dialog {
|
||||
display: grid;
|
||||
|
||||
min-width: 240px;
|
||||
max-width: 640px;
|
||||
|
||||
background-color: rgb(25 20 40 / 95%);
|
||||
box-shadow: rgb(81 75 89 / 50%) 7px 7px 2em;
|
||||
border-radius: 16px !important;
|
||||
|
||||
> div.background {
|
||||
background-image: url('~/assets/images/textures/aero.png');
|
||||
background-attachment: scroll;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
background-blend-mode: lighten;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
opacity: 0.2;
|
||||
|
||||
box-shadow: azure 0 0 20rem;
|
||||
filter: blur(5px);
|
||||
}
|
||||
|
||||
> div.content > img.icon-badge {
|
||||
position: fixed;
|
||||
margin-top: -20px;
|
||||
margin-left: -20px;
|
||||
transform: rotate(-25deg);
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
.accent-gradient {
|
||||
background: radial-gradient(
|
||||
circle at left,
|
||||
rgb(255 255 255 / 80%),
|
||||
rgb(255 255 255 / 75%),
|
||||
rgb(255 255 255 / 0%)
|
||||
);
|
||||
}
|
||||
|
||||
.post {
|
||||
scroll-snap-type: y mandatory;
|
||||
scroll-snap-stop: normal;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
&-box {
|
||||
border-color: rgb(153 153 255 / 60%);
|
||||
background-color: rgb(45 7 110 / 10%);
|
||||
|
||||
&:hover {
|
||||
border-color: rgb(153 153 255 / 100%);
|
||||
background-color: rgb(45 7 110 / 30%);
|
||||
}
|
||||
}
|
||||
|
||||
&-pocket {
|
||||
background-color: rgb(153 153 255 / 10%);
|
||||
border-left-color: rgb(153 153 255 / 60%);
|
||||
border-top-color: rgb(153 153 255 / 60%);
|
||||
}
|
||||
|
||||
&-tag {
|
||||
background-color: rgb(153 153 255 / 10%);
|
||||
border-color: rgb(153 153 255 / 60%);
|
||||
}
|
||||
}
|
||||
|
||||
:where(html.light) {
|
||||
body {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.accent-background {
|
||||
background-color: rgb(255 255 255 / 90%);
|
||||
}
|
||||
|
||||
.accent-overlay-background {
|
||||
background-color: rgb(255 255 255 / 90%);
|
||||
}
|
||||
|
||||
.accent-drop-shadow {
|
||||
filter: drop-shadow(1px 2px 3px white);
|
||||
}
|
||||
|
||||
a {
|
||||
@apply no-underline transition-all;
|
||||
|
||||
color: royalblue;
|
||||
|
||||
&:hover {
|
||||
color: darkblue;
|
||||
}
|
||||
}
|
||||
|
||||
.accent-text {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.accent-gradient {
|
||||
background: radial-gradient(
|
||||
circle at left,
|
||||
rgb(0 0 0 / 95%),
|
||||
rgb(0 0 0 / 80%),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.accent-text-shadow {
|
||||
text-shadow: white 1px 2px 3px;
|
||||
}
|
||||
|
||||
.accent-fade {
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
white,
|
||||
rgb(255 255 255 / 50%) 75%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.post {
|
||||
&-box {
|
||||
border-color: rgb(123 123 255 / 1000%);
|
||||
background-color: rgb(45 7 110 / 20%);
|
||||
|
||||
&:hover {
|
||||
border-color: rgb(123 123 255 / 80%);
|
||||
background-color: rgb(45 7 110 / 10%);
|
||||
}
|
||||
}
|
||||
|
||||
&-pocket {
|
||||
background-color: rgb(123 123 255 / 10%);
|
||||
border-left-color: rgb(123 123 255 / 80%);
|
||||
border-top-color: rgb(123 123 255 / 80%);
|
||||
}
|
||||
|
||||
&-tag {
|
||||
background-color: rgb(123 123 255 / 10%);
|
||||
border-color: rgb(123 123 255 / 80%);
|
||||
}
|
||||
}
|
||||
|
||||
:is(section, article, aside).page {
|
||||
code:not(pre *) {
|
||||
background-color: rgb(138 71 245 / 20%);
|
||||
|
||||
&:hover {
|
||||
background-color: rgb(83 35 162 / 20%);
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left-color: rgb(153 153 255 / 60%);
|
||||
background-color: rgb(153 153 255 / 10%);
|
||||
|
||||
&:hover {
|
||||
border-left-color: rgb(153 153 255 / 100%);
|
||||
background-color: rgb(153 153 255 / 15%);
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: rgb(83 35 162 / 10%);
|
||||
|
||||
code {
|
||||
.line::before {
|
||||
color: rgb(83 85 89 / 40%);
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-action {
|
||||
background-color: rgb(153 153 255 / 30%);
|
||||
border-color: rgb(153 153 255 / 80%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic classes.
|
||||
@screen lm {
|
||||
.dimensions {
|
||||
width: 100% !important;
|
||||
min-height: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
@screen sm {
|
||||
html {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#ender-app {
|
||||
height: 100dvh;
|
||||
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.dimensions {
|
||||
width: 90%;
|
||||
|
||||
min-height: fit-content;
|
||||
max-height: 90%;
|
||||
}
|
||||
|
||||
.animate__animated-sm {
|
||||
animation-duration: var(--animate-duration);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
.animate__delay-1-5s {
|
||||
animation-delay: calc(var(--animate-delay) * 1.5);
|
||||
}
|
||||
}
|
||||
|
||||
@screen md {
|
||||
html {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@screen lg {
|
||||
html {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.dimensions {
|
||||
width: 75%;
|
||||
}
|
||||
}
|
||||
|
||||
@screen xl {
|
||||
.dimensions {
|
||||
width: 60%;
|
||||
}
|
||||
}
|
||||
|
||||
@screen 2xl {
|
||||
.dimensions {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
.page {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
opacity: 0;
|
||||
filter: blur(1em);
|
||||
}
|
||||
}
|
||||
|
||||
.switch {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: all 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.transition {
|
||||
&-ease {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
&-portal {
|
||||
transition: all 0.6942s ease-in;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
&-twitter:hover {
|
||||
transform: translateZ(0) scale(1.1) rotate(-10deg);
|
||||
}
|
||||
|
||||
&-back:hover {
|
||||
transform: translateZ(0) scale(0.9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
address: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
subject: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
body: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
cc: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
bcc: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const href =
|
||||
`mailto:${props.address}` +
|
||||
(props.body ? `?body=${props.body}` : '') +
|
||||
(props.subject ? `${props.body ? '&' : '?'}subject=${props.subject}` : '') +
|
||||
(props.cc ? `${props.body || props.subject ? '&' : '?'}cc=${props.cc}` : '') +
|
||||
(props.bcc
|
||||
? `${props.body || props.subject || props.cc ? '&' : '?'}bcc=${props.bcc}`
|
||||
: '')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a target="_blank" rel="noopener" :href="href">
|
||||
<slot>
|
||||
{{ props.address }}
|
||||
</slot>
|
||||
</a>
|
||||
</template>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
logo: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
sectionLogo: {
|
||||
type: null as unknown as PropType<string | null>,
|
||||
default: null,
|
||||
required: false,
|
||||
},
|
||||
thumbnail: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const sentence = computed(() => {
|
||||
return props.description.split('.').at(0) + '...'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-full flex flex-col justify-end text-white relative">
|
||||
<img
|
||||
:src="thumbnail"
|
||||
:alt="alt"
|
||||
class="w-full h-full object-cover absolute top-0 left-0"
|
||||
/>
|
||||
<div class="absolute top-0 left-0 p-5">
|
||||
<img :src="logo" alt="Logo" class="w-[100] h-[100]" />
|
||||
</div>
|
||||
<div v-if="sectionLogo" class="absolute top-0 right-0 p-5">
|
||||
<img :src="sectionLogo" alt="Logo" class="w-[100] h-[100]" />
|
||||
</div>
|
||||
|
||||
<div class="mb-5 px-5" style="text-shadow: black 1px 1px 10px">
|
||||
<h1 class="m-0 text-[60px]">{{ title }}</h1>
|
||||
<strong class="min-h-[90px] m-0 text-[24px]" style="font-family: Lato">
|
||||
{{ sentence }}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<button type="button" data-theme-toggle aria-label="Change to light theme">
|
||||
Change to light theme
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
const config = useAppConfig()
|
||||
const meta = {
|
||||
title: 'Page not found',
|
||||
description:
|
||||
'The page you are looking for does not exist. It might have been removed, had its name changed, or is temporarily unavailable.',
|
||||
image: `${config.url}/images/logo.png`,
|
||||
url: config.url,
|
||||
}
|
||||
|
||||
let slug = useRoute().params.slug
|
||||
|
||||
if (Array.isArray(slug)) slug = slug.join('/')
|
||||
|
||||
const error = {
|
||||
http_code: 200,
|
||||
title: config.title,
|
||||
url: config.url + '/' + slug,
|
||||
locale: config.locale || 'en',
|
||||
data: {
|
||||
path: '/error.vue',
|
||||
content: {
|
||||
title: 'Page not found!',
|
||||
description:
|
||||
"The page you are looking for does not exist. However, no 404 error has occurred, as you're browsing through a web application loaded into memory and HTTP error codes make no sense here.",
|
||||
},
|
||||
},
|
||||
slug: useRoute().params.slug,
|
||||
build: config.build,
|
||||
hint: 'Use the navigation bar to get back to the main page.',
|
||||
}
|
||||
const errorString = JSON.stringify(error, null, 2)
|
||||
const buffer = ref('')
|
||||
|
||||
const startTime = Date.now()
|
||||
const dt = 20
|
||||
|
||||
let i = 0
|
||||
|
||||
function type() {
|
||||
if (Date.now() > startTime + dt * i) buffer.value += errorString[i++]
|
||||
if (i < errorString.length) requestAnimationFrame(type)
|
||||
}
|
||||
|
||||
onMounted(() => setTimeout(type, 1500))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h3>Page not found!</h3>
|
||||
<pre class="whitespace-pre-wrap break-keep font-small">{{
|
||||
buffer
|
||||
}}<span class="blinker">▊</span></pre>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.blinker {
|
||||
animation: blinker 1s steps(2, start) infinite;
|
||||
}
|
||||
|
||||
@keyframes blinker {
|
||||
from {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
to {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,666 @@
|
||||
<script setup lang="ts">
|
||||
import sky from 'assets/images/textures/sky.png'
|
||||
import particles from 'assets/images/textures/particles.png'
|
||||
|
||||
const { $local } = useNuxtApp()
|
||||
const config = useAppConfig()
|
||||
const fqdn = config.url.split('//').at(1)
|
||||
|
||||
const resources: string[] = [sky, particles]
|
||||
const animated = defineModel<boolean>({
|
||||
required: true,
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
layout: {
|
||||
default: '#ender-app',
|
||||
type: String,
|
||||
},
|
||||
directory: {
|
||||
default: '/images/portal',
|
||||
type: String,
|
||||
},
|
||||
create: Boolean,
|
||||
animate: Boolean,
|
||||
randomize: Boolean,
|
||||
fade: Boolean,
|
||||
speed: {
|
||||
default: 1,
|
||||
type: Number,
|
||||
},
|
||||
})
|
||||
|
||||
class Canvas {
|
||||
element: HTMLCanvasElement
|
||||
|
||||
constructor(create: boolean = true, transparent: boolean = false) {
|
||||
this.element = create ? this.create() : this.query()
|
||||
|
||||
if (transparent) this.element.style.opacity = '0'
|
||||
|
||||
this.fill()
|
||||
}
|
||||
|
||||
create(): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas')
|
||||
|
||||
canvas.width = window.innerWidth
|
||||
canvas.height = window.innerHeight
|
||||
|
||||
canvas.style.display = 'block'
|
||||
canvas.style.position = 'fixed'
|
||||
canvas.style.bottom = '0'
|
||||
canvas.style.left = '0'
|
||||
canvas.style.zIndex = '-1'
|
||||
canvas.style.transition = 'all 0.69420s ease-in'
|
||||
|
||||
canvas.innerHTML =
|
||||
'<span>Your browser does not support the <canvas /> element, which is required for parallax animation.</span>'
|
||||
|
||||
// Add a class for subclassing support.
|
||||
canvas.classList.add('ecmaportal')
|
||||
canvas.id = 'ecmaportal'
|
||||
|
||||
document.body.appendChild(canvas)
|
||||
|
||||
return canvas
|
||||
}
|
||||
|
||||
compatible(
|
||||
obj: HTMLCanvasElement | HTMLElement | Element,
|
||||
): obj is HTMLCanvasElement {
|
||||
return obj.tagName.toLowerCase() === 'canvas'
|
||||
}
|
||||
|
||||
query(): HTMLCanvasElement {
|
||||
const canvas = document.querySelector('#ecmaportal')
|
||||
|
||||
if (!canvas) throw new Error('ECMAPortal canvas not found.')
|
||||
if (!this.compatible(canvas))
|
||||
throw new Error('The ECMAPortal element is not a canvas.')
|
||||
|
||||
return canvas
|
||||
}
|
||||
|
||||
fadeIn() {
|
||||
this.element.style.opacity = '1'
|
||||
}
|
||||
|
||||
fadeOut() {
|
||||
this.element.style.opacity = '0'
|
||||
}
|
||||
|
||||
fill() {
|
||||
this.element.width = window.innerWidth * window.devicePixelRatio
|
||||
this.element.height = window.innerHeight * window.devicePixelRatio
|
||||
}
|
||||
|
||||
full(): boolean {
|
||||
return (
|
||||
this.element.width === window.innerWidth &&
|
||||
this.element.height === window.innerHeight
|
||||
)
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.element.remove()
|
||||
}
|
||||
}
|
||||
|
||||
class Portal {
|
||||
private readonly version: string = '1.4T'
|
||||
private readonly canvas: Canvas
|
||||
private readonly prog?: WebGLProgram | null
|
||||
private readonly gl: WebGL2RenderingContext | null
|
||||
|
||||
private animate: boolean = false
|
||||
private readonly randomize: boolean = false
|
||||
private speed: number = 1
|
||||
private readonly initialSpeed: number = 1
|
||||
private readonly dv = 20
|
||||
|
||||
// It's integral for the animation to tie it to the current time, else it will be tied to the framerate.
|
||||
private currentTime: number = Date.now()
|
||||
private pauseTime: number = 0
|
||||
private clickTime: number = 0
|
||||
|
||||
private tick: number = 0
|
||||
private dt: number = 6 / 1000000
|
||||
|
||||
private promises: Promise<HTMLImageElement>[] = []
|
||||
private uniforms!: {
|
||||
modelViewMatrix: WebGLUniformLocation
|
||||
projectionMatrix: WebGLUniformLocation
|
||||
dt: WebGLUniformLocation
|
||||
resolution: WebGLUniformLocation
|
||||
sky: WebGLUniformLocation
|
||||
particles: WebGLUniformLocation
|
||||
}
|
||||
|
||||
constructor(
|
||||
directory: string = '/',
|
||||
create: boolean = true,
|
||||
animate: boolean = false,
|
||||
randomize: boolean = false,
|
||||
fade: boolean = false,
|
||||
speed: number = 1,
|
||||
) {
|
||||
// Vertex shader.
|
||||
const vglsl = `#version 300 es
|
||||
|
||||
layout (location = 0) in vec3 Position;
|
||||
|
||||
uniform mat4 modelViewMatrix;
|
||||
uniform mat4 projectionMatrix;
|
||||
|
||||
uniform vec2 canvasResolution;
|
||||
|
||||
vec4 projection_from_position(vec4 position) {
|
||||
vec4 projection = position * 0.5;
|
||||
projection.xy = vec2(projection.x + projection.w, projection.y + projection.w);
|
||||
projection.zw = position.zw;
|
||||
return projection;
|
||||
}
|
||||
|
||||
out vec4 texProj0;
|
||||
|
||||
void main() {
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(Position, 1.0);
|
||||
|
||||
texProj0 = projection_from_position(gl_Position);
|
||||
texProj0 = vec4(texProj0.xy * canvasResolution / max(canvasResolution.x, canvasResolution.y), texProj0.zw);
|
||||
}`
|
||||
|
||||
// Fragment shader.
|
||||
const fglsl = `#version 300 es
|
||||
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D sky;
|
||||
uniform sampler2D particles;
|
||||
uniform float dt;
|
||||
|
||||
in vec4 texProj0;
|
||||
|
||||
const int LAYERS = 15;
|
||||
const vec3 COLORS[] = vec3[](
|
||||
vec3(0.022087, 0.098399, 0.110818),
|
||||
vec3(0.011892, 0.095924, 0.089485),
|
||||
vec3(0.027636, 0.101689, 0.100326),
|
||||
vec3(0.046564, 0.109883, 0.114838),
|
||||
vec3(0.064901, 0.117696, 0.097189),
|
||||
vec3(0.063761, 0.086895, 0.123646),
|
||||
vec3(0.084817, 0.111994, 0.166380),
|
||||
vec3(0.097489, 0.154120, 0.091064),
|
||||
vec3(0.106152, 0.131144, 0.195191),
|
||||
vec3(0.097721, 0.110188, 0.187229),
|
||||
vec3(0.133516, 0.138278, 0.148582),
|
||||
vec3(0.070006, 0.243332, 0.235792),
|
||||
vec3(0.196766, 0.142899, 0.214696),
|
||||
vec3(0.047281, 0.315338, 0.321970),
|
||||
vec3(0.204675, 0.390010, 0.302066),
|
||||
vec3(0.080955, 0.314821, 0.661491)
|
||||
);
|
||||
|
||||
const mat4 SCALE_TRANSLATE = mat4(
|
||||
0.5, 0.0, 0.0, 0.25,
|
||||
0.0, 0.5, 0.0, 0.25,
|
||||
0.0, 0.0, 1.0, 0.0,
|
||||
0.0, 0.0, 0.0, 1.0
|
||||
);
|
||||
|
||||
mat2 mat2_rotate_z(float radians) {
|
||||
return mat2(
|
||||
cos(radians), -sin(radians),
|
||||
sin(radians), cos(radians)
|
||||
);
|
||||
}
|
||||
|
||||
mat4 portal_layer(float layer) {
|
||||
mat4 translate = mat4(
|
||||
1.0, 0.0, 0.0, 17.0 / layer,
|
||||
0.0, 1.0, 0.0, (2.0 + layer / 1.5) * (dt * 1.5),
|
||||
0.0, 0.0, 1.0, 0.0,
|
||||
0.0, 0.0, 0.0, 1.0
|
||||
);
|
||||
|
||||
mat2 rotate = mat2_rotate_z(radians((layer * layer * 4321.0 + layer * 9.0) * 2.0));
|
||||
|
||||
mat2 scale = mat2((4.5 - layer / 4.0) * 2.0);
|
||||
|
||||
return mat4(scale * rotate) * translate * SCALE_TRANSLATE;
|
||||
}
|
||||
|
||||
out vec4 fragColor;
|
||||
|
||||
void main() {
|
||||
vec3 color = textureProj(sky, texProj0).rgb * COLORS[0];
|
||||
|
||||
for (int i = 0; i < LAYERS; i++) {
|
||||
color += textureProj(particles, texProj0 * portal_layer(float(i + 1))).rgb * COLORS[i];
|
||||
}
|
||||
|
||||
fragColor = vec4(color, 1.0);
|
||||
}`
|
||||
|
||||
// Make sure the resource directory ends with a slash.
|
||||
if (directory.at(-1) !== '/') directory += '/'
|
||||
|
||||
// Images to load.
|
||||
const images = resources // [`${directory}sky.png`, `${directory}particles.png`]
|
||||
|
||||
// Notice.
|
||||
this.notice()
|
||||
|
||||
// Set initial property values.
|
||||
this.animate = animate
|
||||
this.randomize = randomize
|
||||
|
||||
// Set the default speed.
|
||||
this.speed = speed
|
||||
this.initialSpeed = this.speed
|
||||
this.dv = 20
|
||||
|
||||
// Create a canvas and acquire its context.
|
||||
this.canvas = new Canvas(create, fade)
|
||||
this.gl = this.canvas.element.getContext('webgl2')
|
||||
|
||||
// If WebGL isn't part of available features, fail.
|
||||
if (!this.gl) {
|
||||
this.canvas.destroy()
|
||||
|
||||
alert(
|
||||
'Unable to initialize WebGL 2.\nThe website will lack parallax animation.',
|
||||
)
|
||||
console.error(
|
||||
'Unable to initialize WebGL 2. Your browser or machine may not support it.',
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Get current tick and slightly randomize it if animation is enabled.
|
||||
this.tick = Number(this.animate && this.randomize) * this.randomRange(0, 10)
|
||||
|
||||
// Build shaders.
|
||||
this.prog = this.build(vglsl, fglsl)
|
||||
|
||||
if (!this.prog) {
|
||||
this.canvas.destroy()
|
||||
|
||||
alert(
|
||||
'Failed to compile WebGL 2 shaders.\nOpen the developer console for debug output.',
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Add an event listener to the canvas if animation is enabled.
|
||||
if (this.animate) this.canvas.element.onclick = this.click.bind(this)
|
||||
|
||||
// Bind class context to the resize handler, pass it to the event bus.
|
||||
window.addEventListener('resize', this.resize.bind(this))
|
||||
|
||||
// Create image loading promises.
|
||||
this.promises = images.map((image) => this.loadImage(image))
|
||||
|
||||
// Initialize the scene.
|
||||
this.initialize()
|
||||
|
||||
// Once all promises have been fulfilled, build the scene.
|
||||
Promise.all(this.promises).then((resources) => {
|
||||
// Load resources.
|
||||
resources.forEach((image, index) => this.loadResource(image, index))
|
||||
|
||||
// Bind program
|
||||
this.gl!.useProgram(this.prog!)
|
||||
|
||||
// Begin render.
|
||||
this.render()
|
||||
if (fade) this.canvas.fadeIn()
|
||||
})
|
||||
}
|
||||
|
||||
notice() {
|
||||
console.log(
|
||||
`%c \u2587%c\u2587%c\u2587 %c\u2587%c\u2587%c\u2587 %c // ECMAPortal v${this.version} by Endermanch & WiPet\n\n\t${config.url}\n\t${config.shortener}/wipet`,
|
||||
'color: #E58EFF',
|
||||
'color: #D52DFF',
|
||||
'color: #E58EFF',
|
||||
'color: #E58EFF',
|
||||
'color: #D52DFF',
|
||||
'color: #E58EFF',
|
||||
'color: #008000',
|
||||
)
|
||||
|
||||
console.log(
|
||||
`If any errors occur below, please send a screenshot of them my way!\n\t%ccontact@${fqdn}`,
|
||||
'color: #87CEFA',
|
||||
)
|
||||
}
|
||||
|
||||
randomRange(min: number, max: number): number {
|
||||
return Math.random() * (max - min) + min
|
||||
}
|
||||
|
||||
build(vshSource: string, fshSource: string): WebGLProgram | null {
|
||||
const vsh = this.gl!.createShader(this.gl!.VERTEX_SHADER)
|
||||
|
||||
this.gl!.shaderSource(vsh!, vshSource)
|
||||
this.gl!.compileShader(vsh!)
|
||||
|
||||
if (!this.gl!.getShaderParameter(vsh!, this.gl!.COMPILE_STATUS)) {
|
||||
console.error(this.gl!.getShaderInfoLog(vsh!))
|
||||
return null
|
||||
}
|
||||
|
||||
const fsh = this.gl!.createShader(this.gl!.FRAGMENT_SHADER)
|
||||
|
||||
this.gl!.shaderSource(fsh!, fshSource)
|
||||
this.gl!.compileShader(fsh!)
|
||||
|
||||
if (!this.gl!.getShaderParameter(fsh!, this.gl!.COMPILE_STATUS)) {
|
||||
console.error(this.gl!.getShaderInfoLog(fsh!))
|
||||
return null
|
||||
}
|
||||
|
||||
const program: WebGLProgram = this.gl!.createProgram()!
|
||||
|
||||
this.gl!.attachShader(program, vsh!)
|
||||
this.gl!.attachShader(program, fsh!)
|
||||
|
||||
this.gl!.linkProgram(program)
|
||||
|
||||
if (!this.gl!.getProgramParameter(program, this.gl!.LINK_STATUS)) {
|
||||
console.error(this.gl!.getProgramInfoLog(program))
|
||||
return null
|
||||
}
|
||||
|
||||
return program
|
||||
}
|
||||
|
||||
loadImage(url: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
|
||||
img.onload = () => resolve(img)
|
||||
img.onerror = reject
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
loadResource(image: HTMLImageElement, index: number) {
|
||||
const samplerParameters = [this.gl!.CLAMP_TO_EDGE, this.gl!.MIRRORED_REPEAT]
|
||||
|
||||
const sampler = this.gl!.createSampler()!
|
||||
|
||||
this.gl!.samplerParameteri(
|
||||
sampler,
|
||||
this.gl!.TEXTURE_WRAP_S,
|
||||
samplerParameters[index],
|
||||
)
|
||||
this.gl!.samplerParameteri(
|
||||
sampler,
|
||||
this.gl!.TEXTURE_WRAP_T,
|
||||
samplerParameters[index],
|
||||
)
|
||||
this.gl!.samplerParameteri(
|
||||
sampler,
|
||||
this.gl!.TEXTURE_WRAP_R,
|
||||
samplerParameters[index],
|
||||
)
|
||||
|
||||
this.gl!.samplerParameteri(
|
||||
sampler,
|
||||
this.gl!.TEXTURE_MIN_FILTER,
|
||||
this.gl!.NEAREST_MIPMAP_LINEAR,
|
||||
)
|
||||
this.gl!.samplerParameteri(
|
||||
sampler,
|
||||
this.gl!.TEXTURE_MAG_FILTER,
|
||||
this.gl!.NEAREST,
|
||||
)
|
||||
|
||||
this.gl!.bindSampler(index, sampler)
|
||||
|
||||
const texture = this.gl!.createTexture()
|
||||
|
||||
this.gl!.activeTexture(this.gl!.TEXTURE0 + index)
|
||||
this.gl!.bindTexture(this.gl!.TEXTURE_2D, texture)
|
||||
|
||||
this.gl!.texImage2D(
|
||||
this.gl!.TEXTURE_2D,
|
||||
0,
|
||||
this.gl!.RGBA,
|
||||
this.gl!.RGBA,
|
||||
this.gl!.UNSIGNED_BYTE,
|
||||
image,
|
||||
)
|
||||
this.gl!.generateMipmap(this.gl!.TEXTURE_2D)
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.uniforms = {
|
||||
modelViewMatrix: this.gl!.getUniformLocation(
|
||||
this.prog!,
|
||||
'modelViewMatrix',
|
||||
)!,
|
||||
projectionMatrix: this.gl!.getUniformLocation(
|
||||
this.prog!,
|
||||
'projectionMatrix',
|
||||
)!,
|
||||
dt: this.gl!.getUniformLocation(this.prog!, 'dt')!,
|
||||
resolution: this.gl!.getUniformLocation(this.prog!, 'canvasResolution')!,
|
||||
sky: this.gl!.getUniformLocation(this.prog!, 'sky')!,
|
||||
particles: this.gl!.getUniformLocation(this.prog!, 'particles')!,
|
||||
}
|
||||
|
||||
const vertexPosBuffer = this.gl!.createBuffer()
|
||||
const positions = new Float32Array([
|
||||
-1.0, -1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, -1.0, -1.0,
|
||||
])
|
||||
|
||||
this.gl!.bindBuffer(this.gl!.ARRAY_BUFFER, vertexPosBuffer)
|
||||
this.gl!.bufferData(this.gl!.ARRAY_BUFFER, positions, this.gl!.STATIC_DRAW)
|
||||
this.gl!.bindBuffer(this.gl!.ARRAY_BUFFER, null)
|
||||
|
||||
const vertexTexBuffer = this.gl!.createBuffer()
|
||||
const texCoords = new Float32Array([
|
||||
0.0, 1.0, 1.0, 1.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
|
||||
])
|
||||
|
||||
this.gl!.bindBuffer(this.gl!.ARRAY_BUFFER, vertexTexBuffer)
|
||||
this.gl!.bufferData(this.gl!.ARRAY_BUFFER, texCoords, this.gl!.STATIC_DRAW)
|
||||
this.gl!.bindBuffer(this.gl!.ARRAY_BUFFER, null)
|
||||
|
||||
const vertexPosLocation = 0
|
||||
|
||||
this.gl!.bindBuffer(this.gl!.ARRAY_BUFFER, vertexPosBuffer)
|
||||
this.gl!.vertexAttribPointer(
|
||||
vertexPosLocation,
|
||||
2,
|
||||
this.gl!.FLOAT,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
this.gl!.enableVertexAttribArray(vertexPosLocation)
|
||||
this.gl!.bindBuffer(this.gl!.ARRAY_BUFFER, null)
|
||||
|
||||
const vertexTexLocation = 4
|
||||
|
||||
this.gl!.bindBuffer(this.gl!.ARRAY_BUFFER, vertexTexBuffer)
|
||||
this.gl!.vertexAttribPointer(
|
||||
vertexTexLocation,
|
||||
2,
|
||||
this.gl!.FLOAT,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
this.gl!.enableVertexAttribArray(vertexTexLocation)
|
||||
this.gl!.bindBuffer(this.gl!.ARRAY_BUFFER, null)
|
||||
}
|
||||
|
||||
scene() {
|
||||
const identityMatrix = new Float32Array([
|
||||
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0,
|
||||
1.0,
|
||||
])
|
||||
|
||||
if (this.animate) {
|
||||
// Speed up the animation if the user has clicked.
|
||||
if (this.clickTime !== 0) {
|
||||
this.speed =
|
||||
this.dv * this.initialSpeed -
|
||||
Math.min(
|
||||
((Date.now() - this.clickTime) / 100) * this.initialSpeed,
|
||||
(this.dv - 1) * this.initialSpeed,
|
||||
)
|
||||
if (this.speed === this.initialSpeed) this.clickTime = 0
|
||||
}
|
||||
|
||||
// R(t) = t + dt (mod 20)
|
||||
this.tick += this.dt
|
||||
this.tick %= 20
|
||||
}
|
||||
|
||||
this.gl!.uniformMatrix4fv(
|
||||
this.uniforms.modelViewMatrix,
|
||||
false,
|
||||
identityMatrix,
|
||||
)
|
||||
this.gl!.uniformMatrix4fv(
|
||||
this.uniforms.projectionMatrix,
|
||||
false,
|
||||
identityMatrix,
|
||||
)
|
||||
|
||||
this.gl!.uniform2f(
|
||||
this.uniforms.resolution,
|
||||
this.canvas.element.width,
|
||||
this.canvas.element.height,
|
||||
)
|
||||
|
||||
this.gl!.uniform1f(this.uniforms.dt, this.tick)
|
||||
this.gl!.uniform1i(this.uniforms.sky, 0)
|
||||
this.gl!.uniform1i(this.uniforms.particles, 1)
|
||||
|
||||
this.gl!.drawArraysInstanced(this.gl!.TRIANGLES, 0, 6, 1)
|
||||
}
|
||||
|
||||
render() {
|
||||
// Make sure the viewport matches the size of the canvas' drawingBuffer.
|
||||
this.gl!.viewport(
|
||||
0,
|
||||
0,
|
||||
this.gl!.drawingBufferWidth,
|
||||
this.gl!.drawingBufferHeight,
|
||||
)
|
||||
|
||||
// Calculate the delta time.
|
||||
this.dt = ((Date.now() - this.currentTime) * this.speed) / 1000000
|
||||
this.currentTime = Date.now()
|
||||
|
||||
// Run the scene.
|
||||
this.scene()
|
||||
|
||||
// Request the next animation frame if animation is enabled.
|
||||
if (this.animate) requestAnimationFrame(this.render.bind(this))
|
||||
}
|
||||
|
||||
resize() {
|
||||
if (!this.canvas.full()) this.canvas.fill()
|
||||
|
||||
// If we have animation disabled, we still have to re-render the scene once on resize.
|
||||
if (!this.animate) requestAnimationFrame(this.render.bind(this))
|
||||
}
|
||||
|
||||
click() {
|
||||
this.clickTime = Date.now()
|
||||
}
|
||||
|
||||
play() {
|
||||
if (this.pauseTime === 0) this.pauseTime = this.currentTime
|
||||
|
||||
// Add the time that has passed since the pause to the current time.
|
||||
this.currentTime += Date.now() - this.pauseTime
|
||||
this.pauseTime = 0
|
||||
this.animate = true
|
||||
|
||||
this.render()
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (this.pauseTime !== 0) return
|
||||
|
||||
// Save the pause time to calculate the delta time.
|
||||
this.pauseTime = Date.now()
|
||||
this.animate = false
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// Doesn't need to be called usually, the garbage collector should do its job.
|
||||
// The method isn't implemented, but the pseudocode is as follows:
|
||||
|
||||
// this.gl!.deleteBuffer(vertexPosBuffer);
|
||||
// this.gl!.deleteBuffer(vertexTexBuffer);
|
||||
|
||||
// this.gl!.deleteSampler(sampler);
|
||||
// this.gl!.deleteTexture(texture);
|
||||
|
||||
this.gl!.deleteProgram(this.prog!)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// If the localStorage value is null, trigger the setup.
|
||||
// If the viewport is too small, disable by default.
|
||||
const ecmaportal: string | null | undefined = $local.getItem('ecmaportal')
|
||||
|
||||
if (ecmaportal === null) {
|
||||
const notMobile = window.innerWidth > 600
|
||||
|
||||
$local.setItem('ecmaportal', notMobile)
|
||||
animated.value = notMobile
|
||||
} else animated.value = ecmaportal === 'true'
|
||||
|
||||
const layout = document.querySelector(props.layout)
|
||||
const portal = new Portal(
|
||||
props.directory,
|
||||
props.create,
|
||||
animated.value,
|
||||
props.randomize,
|
||||
props.fade,
|
||||
props.speed,
|
||||
)
|
||||
|
||||
const click: EventListener = (e: Event) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
e.preventDefault()
|
||||
portal.click()
|
||||
}
|
||||
}
|
||||
|
||||
for (const event of ['dblclick', 'touchstart'])
|
||||
layout?.addEventListener(event, click)
|
||||
|
||||
// Save the new value to local storage.
|
||||
watch(animated, (newAnimate) => {
|
||||
$local.setItem('ecmaportal', newAnimate)
|
||||
newAnimate ? portal.play() : portal.pause()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas
|
||||
id="ecmaportal"
|
||||
class="fixed top-0 left-0 opacity-0 transition-portal -z-10"
|
||||
>
|
||||
<span>
|
||||
Your browser does not support the <canvas /> element, which is
|
||||
required for parallax animation.
|
||||
</span>
|
||||
</canvas>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<hr class="accent-text accent-gradient border-0 h-px my-4" />
|
||||
<p>
|
||||
<strong>🚧 This page is currently under construction.</strong> Expect a
|
||||
lot more content to be added as time passes.
|
||||
<em>Please report all bugs you encounter via the link in the footer</em>,
|
||||
I will make sure to sand them down.
|
||||
</p>
|
||||
<hr class="accent-text accent-gradient border-0 h-px my-4" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
default: 'Logo',
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 100,
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 100,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Title',
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: 'Description',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink
|
||||
class="flex flex-row items-center text-inherit hover:text-inherit select-none sm:m-auto lm:m-0 lt:m-0 lg:m-0"
|
||||
to="/"
|
||||
>
|
||||
<img
|
||||
class="transition-all"
|
||||
draggable="false"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
:src="props.src"
|
||||
:alt="props.alt"
|
||||
/>
|
||||
<div>
|
||||
<h2>{{ props.title }}</h2>
|
||||
<hr class="accent-text accent-gradient border-0 h-px" />
|
||||
<p>{{ props.description }}</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
a:hover > img {
|
||||
transform: scale(105%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { usePageStore } from '~/stores/pages'
|
||||
|
||||
import logo from 'assets/images/logo.avif'
|
||||
|
||||
const pageStore = usePageStore()
|
||||
const { pages } = storeToRefs(pageStore)
|
||||
|
||||
const links = toRaw(pages.value).filter((page) => page.path !== '/')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="flex flex-row flex-wrap lm:flex-row lt:flex-row sm:flex-col lg:flex-row justify-around sm:justify-start lm:justify-between lt:justify-between lg:justify-between gap-2 lg:gap-4"
|
||||
>
|
||||
<Logo
|
||||
:src="logo"
|
||||
alt="Endermanch"
|
||||
title="Enderman"
|
||||
description="official website"
|
||||
/>
|
||||
<ul
|
||||
class="flex flex-row flex-wrap items-center sm:items-start lm:items-center lt:items-center lg:items-center justify-center gap-4 m-2 sm:m-0 lm:mx-2 lt:mx-2 lg:mx-2 sm:my-2 lg:my-0"
|
||||
>
|
||||
<li v-for="(page, index) in links" :key="index" class="nav-item">
|
||||
<Route
|
||||
:path="page.path"
|
||||
:name="page.name"
|
||||
:icon="page.icon!.src"
|
||||
:alt="page.icon!.alt"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
path: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
external: Boolean,
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
default: 'Link',
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 32,
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 32,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink
|
||||
class="flex flex-row items-center gap-2 select-none text-inherit no-underline"
|
||||
active-class="active"
|
||||
:to="!props.external ? props.path : undefined"
|
||||
:href="props.external ? props.path : undefined"
|
||||
>
|
||||
<img
|
||||
draggable="false"
|
||||
:src="props.icon"
|
||||
:alt="props.alt"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
/>
|
||||
<span class="hidden lm:hidden lt:hidden sm:block">
|
||||
<strong>
|
||||
{{ props.name }}
|
||||
</strong>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.active {
|
||||
@apply px-4;
|
||||
transform: translate(2px, 2px) rotate3d(1, 1, 1, 5deg) scale(1.25);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import cogIcon from 'assets/images/icons/cog.avif'
|
||||
import pearlIcon from 'assets/images/icons/pearl.avif'
|
||||
|
||||
const config = useAppConfig()
|
||||
const { animate } = storeToRefs(usePageStore())
|
||||
|
||||
const fqdn = config.url.split('//').at(1)
|
||||
const mailTemplate = `I've just found a bug on ${config.url} and would like to report it.%0D%0A%0D%0AWebsite version: ${
|
||||
config.build.version
|
||||
}%0D%0ABuild date: ${
|
||||
config.build.date
|
||||
}%0D%0ATimestamp: ${new Date().toISOString()}%0D%0A%0D%0A%0D%0ASteps to reproduce:%0D%0A{ Explain in detail what happened here, or attach a video/screenshot }%0D%0A%0D%0AAdditional information:%0D%0A{ Helpful information, such as developer console output / Leave empty if none }%0D%0A%0D%0A%0D%0A// Keep in mind that it's just a template, you can change it as you wish! :)`
|
||||
|
||||
const active = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute top-0 right-0 flex flex-col cursor-pointer select-none p-2"
|
||||
@click="active = true"
|
||||
>
|
||||
<img
|
||||
draggable="false"
|
||||
:src="cogIcon"
|
||||
alt="Options"
|
||||
width="16"
|
||||
height="16"
|
||||
/>
|
||||
</div>
|
||||
<Transition
|
||||
enter-active-class="animate__animated animate__fadeIn"
|
||||
leave-active-class="animate__animated animate__fadeOut"
|
||||
>
|
||||
<div
|
||||
v-if="active"
|
||||
class="fixed top-0 left-0 flex flex-row justify-center items-center w-full h-full z-20"
|
||||
>
|
||||
<div
|
||||
class="relative flex flex-col gap-4 accent-overlay-background rounded-xl max-w-[800px] mx-4 p-4"
|
||||
>
|
||||
<img
|
||||
draggable="false"
|
||||
:src="pearlIcon"
|
||||
alt="Pearl"
|
||||
class="absolute -top-8 -left-6 badge select-none w-16 lg:w-auto"
|
||||
/>
|
||||
|
||||
<h3 class="text-center">Site settings</h3>
|
||||
|
||||
<div class="overlay accent-overlay-background" />
|
||||
<div class="overlay">
|
||||
<p class="mb-3">
|
||||
The site settings are experimental. Suggest what you want to see
|
||||
here next!
|
||||
<strong>Please report any bugs that may occur.</strong>
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="md:basis-0 flex-grow">
|
||||
<strong>Theme</strong>
|
||||
<p>Available in vanilla and chocolate flavors.</p>
|
||||
|
||||
<select
|
||||
v-model="$colorMode.preference"
|
||||
class="accent accent-overlay-background"
|
||||
>
|
||||
<option value="system">System</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="sepia">Sepia</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:basis-0 flex-grow">
|
||||
<strong>Animation</strong>
|
||||
<p>
|
||||
Some computers may have issues rendering that gorgeous
|
||||
background animation. Disabling it substantially decreases power
|
||||
consumption, but also makes the website less cool.
|
||||
</p>
|
||||
<input v-model="animate" type="checkbox" color="primary" />
|
||||
</div>
|
||||
</div>
|
||||
<span class="mt-3">
|
||||
<EMail
|
||||
:address="`contact@${fqdn}`"
|
||||
:cc="`admin@${fqdn}`"
|
||||
:subject="`Bug report: ${fqdn}`"
|
||||
:body="mailTemplate"
|
||||
>
|
||||
<strong>Report a bug</strong>
|
||||
</EMail>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button @click="active = false">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.badge {
|
||||
transform: rotate(-15deg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
const { pages } = storeToRefs(usePageStore())
|
||||
|
||||
const route = useRoute()
|
||||
const state = computed(() => {
|
||||
const i = pages.value.indexOf(
|
||||
pages.value.find(
|
||||
(page) => page.path === '/' + route.fullPath.split('/')[1],
|
||||
)!,
|
||||
)
|
||||
|
||||
return {
|
||||
current: route.fullPath,
|
||||
index: i,
|
||||
next: toRaw(pages.value)[(i + 1) % pages.value.length].path,
|
||||
prev: toRaw(pages.value)[i - 1 < 0 ? pages.value.length - (i + 1) : i - 1]
|
||||
.path,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex fixed flex-row justify-between items-center w-full h-full pass-through"
|
||||
>
|
||||
<NuxtLink
|
||||
class="opacity-25 hover:opacity-100 text-inherit no-underline transition-all"
|
||||
:to="state.prev"
|
||||
>
|
||||
<iconify-icon icon="iconoir:nav-arrow-left" inline />
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="control-icon text-inherit no-underline" :to="state.next">
|
||||
<iconify-icon icon="iconoir:nav-arrow-right" inline />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
|
||||
import warningIcon from 'assets/images/icons/accent/warning.avif'
|
||||
import errorIcon from 'assets/images/icons/accent/error.avif'
|
||||
import infoIcon from 'assets/images/icons/accent/info.avif'
|
||||
import helpIcon from 'assets/images/icons/accent/help.avif'
|
||||
import checkIcon from 'assets/images/icons/accent/checkmark.avif'
|
||||
import ideaIcon from 'assets/images/icons/accent/idea.avif'
|
||||
|
||||
type IconOptions = 'warning' | 'error' | 'info' | 'help' | 'checkmark' | 'idea'
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String as PropType<IconOptions>,
|
||||
default: 'warning',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Important',
|
||||
},
|
||||
})
|
||||
|
||||
const cards = {
|
||||
warning: {
|
||||
color: 'border-warning',
|
||||
icon: {
|
||||
src: warningIcon,
|
||||
alt: 'Warning triangle',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
color: 'border-error',
|
||||
icon: {
|
||||
src: errorIcon,
|
||||
alt: 'Error circle',
|
||||
},
|
||||
},
|
||||
info: {
|
||||
color: 'border-info',
|
||||
icon: {
|
||||
src: infoIcon,
|
||||
alt: 'Information circle',
|
||||
},
|
||||
},
|
||||
help: {
|
||||
color: 'border-help',
|
||||
icon: {
|
||||
src: helpIcon,
|
||||
alt: 'Help circle',
|
||||
},
|
||||
},
|
||||
checkmark: {
|
||||
color: 'border-checkmark',
|
||||
icon: {
|
||||
src: checkIcon,
|
||||
alt: 'Checkmark',
|
||||
},
|
||||
},
|
||||
idea: {
|
||||
color: 'border-idea',
|
||||
icon: {
|
||||
src: ideaIcon,
|
||||
alt: 'Lightbulb',
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tilt flex flex-row items-center gap-2 mb-0 sm:mb-2 md:mb-4">
|
||||
<img
|
||||
draggable="false"
|
||||
:src="cards[props.type].icon.src"
|
||||
:alt="cards[props.type].icon.alt"
|
||||
class="w-12 h-12"
|
||||
/>
|
||||
<h3 class="whitespace-nowrap mb-0">{{ props.title }}</h3>
|
||||
</div>
|
||||
<div
|
||||
:class="`box p-4 mb-4 ${cards[props.type].color} border-b border-r rounded-xl`"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.tilt {
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits(['copy'])
|
||||
|
||||
const clicked = ref<boolean>(false)
|
||||
|
||||
function p() {
|
||||
emit('copy')
|
||||
|
||||
if (!clicked.value) setTimeout(() => (clicked.value = false), 2000)
|
||||
clicked.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute flex flex-col justify-start top-0 left-0 w-full h-full pointer-events-none opacity-0 hover:opacity-100 transition-ease"
|
||||
>
|
||||
<div class="flex flex-row justify-end items-center p-2">
|
||||
<span
|
||||
class="overlay-action flex-grow-0 pointer-events-auto cursor-pointer p-2 border rounded-xl"
|
||||
@click="p"
|
||||
>
|
||||
<Transition name="switch" mode="out-in">
|
||||
<iconify-icon v-if="!clicked" icon="mdi:content-copy" inline />
|
||||
<iconify-icon
|
||||
v-else
|
||||
icon="mdi:check-bold"
|
||||
class="text-green-500"
|
||||
inline
|
||||
/>
|
||||
</Transition>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import type { UseSwipeDirection } from '@vueuse/core'
|
||||
import { useSwipe } from '@vueuse/core'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const card = ref<HTMLElement | null>(null)
|
||||
|
||||
const { pages, reader } = storeToRefs(usePageStore())
|
||||
|
||||
const swipe = useSwipe(card, {
|
||||
passive: true,
|
||||
onSwipeEnd: (_touch: TouchEvent, _direction: UseSwipeDirection) => {
|
||||
if (reader.value) return
|
||||
|
||||
const currentPage = pages.value.find(
|
||||
(page) => page.path === route.fullPath,
|
||||
)!
|
||||
|
||||
const currentIndex = pages.value.indexOf(currentPage)
|
||||
|
||||
if (
|
||||
card.value?.offsetWidth &&
|
||||
Math.abs(swipe.lengthX.value) > 1.5 * Math.abs(swipe.lengthY.value) &&
|
||||
Math.abs(swipe.lengthX.value) / card.value?.offsetWidth >= 0.1
|
||||
) {
|
||||
router.push(
|
||||
toRaw(pages.value)[
|
||||
swipe.lengthX.value > 0
|
||||
? (currentIndex + 1) % pages.value.length
|
||||
: currentIndex - 1 < 0
|
||||
? pages.value.length - (currentIndex + 1)
|
||||
: currentIndex - 1
|
||||
].path,
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const animationComplete = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
card.value?.addEventListener(
|
||||
'animationend',
|
||||
() => (animationComplete.value = true),
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
ref="card"
|
||||
class="dimensions accent-background transition-ease overflow-auto flex flex-col gap-4 lm:gap-0 sm:gap-2 py-2 px-6 sm:p-4"
|
||||
:class="{
|
||||
'animate__animated-sm animate__delay-1-5s animate__fadeInDown':
|
||||
!reader && !animationComplete,
|
||||
'animate__animated-sm animate__fadeIn': reader && !animationComplete,
|
||||
'lm:rounded-none sm:rounded-xl sm:mt-8 lm:mt-0 lt:mt-0': !reader,
|
||||
'!max-h-full h-full': reader,
|
||||
}"
|
||||
>
|
||||
<Settings />
|
||||
<Navigation />
|
||||
<slot name="header" />
|
||||
<NuxtPage
|
||||
class="sm:fade-mask flex-grow overflow-y-auto h-full sm:py-4 sm:pe-4"
|
||||
/>
|
||||
<slot name="footer" />
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
export default defineNuxtRouteMiddleware((to, from) => {
|
||||
const { reader } = storeToRefs(usePageStore())
|
||||
|
||||
reader.value = /\/blog\/(.*)/.test(to.path)
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
const config = useAppConfig()
|
||||
const meta = {
|
||||
title: 'Page not found',
|
||||
description:
|
||||
'The page you are looking for does not exist. It might have been removed, had its name changed, or is temporarily unavailable.',
|
||||
image: `${config.url}/images/logo.png`,
|
||||
url: config.url,
|
||||
}
|
||||
|
||||
useSeoMeta({
|
||||
title: meta.title,
|
||||
description: meta.description,
|
||||
ogTitle: meta.title,
|
||||
ogDescription: meta.description,
|
||||
ogImage: meta.image,
|
||||
ogUrl: meta.url,
|
||||
ogType: 'website',
|
||||
twitterTitle: meta.title,
|
||||
twitterDescription: meta.description,
|
||||
twitterImage: meta.image,
|
||||
twitterCard: 'summary',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Page not found',
|
||||
htmlAttrs: {
|
||||
lang: config.locale || 'en',
|
||||
},
|
||||
link: [
|
||||
{
|
||||
rel: 'icon',
|
||||
type: 'image/x-icon',
|
||||
href: '/favicon.ico',
|
||||
},
|
||||
],
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NotFound />
|
||||
</template>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { defineRouteRules } from '#imports'
|
||||
|
||||
const config = useAppConfig()
|
||||
const age =
|
||||
new Date(Number(new Date()) - Number(new Date('2003-09-18'))).getFullYear() -
|
||||
1970
|
||||
|
||||
const meta = {
|
||||
title: 'About Me',
|
||||
description: `Nice to meet you!~ I'm Andrew, a ${age}-year-old developer from Kaluga, Russia.`,
|
||||
image: `${config.url}/images/logo.png`,
|
||||
url: `${config.url}/about`,
|
||||
}
|
||||
|
||||
useSeoMeta({
|
||||
title: meta.title,
|
||||
description: meta.description,
|
||||
ogTitle: meta.title,
|
||||
ogDescription: meta.description,
|
||||
ogImage: meta.image,
|
||||
ogUrl: meta.url,
|
||||
ogType: 'website',
|
||||
twitterTitle: meta.title,
|
||||
twitterDescription: meta.description,
|
||||
twitterImage: meta.image,
|
||||
twitterCard: 'summary',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'About',
|
||||
htmlAttrs: {
|
||||
lang: config.locale || 'en',
|
||||
},
|
||||
link: [
|
||||
{
|
||||
rel: 'icon',
|
||||
type: 'image/x-icon',
|
||||
href: '/favicon.ico',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
defineRouteRules({
|
||||
sitemap: {
|
||||
changefreq: 'yearly',
|
||||
priority: 0.6,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<h3>About me</h3>
|
||||
<Construction />
|
||||
<p>
|
||||
Nice to meet you! I'm Andrew, a {{ age }}-year-old guy from Kaluga,
|
||||
Russia. I have been developing software since I was 10, and I have always
|
||||
been interested in technology. I'm a middle-senior C/++ developer and a
|
||||
junior full-stack engineer.
|
||||
<sup>1</sup>
|
||||
<br />
|
||||
<small>
|
||||
<sup>1</sup>
|
||||
All titles are based on knowledge and confidence, not on official work
|
||||
experience.
|
||||
</small>
|
||||
</p>
|
||||
<p><strong>Here's a little more about myself:</strong></p>
|
||||
<ul>
|
||||
<li>My favorite season is winter.</li>
|
||||
<li>My favorite bands are Black Sabbath, Metallica and Opeth.</li>
|
||||
<li>My favorite field in mathematics is Algebraic Geometry.</li>
|
||||
</ul>
|
||||
<p>
|
||||
I work on all sorts of projects, most of the time simultaneously, which
|
||||
doesn't help with adhering to deadlines at all. However, you can always
|
||||
check them out!
|
||||
</p>
|
||||
<h5>FAQ</h5>
|
||||
<ul class="list-style-type-faq">
|
||||
<li><strong>Why are you called Endermanch?</strong></li>
|
||||
<li>
|
||||
There's no particular reason to it. When I was rebranding my YouTube
|
||||
channel from Ender's Show to Enderman, the short URL was taken. I went
|
||||
thinking for quite some time, and then concluded that adding «ch» at the
|
||||
end sounded pretty good. I was 14 at the time of making that decision.
|
||||
</li>
|
||||
<li>
|
||||
<strong>
|
||||
When will you make a new video? What happened to your schedule?
|
||||
</strong>
|
||||
</li>
|
||||
<li>
|
||||
One day. Haven't been particularly motivated lately, but life's busy. 🙁
|
||||
I'm alive and well, though.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,230 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDate } from 'date-fns'
|
||||
import chestAnimation from 'assets/images/chest.avif'
|
||||
import { render } from 'vue'
|
||||
import { CodeControls } from '#components'
|
||||
|
||||
const config = useAppConfig()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
let slug = route.params.slug
|
||||
|
||||
if (Array.isArray(slug)) slug = slug.join('/')
|
||||
|
||||
const { data, status } = await useAsyncData('home', () =>
|
||||
queryContent(`/blog/${slug}`).findOne(),
|
||||
)
|
||||
|
||||
const meta = {
|
||||
title: data.value ? data.value.title : 'Page not found',
|
||||
description: data.value
|
||||
? data.value.description
|
||||
: 'The page you are looking for does not exist. It might have been removed, had its name changed, or is temporarily unavailable.',
|
||||
url: `${config.url}/blog`,
|
||||
}
|
||||
|
||||
// When defineOgImage is used, useSeoMeta must exclude ogImage and twitterImage properties.
|
||||
useSeoMeta({
|
||||
title: meta.title,
|
||||
description: meta.description,
|
||||
ogTitle: meta.title,
|
||||
ogDescription: meta.description,
|
||||
// ogImage: EXCLUDED
|
||||
ogUrl: meta.url,
|
||||
ogType: 'article',
|
||||
twitterTitle: meta.title,
|
||||
twitterDescription: meta.description,
|
||||
// twitterImage: EXCLUDED
|
||||
twitterCard: 'summary_large_image',
|
||||
})
|
||||
|
||||
const clipboard = (el: HTMLElement) => {
|
||||
if (el.textContent)
|
||||
navigator.clipboard.writeText(el.textContent).catch(() => {
|
||||
console.error(
|
||||
'Failed to copy element data to the clipboard! Element data:',
|
||||
el,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const content = ref<Element | null>(null)
|
||||
const thumbnail: string =
|
||||
data.value!.thumbnail ??
|
||||
`/images/blog/thumbnails/${data.value!._path!.split('/').at(-1)}.png`
|
||||
|
||||
// Generate the article's Open Graph image.
|
||||
defineOgImageComponent(
|
||||
'ArticleThumbnail',
|
||||
{
|
||||
title: data.value!.title,
|
||||
description: data.value!.description,
|
||||
logo: '/images/logo.png',
|
||||
sectionLogo: '/images/chest.png',
|
||||
thumbnail,
|
||||
alt: data.value!.title,
|
||||
},
|
||||
{
|
||||
fonts: ['Alexandria:700', 'Lato:700'],
|
||||
},
|
||||
)
|
||||
|
||||
// Hydrate the rendered items.
|
||||
onMounted(() => {
|
||||
if (route.hash)
|
||||
content.value
|
||||
?.querySelector(route.hash.toLowerCase())
|
||||
?.scrollIntoView({ behavior: 'smooth' })
|
||||
|
||||
content.value
|
||||
?.querySelectorAll('code:not(pre *)')
|
||||
.forEach((code: Element) => {
|
||||
if (code instanceof HTMLElement)
|
||||
code.addEventListener('click', () => clipboard(code))
|
||||
})
|
||||
|
||||
content.value?.querySelectorAll('pre').forEach((pre: HTMLPreElement) => {
|
||||
const overlay = h(CodeControls, { onCopy: () => clipboard(pre) })
|
||||
|
||||
render(overlay, pre)
|
||||
})
|
||||
|
||||
content.value?.querySelectorAll('a').forEach((a: HTMLAnchorElement) => {
|
||||
if (
|
||||
a.hash &&
|
||||
a.host === window.location.host &&
|
||||
a.pathname.toLowerCase() === route.path.toLowerCase()
|
||||
)
|
||||
a.addEventListener('click', (e: Event) => {
|
||||
e.preventDefault()
|
||||
|
||||
content.value
|
||||
?.querySelector(a.hash.toLowerCase())
|
||||
?.scrollIntoView({ behavior: 'smooth' })
|
||||
|
||||
history.replaceState(history.state, '', a.hash)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: data.value!.title,
|
||||
htmlAttrs: {
|
||||
lang: config.locale || 'en',
|
||||
},
|
||||
link: [
|
||||
{
|
||||
rel: 'icon',
|
||||
type: 'image/x-icon',
|
||||
href: '/favicon.ico',
|
||||
},
|
||||
],
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="!data"
|
||||
class="flex flex-col items-center justify-center gap-4 w-full select-none text-center"
|
||||
>
|
||||
<img draggable="false" :src="chestAnimation" alt="The Ender Chest" />
|
||||
<div>
|
||||
<h1 class="font-enchant">Loading document...</h1>
|
||||
<span class="opacity-0 hover:opacity-100 transition-ease">
|
||||
<em>Loading document...</em>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<article
|
||||
v-else-if="data"
|
||||
class="flex-grow post snap-normal fade-mask-sm flex flex-col gap-4 overflow-y-auto sm:py-4 sm:pe-4"
|
||||
>
|
||||
<div class="grid-thumbnail grid max-w-[768px] snap-end">
|
||||
<img
|
||||
draggable="false"
|
||||
:src="thumbnail!"
|
||||
alt="Thumbnail"
|
||||
class="grid-thumbnail-image object-cover object-center rounded-xl aspect-[16/9] select-none"
|
||||
/>
|
||||
<NuxtLink to="/blog" class="grid-thumbnail-start button-back p-2">
|
||||
<iconify-icon
|
||||
icon="icon-park-solid:back"
|
||||
width="2em"
|
||||
height="2em"
|
||||
class="drop-shadow text-pink-200"
|
||||
/>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="grid-thumbnail-end button-twitter p-2"
|
||||
:href="`https://twitter.com/share?url=${config.url}/blog/${slug}&text=${data.title}&hashtags=${data.tags.slice(0, 3).join(',').replace(/ /g, '')}`"
|
||||
target="_blank"
|
||||
>
|
||||
<iconify-icon
|
||||
icon="logos:twitter"
|
||||
width="2em"
|
||||
height="2em"
|
||||
class="drop-shadow"
|
||||
/>
|
||||
</NuxtLink>
|
||||
<div
|
||||
class="grid-thumbnail-title accent-fade w-full rounded-b-lg p-2 mt-16"
|
||||
>
|
||||
<h3 class="mb-1">{{ data.title }}</h3>
|
||||
<div class="flex flex-row flex-wrap gap-x-2 gap-y-0">
|
||||
<small class="flex flex-row items-center gap-1 whitespace-nowrap">
|
||||
<iconify-icon icon="mdi:calendar" inline />
|
||||
<strong>
|
||||
{{ formatDate(data.created, 'HH:mm • LLLL do, y') }}
|
||||
</strong>
|
||||
</small>
|
||||
<small class="flex flex-row items-center gap-1 whitespace-nowrap">
|
||||
<iconify-icon icon="mdi:clock-outline" inline />
|
||||
<strong>
|
||||
{{ data!.readingTime.text.split(' ')[0] + ' minutes to read' }}
|
||||
</strong>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section ref="content" class="page snap-start">
|
||||
<ContentRenderer :value="data" />
|
||||
</section>
|
||||
</article>
|
||||
<NotFound v-else />
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.grid-thumbnail {
|
||||
&-image {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
|
||||
place-self: start center;
|
||||
|
||||
max-width: clamp(1px, 100%, 768px);
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
&-title {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
|
||||
place-self: end center;
|
||||
}
|
||||
|
||||
&-start {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
|
||||
place-self: start start;
|
||||
}
|
||||
|
||||
&-end {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
|
||||
place-self: start end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDate } from 'date-fns'
|
||||
import { defineRouteRules } from '#imports'
|
||||
|
||||
const config = useAppConfig()
|
||||
const meta = {
|
||||
title: 'The Ender Chest',
|
||||
description:
|
||||
'A blog about software development, scientific research and Windows quirks.',
|
||||
image: `${config.url}/images/chest.png`,
|
||||
url: `${config.url}/blog`,
|
||||
}
|
||||
|
||||
useSeoMeta({
|
||||
title: meta.title,
|
||||
description: meta.description,
|
||||
ogTitle: meta.title,
|
||||
ogDescription: meta.description,
|
||||
ogImage: meta.image,
|
||||
ogUrl: meta.url,
|
||||
ogType: 'website',
|
||||
twitterTitle: meta.title,
|
||||
twitterDescription: meta.description,
|
||||
twitterImage: meta.image,
|
||||
twitterCard: 'summary',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'The Ender Chest',
|
||||
htmlAttrs: {
|
||||
lang: config.locale || 'en',
|
||||
},
|
||||
link: [
|
||||
{
|
||||
rel: 'icon',
|
||||
type: 'image/x-icon',
|
||||
href: '/favicon.ico',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
defineRouteRules({
|
||||
sitemap: {
|
||||
changefreq: 'monthly',
|
||||
priority: 0.7,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h3 class="mb-2">The Ender Chest</h3>
|
||||
<p class="mb-4">
|
||||
You're browsing the Ender Chest — a blog about software development,
|
||||
scientific research and Windows quirks.
|
||||
</p>
|
||||
<h4 class="mb-2">Recent posts</h4>
|
||||
<ContentList
|
||||
:query="{
|
||||
path: '/blog',
|
||||
where: [{ draft: false }],
|
||||
limit: 3,
|
||||
sort: [{ created: -1 }],
|
||||
}"
|
||||
>
|
||||
<template #default="{ list }">
|
||||
<div class="grid gap-4">
|
||||
<NuxtLink
|
||||
v-for="post in list"
|
||||
:key="post._path"
|
||||
:to="post._path"
|
||||
class="text-inherit hover:text-inherit no-underline"
|
||||
>
|
||||
<article
|
||||
class="post-box relative flex flex-col md:flex-row gap-4 p-2 border-2 rounded-xl transition-ease"
|
||||
>
|
||||
<img
|
||||
draggable="false"
|
||||
:src="
|
||||
post.thumbnail ??
|
||||
`/images/blog/thumbnails/${post._path!.split('/').at(-1)}.png`
|
||||
"
|
||||
:alt="post.title"
|
||||
class="md:flex-grow-[2] md:basis-0 xl:flex-grow-1 object-cover object-center aspect-[16/9] w-fit md:w-0 xl:w-fit h-full max-h-[300px] rounded-xl"
|
||||
/>
|
||||
<div class="md:flex-grow-[3] md:basis-0 xl:flex-grow-1 w-full">
|
||||
<h3 class="post-title md:line-clamp-2 md:overflow-ellipsis">
|
||||
{{ post.title }}
|
||||
</h3>
|
||||
<p
|
||||
class="post-description mb-0 md:line-clamp-4 md:overflow-ellipsis"
|
||||
>
|
||||
{{ post.description }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-row flex-wrap gap-2 py-2">
|
||||
<small
|
||||
v-for="(tag, index) in post.tags.slice(0, 3)"
|
||||
:key="index"
|
||||
class="post-tag px-2 py-1 rounded-xl border whitespace-nowrap"
|
||||
>
|
||||
{{ tag }}
|
||||
</small>
|
||||
<small
|
||||
v-if="post.tags.length > 3"
|
||||
class="post-tag px-2 py-1 rounded-xl border whitespace-nowrap"
|
||||
>
|
||||
{{ post.tags.length - 3 }} more
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="post-details py-2">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<iconify-icon icon="mdi:calendar" />
|
||||
<small class="whitespace-nowrap">
|
||||
{{ formatDate(post.created, 'HH:mm • LLLL do, y') }}
|
||||
</small>
|
||||
</div>
|
||||
<div
|
||||
v-if="post.updated"
|
||||
class="flex flex-row items-center gap-2"
|
||||
>
|
||||
<iconify-icon icon="mdi:pencil" />
|
||||
<small class="whitespace-nowrap">
|
||||
{{ formatDate(post.updated, 'HH:mm • LLLL do, y') }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="post-pocket border-l border-t absolute bottom-0 right-0 uppercase rounded-tl-xl flex flex-row items-center gap-2 p-2"
|
||||
>
|
||||
<iconify-icon icon="mdi:clock-outline" />
|
||||
<small class="whitespace-nowrap font-mono font-small">
|
||||
{{ post.readingTime.text }}
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #not-found>
|
||||
<div class="flex flex-col justify-center items-center gap-4">
|
||||
<span>No posts found 🙁</span>
|
||||
<NuxtLink to="/">Back to index</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</ContentList>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { defineRouteRules } from '#imports'
|
||||
|
||||
const config = useAppConfig()
|
||||
const meta = {
|
||||
title: 'Enderman',
|
||||
description: config.description,
|
||||
image: `${config.url}/images/logo.png`,
|
||||
url: config.url,
|
||||
}
|
||||
|
||||
useSeoMeta({
|
||||
title: meta.title,
|
||||
description: meta.description,
|
||||
ogTitle: meta.title,
|
||||
ogDescription: meta.description,
|
||||
ogImage: meta.image,
|
||||
ogUrl: meta.url,
|
||||
ogType: 'website',
|
||||
twitterTitle: meta.title,
|
||||
twitterDescription: meta.description,
|
||||
twitterImage: meta.image,
|
||||
twitterCard: 'summary',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Enderman',
|
||||
htmlAttrs: {
|
||||
lang: 'en',
|
||||
},
|
||||
link: [
|
||||
{
|
||||
rel: 'icon',
|
||||
type: 'image/x-icon',
|
||||
href: '/favicon.ico',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
defineRouteRules({
|
||||
sitemap: {
|
||||
changefreq: 'yearly',
|
||||
priority: 1,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<h3>Welcome 👋</h3>
|
||||
<p>
|
||||
I'm <strong>Enderman</strong> – a software engineer, a malware
|
||||
enthusiast and most importantly, a weird tall creature. I have over 300K
|
||||
subscribers on <a :href="`${config.shortener}/youtube`">YouTube</a> and
|
||||
over 25K followers on
|
||||
<a :href="`${config.shortener}/twitter`">Twitter</a>. Sometimes I wish
|
||||
there were 48 hours in a day.
|
||||
</p>
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="md:basis-0 md:flex-grow-[2]">
|
||||
<h6>What I do</h6>
|
||||
<ul class="list-style-type-do">
|
||||
<li>
|
||||
I develop cross-platform software. My language of choice is usually
|
||||
<strong>C/C++</strong> for desktop applications and any common
|
||||
backend stack with framework-loaded TypeScript for web. You can take
|
||||
a look at my code on
|
||||
<a :href="`${config.shortener}/github`">GitHub</a>.
|
||||
</li>
|
||||
<li>
|
||||
I have the most unnecessary, yet fascinating knowledge about
|
||||
Microsoft Windows, and I absolutely love sharing it with my viewers
|
||||
in an unorthodox comedic fashion.
|
||||
<sub>
|
||||
<em>*Microsoft still has not hired me as a software tester.</em>
|
||||
</sub>
|
||||
</li>
|
||||
<li>
|
||||
I research and analyze modern malware, educate computer users about
|
||||
it and preserve history. The repository can be found
|
||||
<a :href="`${config.shortener}/repository`">here</a>.
|
||||
</li>
|
||||
<li>I make videos for you to enjoy!</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="md:basis-0 md:flex-grow-[1]">
|
||||
<h6>What I enjoy</h6>
|
||||
<ul class="list-style-type-enjoy">
|
||||
<li>Teaching</li>
|
||||
<li>Mathematics</li>
|
||||
<li>Technology</li>
|
||||
<li>Video Production</li>
|
||||
<li>Philosophy</li>
|
||||
<li>Geopolitics</li>
|
||||
<li>
|
||||
<a :href="`${config.shortener}/chess`">Chess</a>
|
||||
</li>
|
||||
<li>Solitude</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
<strong>Want to learn more?</strong> Find out more about me using the
|
||||
navigation bar!<br />
|
||||
<em>See you there!~</em>
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { defineRouteRules } from '#imports'
|
||||
|
||||
const config = useAppConfig()
|
||||
const projects = [
|
||||
{
|
||||
name: 'MalwareWatch',
|
||||
description:
|
||||
'A website that tracks malware and ransomware attacks in real time.',
|
||||
url: 'https://malwarewatch.org',
|
||||
},
|
||||
{
|
||||
name: 'You Are an Idiot! (dot cc)',
|
||||
description: 'A recreation of the infamous "You Are an Idiot!" virus.',
|
||||
url: 'https://youareanidiot.cc',
|
||||
},
|
||||
{
|
||||
name: 'HURR-DURR (dot cc)',
|
||||
description: 'A recreation of the infamous "HURR-DURR" virus.',
|
||||
url: 'https://hurr-durr.cc',
|
||||
},
|
||||
{
|
||||
name: 'ClickSword',
|
||||
description: 'A clicker game with a sword theme.',
|
||||
url: 'https://clicksword.org',
|
||||
},
|
||||
{
|
||||
name: 'Windows XP Keygen / UMSKT',
|
||||
description: 'A key generator for Windows XP.',
|
||||
url: `${config.shortener}/xpkeygen`,
|
||||
},
|
||||
]
|
||||
|
||||
const meta = {
|
||||
title: "Enderman's Projects",
|
||||
description:
|
||||
"A display of my largest projects. Some of them aren't published on YouTube.",
|
||||
image: `${config.url}/images/logo.png`,
|
||||
url: `${config.url}/projects`,
|
||||
}
|
||||
|
||||
useSeoMeta({
|
||||
title: meta.title,
|
||||
description: meta.description,
|
||||
ogTitle: meta.title,
|
||||
ogDescription: meta.description,
|
||||
ogImage: meta.image,
|
||||
ogUrl: meta.url,
|
||||
ogType: 'website',
|
||||
twitterTitle: meta.title,
|
||||
twitterDescription: meta.description,
|
||||
twitterImage: meta.image,
|
||||
twitterCard: 'summary',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Projects',
|
||||
htmlAttrs: {
|
||||
lang: config.locale || 'en',
|
||||
},
|
||||
link: [
|
||||
{
|
||||
rel: 'icon',
|
||||
type: 'image/x-icon',
|
||||
href: '/favicon.ico',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
defineRouteRules({
|
||||
sitemap: {
|
||||
changefreq: 'monthly',
|
||||
priority: 0.7,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<h3>Projects</h3>
|
||||
<Construction />
|
||||
<p><strong>My current projects are:</strong></p>
|
||||
<ul>
|
||||
<li v-for="(item, index) in projects" :key="index">
|
||||
<a :href="item.url">
|
||||
{{ item.name }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
The list above is not exhaustive and only includes the most recent and
|
||||
largest projects I remembered while constructing this page.
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import { defineRouteRules } from '#imports'
|
||||
|
||||
const config = useAppConfig()
|
||||
const fqdn = config.url.split('//')[1]
|
||||
|
||||
const socials = [
|
||||
{
|
||||
name: 'YouTube',
|
||||
url: `${config.shortener}/youtube`,
|
||||
icon: {
|
||||
name: 'logos:youtube-icon',
|
||||
color: 'white',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Andrew',
|
||||
url: `${config.shortener}/andrew`,
|
||||
icon: {
|
||||
name: 'logos:youtube-icon',
|
||||
color: 'white',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Twitch',
|
||||
url: `${config.shortener}/twitch`,
|
||||
icon: {
|
||||
name: 'fa:twitch',
|
||||
color: '#B68CFF',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'TikTok',
|
||||
url: `${config.shortener}/tiktok`,
|
||||
icon: {
|
||||
name: 'logos:tiktok-icon',
|
||||
color: 'white',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Twitter',
|
||||
url: `${config.shortener}/twitter`,
|
||||
icon: {
|
||||
name: 'logos:twitter',
|
||||
color: 'white',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Telegram',
|
||||
url: `${config.shortener}/telegram`,
|
||||
icon: {
|
||||
name: 'logos:telegram',
|
||||
color: 'white',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Discord',
|
||||
url: `${config.shortener}/discord`,
|
||||
icon: {
|
||||
name: 'logos:discord-icon',
|
||||
color: 'white',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'GitHub',
|
||||
url: `${config.shortener}/github`,
|
||||
icon: {
|
||||
name: 'fa:github',
|
||||
color: 'white',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Steam',
|
||||
url: `${config.shortener}/steam`,
|
||||
icon: {
|
||||
name: 'fa:steam',
|
||||
color: 'white',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const meta = {
|
||||
title: 'Enderman Online',
|
||||
description:
|
||||
'Discover me on your favorite social media and find ways to contact me here.',
|
||||
image: `${config.url}/images/logo.png`,
|
||||
url: `${config.url}/projects`,
|
||||
}
|
||||
|
||||
useSeoMeta({
|
||||
title: meta.title,
|
||||
description: meta.description,
|
||||
ogTitle: meta.title,
|
||||
ogDescription: meta.description,
|
||||
ogImage: meta.image,
|
||||
ogUrl: meta.url,
|
||||
ogType: 'website',
|
||||
twitterTitle: meta.title,
|
||||
twitterDescription: meta.description,
|
||||
twitterImage: meta.image,
|
||||
twitterCard: 'summary',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Socials',
|
||||
htmlAttrs: {
|
||||
lang: config.locale || 'en',
|
||||
},
|
||||
link: [
|
||||
{
|
||||
rel: 'icon',
|
||||
type: 'image/x-icon',
|
||||
href: '/favicon.ico',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
defineRouteRules({
|
||||
sitemap: {
|
||||
changefreq: 'yearly',
|
||||
priority: 0.7,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<h3>Online presence</h3>
|
||||
<Construction />
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="md:basis-0 md:flex-grow-[1]">
|
||||
<h5>Social media</h5>
|
||||
<ul class="list-style-type-none p-0">
|
||||
<li v-for="(page, index) in socials" :key="index">
|
||||
<NuxtLink target="_blank" rel="noopener" :href="page.url">
|
||||
<iconify-icon
|
||||
:icon="page.icon.name"
|
||||
:style="{ color: page.icon.color }"
|
||||
width="1em"
|
||||
height="1em"
|
||||
inline
|
||||
/>
|
||||
<span class="mx-2">{{ page.name }}</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="md:basis-0 md:flex-grow-[3]">
|
||||
<h5>Contact me</h5>
|
||||
<p>
|
||||
Personal:
|
||||
<EMail :address="`contact@${fqdn}`" subject="Hey Enderman!" /><br />
|
||||
Manager: <EMail :address="`manager@${fqdn}`" /><br />
|
||||
Abuse: <EMail :address="`abuse@${fqdn}`" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
export default defineNuxtPlugin(() => {
|
||||
return {
|
||||
provide: {
|
||||
local: {
|
||||
getItem(item: string) {
|
||||
if (import.meta.client) {
|
||||
return localStorage.getItem(item)
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
|
||||
setItem(item: string, value: any) {
|
||||
if (import.meta.client) {
|
||||
return localStorage.setItem(item, value)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import aboutIcon from 'assets/images/icons/accent/info.avif'
|
||||
import projectIcon from 'assets/images/icons/defrag.avif'
|
||||
import socialIcon from 'assets/images/icons/user.avif'
|
||||
import blogIcon from 'assets/images/icons/book.avif'
|
||||
|
||||
export const usePageStore = defineStore('page', () => {
|
||||
const title: string = 'Enderman'
|
||||
const description: string = 'official website'
|
||||
const keywords: string =
|
||||
'endermanch, enderman, developer, youtuber, filmmaker, artist, engineer'
|
||||
|
||||
const pages = ref([
|
||||
{
|
||||
name: 'Home',
|
||||
path: '/',
|
||||
},
|
||||
{
|
||||
name: 'About',
|
||||
path: '/about',
|
||||
icon: {
|
||||
src: aboutIcon,
|
||||
alt: 'Information',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Projects',
|
||||
path: '/projects',
|
||||
icon: {
|
||||
src: projectIcon,
|
||||
alt: 'Blocks',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Socials',
|
||||
path: '/social',
|
||||
icon: {
|
||||
src: socialIcon,
|
||||
alt: 'Users',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Blog',
|
||||
path: '/blog',
|
||||
icon: {
|
||||
src: blogIcon,
|
||||
alt: 'Book',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const reader = ref<boolean>(false)
|
||||
const animate = ref<boolean>(false)
|
||||
const dark = ref<boolean>(false)
|
||||
|
||||
function _autoFetchPages() {
|
||||
while (pages.value.length) pages.value.pop()
|
||||
|
||||
useRouter()
|
||||
.getRoutes()
|
||||
.forEach((route) =>
|
||||
pages.value.push({
|
||||
name: route.path.slice(1)[0].toUpperCase() + route.path.slice(2),
|
||||
path: route.path,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
keywords,
|
||||
pages,
|
||||
reader,
|
||||
animate,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import packageJSON from './package.json'
|
||||
|
||||
interface TitleConfig {
|
||||
full: string
|
||||
short: string
|
||||
}
|
||||
|
||||
interface BuildConfig {
|
||||
date?: string
|
||||
version: string
|
||||
}
|
||||
|
||||
type config = {
|
||||
url: string
|
||||
shortener: string
|
||||
name: string
|
||||
title: TitleConfig
|
||||
description: string
|
||||
locale: string
|
||||
build: BuildConfig
|
||||
}
|
||||
|
||||
export default {
|
||||
url: 'https://enderman.ch',
|
||||
shortener: 'https://go.enderman.ch',
|
||||
name: packageJSON.name || 'app',
|
||||
title: {
|
||||
full: "Enderman's Website",
|
||||
short: 'Enderman',
|
||||
},
|
||||
description:
|
||||
'A software engineer, a malware enthusiast and, most importantly, a weird tall creature. I have over 300K subscribers on YouTube and over 20K followers on Twitter.',
|
||||
locale: 'en',
|
||||
build: {
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
version: packageJSON.version || '0.0.0',
|
||||
},
|
||||
} satisfies config as config
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineCollection, defineContentConfig } from '@nuxt/content'
|
||||
import { asSitemapCollection } from '@nuxtjs/sitemap/content'
|
||||
|
||||
export default defineContentConfig({
|
||||
collections: {
|
||||
content: defineCollection(
|
||||
asSitemapCollection({
|
||||
type: 'page',
|
||||
source: '**/*.md',
|
||||
}),
|
||||
),
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
title: Hello World!
|
||||
description: I created this post to test @nuxt/content.
|
||||
authors: ['Enderman']
|
||||
created: 2023-08-12T16:40:09Z
|
||||
updated: 2024-02-18T20:31:54Z
|
||||
draft: false
|
||||
tags: ['hello', 'world']
|
||||
---
|
||||
|
||||
# Hello!
|
||||
|
||||
This is an example piece of content.
|
||||
|
||||
Here's some code:
|
||||
|
||||
```js
|
||||
console.log('Hello, world!');
|
||||
```
|
||||
|
||||
And here's a list:
|
||||
* Item 1
|
||||
* Item 2
|
||||
* ```css
|
||||
.example {
|
||||
color: red;
|
||||
}
|
||||
```
|
||||
* `df -h`
|
||||
|
||||
And here's a link to [Nuxt.js](https://nuxtjs.org/).
|
||||
|
||||
And here's an image:
|
||||

|
||||
|
||||
@@ -0,0 +1,714 @@
|
||||
---
|
||||
title: The Windows Context Menu – Is It a Lost Cause?
|
||||
description: A deep dive into the Windows context menu API and customization options it offers. Shell components for beginners and context menu cleanup.
|
||||
authors: ['Enderman']
|
||||
created: 2023-08-13T15:51:19Z
|
||||
updated: 2024-02-18T21:32:12Z
|
||||
draft: false
|
||||
tags: ['windows', 'context menu', 'customization', 'registry', 'windows tweaks', 'shell', 'shell extensions', 'shell components', 'context menu handlers', 'windows 10', 'windows 11']
|
||||
---
|
||||
|
||||
# The Windows context menu is... poorly made.
|
||||
Third-party software developers adding oil into this dumpster fire aren't helping with it.
|
||||
**It's not their fault**, though,
|
||||
as there is no common standard and almost no documentation for adding entries to the context menu.
|
||||
|
||||
It's safe to say that currently, there's no salvation for it.
|
||||
At the very least, it's working and serving millions of users.
|
||||
The new Windows 11 context menu shows Microsoft keeps pressing onward with their all-time classic:
|
||||
writing new, less friendly, less extensible APIs from scratch,
|
||||
simultaneously piling up layers upon layers of untested code atop the previous versions they can't entirely get rid of due to deep backwards compatibility problems that will inevitably arise.
|
||||
|
||||
I want to dedicate this article to _actually customizing_ the Windows 10
|
||||
(incidentally, also 11 Legacy) context menu to your liking
|
||||
instead of just complaining about its inner workings.
|
||||
After reading this article completely, you should be able to bring your menu from this abomination...
|
||||
|
||||

|
||||
|
||||
**...to this!**
|
||||
|
||||

|
||||
|
||||
Be wary — there's _plenty_ of registry digging involved. And the terrible part is...
|
||||
|
||||
**Forget any logic when dealing with registry keys.**
|
||||
Nowadays, registry structure is the least of all concerns.
|
||||
The idea of using registry as a coherent setting list has long since been abandoned,
|
||||
and nobody seems to care about it, as the end user doesn't.
|
||||
If someone does — they know what they're doing, and they will likely not complain,
|
||||
because they have abused registry the same way at least once. 😉
|
||||
|
||||
Whilst there are layers of abstraction applied to the most Windows settings,
|
||||
the context menu is, unfortunately, left aside.
|
||||
And much to our dismay, its backend is also janky, terrible and tangled up.
|
||||
The only fair question to reader from me is **«Do you really think it's worth your time?»**.
|
||||
And if your answer is an astounding **«Hell yeah»**,
|
||||
and you are as much of a perfectionist as I am, buckle up and prepare for several hours of pure joy.
|
||||
|
||||
::card
|
||||
---
|
||||
type: warning
|
||||
title: Before we begin
|
||||
---
|
||||
Invoking `sfc /scannow` or `dism /restorehealth` will likely interfere with your context menu,
|
||||
as Microsoft is peculiar — they had customization via direct registry manipulation in mind for more granular control,
|
||||
but their tools **aren't adapted** to store so meticulously tweaked settings.
|
||||
This isn't anything new — that hasn't changed ever since integrity checks were introduced in Windows XP.
|
||||
_No need to worry, though!_ The aforementioned tools will keep working; however, some of your tweaks might be reset
|
||||
upon execution.
|
||||
::
|
||||
|
||||
## Context menu components
|
||||
There are two main components the context menu is composed of:
|
||||
|
||||
* **Shell entries** — provided locally and managed by the registry;
|
||||
* **Context menu handlers** — provided and managed by external software.
|
||||
|
||||
Neither of them is documented well, but
|
||||
there is some developer documentation for handlers available [on Microsoft's website](https://go.enderman.ch/Fm4t3).
|
||||
It explains how they work internally, but that doesn't really help our case.
|
||||
As for the shell entries, I couldn't find anything.
|
||||
|
||||
Certainly a shame! Shell entries are so much more flexible and customizable.
|
||||
In fact, **you don't get any customization** with context menu handlers whatsoever,
|
||||
unless the developer was paid and bothered enough to read most of the documentation above.
|
||||
|
||||
To let you tweak context menu settings directly through their app,
|
||||
they have to implement **both the handler and the endpoints** for handler settings,
|
||||
which are commonly simple registry values.
|
||||
Microsoft hasn't provided any convention for software developers to follow,
|
||||
so you're at the mercy of them providing you with a friendly user interface to tweak the handler.
|
||||
|
||||
[7-Zip](https://go.enderman.ch/7-zip) serves as a neat example of great software design —
|
||||
the developer here allows you to extensively tweak their handler.
|
||||
|
||||

|
||||
|
||||
[MobaXterm](https://go.enderman.ch/mobaxterm) also is an illustrative example of the case where you can customize
|
||||
the shell integration from the application itself.
|
||||
The developers of that software use shell entries instead of context menu handlers, which seems like an odd choice
|
||||
for this case.
|
||||
_It seems like someone was underpaid!_ 😊
|
||||
|
||||

|
||||
|
||||
In conclusion, there are three types of applications in terms of friendliness towards context menu customization:
|
||||
|
||||
* **Apps using context menu handlers with customizable settings** — the friendliest;
|
||||
* **Apps using shell entries** — customizable through registry hacks;
|
||||
* **Apps using context menu handlers without settings** — the entries are uneditable.
|
||||
|
||||
The first case is the simplest and isn't worth talking about —
|
||||
consult the documentation for the software in question to find all the necessary tweaks.
|
||||
**The rest of the cases are discussed in this article.**
|
||||
|
||||
### Shell entries
|
||||
For starters, all the tweaking happens inside `HKEY_CLASSES_ROOT` — alias for `HKEY_LOCAL_MACHINE\Software\Classes`.
|
||||
That registry key contains all the data about shell components and file extension handlers.
|
||||
|
||||
For example, data under the `exefile` key will exclusively apply to files with the `.exe` extension.
|
||||
A trusty old method [«companion» viruses](https://go.enderman.ch/Kvo3u) utilized back in the day to «accompany» their victims —
|
||||
the executable (`.exe`/`.com`) files was a simple pass-through setup:
|
||||
the virus executable was specified as the handler for all other executable files.
|
||||
A belarusian virus from 2005, [Neshta.A](https://go.enderman.ch/2WeQ0), worked exactly this way and caused heaps of damage.
|
||||
|
||||
Since all the settings inside the `HKCR` key are divided into types,
|
||||
editing the context menu for each file type and shell extension manually is unavoidable.
|
||||
|
||||
_Yeah..._
|
||||
|
||||
This is **abysmal**, and, unfortunately, there is no better approach.
|
||||
While you're likely already reconsidering your choices, let me try to pull you back in and explain the basics.
|
||||
That way you can at least tweak the menu for the extensions you work with the most
|
||||
and bring the most noticeable context menu areas up to par...
|
||||
|
||||
There _are_ some minor generalizations present in the key.
|
||||
For instance, there are wildcard entries for **every** file system object,
|
||||
**every** image, **every** media file and so on.
|
||||
Nonetheless, Microsoft has taken a horrible approach with the context menu from the beginning.
|
||||
Even though such a disarray always annoyed me, I know full well there's no way for them to fix it now.
|
||||
_That's how sometimes life is._
|
||||
We'll tackle the generalized keys a bit later, let's start simple.
|
||||
|
||||
#### General structure
|
||||
The following figure is what seems to be the general structure of an extension registry key:
|
||||
|
||||

|
||||
|
||||
The structure may vary for some extensions, but you can almost be sure you're in the right place if the key contains the `Shell` subkey.
|
||||
Children of the `Shell` subkey are the «shell entries» — locally provided customizable entries of the context menu.
|
||||
|
||||
::card
|
||||
---
|
||||
type: info
|
||||
title: Clearing up the confusion
|
||||
---
|
||||
The terminology may be a little confusing, since Microsoft hasn't ever offered a standard nomenclature for the
|
||||
context menu. The article tosses plenty of loose terms around, so here's the nomenclature I have resorted to
|
||||
for better understanding:
|
||||
* **Shell** is the Windows Explorer;
|
||||
* **Shell entry** is a singular entry within the context menu — the atomic component;
|
||||
* **Shell integration** is the general footprint of software within the context menu and may consist of one or
|
||||
more shell entries;
|
||||
* **Shell extension** is a general term for a plugin for the Windows Explorer — the **context menu handler** is a
|
||||
particular case of a shell extension;
|
||||
* **Shell component** is a broad term for a component that helps the shell interact with the system — that includes
|
||||
shell extensions, file extensions, protocols, etc.
|
||||
|
||||
If this doesn't show how big of a predicament Microsoft is in with the context menu, I don't know what does.
|
||||
I tried my best to keep the technical language as clear as possible. 😒
|
||||
::
|
||||
|
||||
#### Editing entries
|
||||
Locally provided entries always available for a quick and dirty edit are stored under the `Shell` subkey.
|
||||
Entries provided by software — the context menu handlers — are stored under the `ShellEx` subkey.
|
||||
|
||||

|
||||
|
||||
Since registry key names are case-insensitive by design in Windows, capitalization plays no role.
|
||||
**The value names, however, are** — keep that in mind, as in some cases it might bite.
|
||||
The context menu doesn't seem to care.
|
||||
|
||||
_Truly an amazing specification!_
|
||||
|
||||
|
||||
#### Executing entries
|
||||
A shell entry must contain the `command` subkey to execute commands.
|
||||
The **default value** of the subkey controls execution on click of the shell entry and follows Batch syntax...
|
||||
_to some extent_. You cannot have «extended» Batch logic in the entries,
|
||||
which immeasurably disappointed me, so much so, that I wrote two neat utilities called
|
||||
[quiet](https://go.enderman.ch/elevate) and [elevate](https://go.enderman.ch/elevate)<sup>sudo for Windows [anyone](https://go.enderman.ch/XuDXW)?</sup> for that specific purpose.
|
||||
|
||||
No matter how hard I tried, I could not find the exact specification of the incomplete Batch logic the default value uses.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
My suggestion is to treat the default `command` value as **raw command line** (e.g. Run box),
|
||||
with an exception to the following two macros:
|
||||
* `%1` — the absolute file path without quotes
|
||||
When the shell entry is executed after a right-click on `C:\Users\bleh\p.txt`,
|
||||
the `%1` in the context of the command line is equivalent to `C:\Users\bleh\p.txt`;
|
||||
* `%V` — the absolute directory path without quotes.
|
||||
|
||||
::card
|
||||
---
|
||||
type: error
|
||||
title: Inconsistency
|
||||
---
|
||||
Keep in mind that the `%1` macro is the Batch version of `argv[1]` — the first item in the C argument vector.
|
||||
When a context menu is invoked from within, say, a library, the argument vector stops making sense
|
||||
because a library is a Windows Shell feature, meaning it's sophisticated. Opening a library internally
|
||||
**requires a [different API](https://go.enderman.ch/KuKR4)** to opening a folder, or even executing a file.
|
||||
In such cases, the `%1` macro **does not exist** in the context of the command line. This is also likely
|
||||
the reason why libraries are in a separate category to folders in terms of shell entries.
|
||||
The directory macro `%V` also sometimes falls flat depending on the shell entry it's being invoked from.
|
||||
**Brilliant consistency!**
|
||||
::
|
||||
|
||||
On rare occasions, the default value is left unset, with `DelegateExecute` alongside it containing a GUID.
|
||||
When that's the case, the command handler is implemented through the `IExecuteCommand` COM interface,
|
||||
and hence cannot be customized by normal means.
|
||||
|
||||
**Why is that a thing?** Ask me a simpler question. It could be a compatibility layer for C, but your guess is as good as mine.
|
||||
|
||||

|
||||
|
||||
#### Adding entries
|
||||
Making new entries isn't as bad as managing existing ones,
|
||||
so I managed to scrabble it into a sort of algorithm, if that makes sense:
|
||||
|
||||
1. Locate the target extension.
|
||||
2. **Careful!** Some extensions may be buried within other extensions.
|
||||
As an example, the [Folder Background](#folder-background) extension is located under the [Folder](#folder) extension `Directory` key in the `Background` subkey.
|
||||
3. Navigate to the `shell` key.
|
||||
If there is none, and you're _confident_ there should be,
|
||||
try creating one — **chances are high** it isn't going to break Windows.<sub>(no, seriously)</sub>
|
||||
4. Create a new key, the name **must be alphanumeric**. The name is case-insensitive.
|
||||
5. Create the `command` subkey.
|
||||
6. Specify the command line you want to execute on click in the **default value**.
|
||||
7. The entry should appear in the context menu. If it does not, restart Explorer.
|
||||
If it still doesn't appear, you probably screwed up the procedure — return to step 1.
|
||||
If you verified everything for sure, then the extension is simply **not registered** in the shell.
|
||||
|
||||
#### Nesting entries
|
||||
You can nest entries too!
|
||||
1. Repeat the first 4 steps from [Adding entries](#adding-entries).
|
||||
Don't add the `command` key just yet.
|
||||
2. Create another `shell` key.
|
||||
3. Inside it, create a subkey **called anything else, but** `command`.
|
||||
4. Working within the subkey, repeat steps 5–7.
|
||||
5. Navigate back to the root shell entry
|
||||
6. Create an empty `REG_SZ` value called `SubCommands`.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
It's possible to have an entry include a single nested entry as well.
|
||||
|
||||
|
||||

|
||||
|
||||
To control the parent context menu entry, use the outer `Shell` subkey.
|
||||
|
||||

|
||||
|
||||
To control its children, use the inner `Shell` subkeys.
|
||||
|
||||

|
||||
|
||||
#### Customizing entries further
|
||||
There are a plethora of options when it comes to customizing the shell entries.
|
||||
_Too bad they're all undocumented._
|
||||
By default, the context menu items **borrow their names** from the respective shell entries they're owned by.
|
||||
|
||||
##### Name
|
||||
To override the default name,
|
||||
you can either use the default value or specify a `REG_SZ` value called `MUIVerb` inside the respective `shell` key
|
||||
for the shell entry.
|
||||
MUIVerb takes precedence over the default value.
|
||||
|
||||
When it comes to the name, the sky is the limit.
|
||||
_Actually, Unicode is the limit._
|
||||
Either way, put in anything you want; **it's going to work**.
|
||||
|
||||
##### Icon
|
||||
To enable the icon for your shell entry,
|
||||
you should use the `REG_SZ` value called `Icon` inside the respective `shell` key for the shell entry.
|
||||
|
||||
It uses that unmistakably distinct and clumsy **WinAPI resource syntax** to specify a direct link to the icon within
|
||||
a file containing resources.
|
||||
|
||||
```csv
|
||||
PATH_TO_FILE_WITH_ICON_RESOURCES,INDEX
|
||||
```
|
||||
|
||||
If you want to select the first icon (in that case, the `INDEX` part is 0),
|
||||
the `INDEX` part can be omitted completely:
|
||||
|
||||
```csv
|
||||
PATH_TO_FILE_WITH_ICON_RESOURCES
|
||||
```
|
||||
|
||||
The following expressions are completely valid and are going to work:
|
||||
|
||||
```csv
|
||||
"C:\Windows\system32\cmd.exe"
|
||||
C:\Windows\regedit.exe
|
||||
"C:\Windows\explorer.exe",0
|
||||
C:\Windows\system32\cmd.exe,0
|
||||
cmd.exe
|
||||
shell32.dll,3
|
||||
SHELL32.DLL,10
|
||||
"C:\Windows\system32\SHELL32.DLL",9
|
||||
```
|
||||
|
||||
You can omit the quotes and absolute paths depending on the `%PATH%` variable that unholy concoction of an API uses.
|
||||
**Spoiler:** they don't match against the system `%PATH%`.
|
||||
Only God knows what paths it's able to parse by default.
|
||||
`C:\Windows` and `C:\Windows\system32` always work for sure, though.
|
||||
|
||||
It's still highly recommended to supply the absolute path, the `%PATH%` variables of the shell icon picker and the
|
||||
shell entry **do not seem to correspond**.
|
||||
|
||||
::card
|
||||
---
|
||||
type: idea
|
||||
title: Tip
|
||||
---
|
||||
You can use the Explorer folder icon picker to acquire the icon DLL and visually calculate the offset
|
||||
of the desired icon.
|
||||
For instance, the one I selected on the screenshot has an index of `5`.
|
||||
The indexing begins with 0 and **ascends in columns**.
|
||||
::
|
||||
|
||||

|
||||
|
||||
It's also possible to pull resource strings from files containing string tables.
|
||||
|
||||

|
||||
|
||||
##### Separators
|
||||
Another integral part of the context menu, which is hardly documented by anyone.
|
||||
Managing them is also rather quirky and takes getting used to.
|
||||
Separators in shell entries are relative to the item they're being enabled for and **can overlap** without any issue.
|
||||
|
||||
Use `SeparatorBefore` and `SeparatorAfter` empty `REG_SZ` values
|
||||
to add a separator before or after the shell entry being edited, respectively.
|
||||
|
||||

|
||||
|
||||
##### Appear in the Shift+Click context menu only
|
||||
It's possible to «hide» the shell entry from an ordinary context menu right-click.
|
||||
Such a shell entry is called «extended».
|
||||
|
||||
Enable that functionality by creating an empty `REG_SZ` value called `Extended` inside the `shell` key for the
|
||||
respective shell entry.
|
||||
|
||||
##### Hide in safe mode
|
||||
To disable the shell entry in safe mode,
|
||||
create an empty `REG_SZ` value called `HideInSafeMode` inside the `shell` key for the respective shell entry.
|
||||
|
||||
##### Never default
|
||||
The bold context menu item shown at the top is considered to be the default action for the file type.
|
||||
An example of a default shell entry would be «Open» for folders.
|
||||
|
||||

|
||||
|
||||
There can be no default entry at all, and that's perfectly fine.
|
||||
|
||||
To disable the shell entry from becoming default,
|
||||
add an empty `REG_SZ` value called `NeverDefault` inside the `shell` key for the respective shell entry.
|
||||
|
||||
##### Pass to context menu handler
|
||||
If there is a context handler available for invocation on shell entry click, it's possible to specify a so-called Verb Handler.
|
||||
|
||||
Create a new `REG_SZ` value `VerbHandler` inside the `shell` key for the respective shell entry
|
||||
and specify the GUID of the context menu handler in the value.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
##### No working directory
|
||||
Disables the working directory from being added to the environmental path in the context of the application being run.
|
||||
|
||||
To disable pass-through of the working directory to the application,
|
||||
create an empty `REG_SZ` value called `NoWorkingDirectory` inside the `shell` key for the respective shell entry.
|
||||
|
||||
##### Other options
|
||||
Some options, though might sound obvious, have me scratching the back of my head.
|
||||
Please tag me on [Twitter](https://go.enderman.ch/twitter) or [send a mail](mailto:[email protected]) my way if you
|
||||
have the knowledge.
|
||||
|
||||
**A few examples:**
|
||||
* `CanonicalName`;
|
||||
* `CommandStateHandler`;
|
||||
* `CommandStateSync`;
|
||||
* `Description`;
|
||||
* `VerbName`.
|
||||
|
||||
#### Deleting entries
|
||||
Even simpler — it's the opposite of adding one.
|
||||
|
||||
1. Locate the target extension.
|
||||
2. Navigate to the `shell` subkey.
|
||||
3. Find the shell entry to delete. The default value of the `command` key will usually contain the
|
||||
executable name, it's best to verify the target application before deletion.
|
||||
4. Delete the key.
|
||||
|
||||
**Multiple shell entries** can sometimes correspond to the **same item** in the context menu,
|
||||
which might result in the item remaining in place despite the deletion.
|
||||
|
||||
#### Sorting entries
|
||||
That's easily the most hilarious procedure of them all. I even [tweeted](https://go.enderman.ch/3hcO9) about it.
|
||||
|
||||
The only way to sort the context menu items is...
|
||||
**by managing the alphabetical order of their respective shell entries in the registry**.
|
||||
Seriously, I am not kidding.
|
||||
Let the screenshots speak for themselves.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
_Yeah... I'd rather leave all the obscene comments aside._
|
||||
|
||||
##### Position override
|
||||
Another silly feature with a limited number of use cases
|
||||
that's likely been created due to all prior mess Microsoft has created.
|
||||
It's possible to manually override the position of a context menu item to be...
|
||||
either at the very top, or the very bottom.
|
||||
_Still doesn't top off the alphabetical sorting between the registry peers..._
|
||||
|
||||
In the `shell` subkey under the respective shell entry, create a `REG_SZ` value called `Position`.
|
||||
To enforce the context menu item to appear at the very top,
|
||||
insert `Top` into the value data, otherwise insert `Bottom`.
|
||||
|
||||
Sometimes directly enforcing the position happens to be necessary.
|
||||
For instance, Microsoft dumps the usual sorting strategy at the very bottom of the context menu,
|
||||
where the display and personalization settings reside.
|
||||
They simply set the `Bottom` position for each of the entries.
|
||||
|
||||
**What happens when there are multiple shell entries enforced to be in a set position?**
|
||||
The alphabetical sorting makes a comeback,
|
||||
it occurs between the shell entries with overridden positions just the same way.
|
||||
|
||||

|
||||
|
||||
### Context menu handlers
|
||||
Compared to shell entries, there isn't much to be tweaked via registry in context menu handlers.
|
||||
Their general location is the `ContextMenuHandlers` subkey under the `shellex` key in the respective shell entry.
|
||||
|
||||

|
||||
|
||||
Most of the time, the only piece of data they contain is a lonely GUID of the respective handler.
|
||||
|
||||

|
||||
|
||||
#### Removing handlers
|
||||
|
||||
A context menu handler always directly corresponds to some child under the `ContextMenuHandlers` key.
|
||||
Therefore, all that has to be done to remove a handler is removal of the **respective child key**.
|
||||
|
||||
I strongly suggest writing **the GUID** and **the location** of the removed entry down somewhere,
|
||||
so that there is a way to roll back any time later.
|
||||
|
||||

|
||||
|
||||
#### Adding handlers
|
||||
|
||||
Adding a custom handler is complicated.
|
||||
It's not as trivial as adding a shell entry —
|
||||
it requires implementing a handler interface using a language
|
||||
supporting Windows API or an abstraction layer on top of it.
|
||||
C# would be a decent choice precisely for this task.
|
||||
|
||||
However, it's just a waste of time to implement a handler for a single shell entry,
|
||||
unless there's software to go along with it.
|
||||
The idea behind context menu handlers is to **make native application experience nicer** for Windows users.
|
||||
|
||||
In other words, Microsoft failed to implement an accessible API for the context menu,
|
||||
so they did what they do best — shifted the responsibility to the developers writing third-party software.
|
||||
|
||||
This is how a partial implementation of the `IContextMenu` interface in C++ looks like:
|
||||
|
||||
```cpp
|
||||
STDMETHODIMP CShellExtension::InvokeCommand(LPCMINVOKECOMMANDINFO lpcmi) {
|
||||
BOOL fEx = FALSE;
|
||||
BOOL fUnicode = FALSE;
|
||||
|
||||
if(lpcmi->cbSize == sizeof(CMINVOKECOMMANDINFOEX)) {
|
||||
fEx = TRUE;
|
||||
if (lpcmi->fMask & CMIC_MASK_UNICODE) fUnicode = TRUE;
|
||||
}
|
||||
|
||||
if (!fUnicode && HIWORD(lpcmi->lpVerb)) {
|
||||
if (StrCmpIA(lpcmi->lpVerb, m_pszVerb)) return E_FAIL;
|
||||
}
|
||||
else if (fUnicode && HIWORD(((CMINVOKECOMMANDINFOEX *) lpcmi)->lpVerbW)) {
|
||||
if (StrCmpIW(((CMINVOKECOMMANDINFOEX *)lpcmi)->lpVerbW, m_pwszVerb)) return E_FAIL;
|
||||
}
|
||||
else if (LOWORD(lpcmi->lpVerb) != IDM_DISPLAY) {
|
||||
return E_FAIL;
|
||||
}
|
||||
else {
|
||||
MessageBox(
|
||||
lpcmi->hwnd,
|
||||
"The File Name",
|
||||
"File Name",
|
||||
MB_OK | MB_ICONINFORMATION
|
||||
);
|
||||
}
|
||||
|
||||
return S_OK;
|
||||
}
|
||||
```
|
||||
|
||||
The `InvokeCommand()` method is called when a user clicks a context menu item.
|
||||
I've taken that example from [Microsoft](https://go.enderman.ch/TArYX).
|
||||
|
||||
That whole thing isn't too bad — the logic is far more robust and understandable
|
||||
compared to its surface-level registry counterpart, but it certainly requires getting used to WinAPI.
|
||||
|
||||
**By definition, it's a requirement to implement interfaces entirely.**
|
||||
However, since we're dealing with Microsoft,
|
||||
the «interface» in question is actually an _abstract class_ and can be implemented partially.
|
||||
|
||||
The `IContextMenu` interface has three methods:
|
||||
* `GetCommandString()` — retrieves the canonical name of the command (optional);
|
||||
* `InvokeCommand()` — handles the command (required);
|
||||
* `QueryContextMenu()` — adds items to the context menu (required).
|
||||
|
||||
The `IContextMenu` interface is the most common one, but there are others,
|
||||
such as `IContextMenu2` and `IContextMenu3`.
|
||||
Essentially, this is how the context menu handlers work inside.
|
||||
|
||||
While all of this is just a brief overview of the API,
|
||||
you can learn everything about them and more in the [Shell Developer's Guide](https://go.enderman.ch/W50xJ).
|
||||
|
||||
## Dealing with locked keys
|
||||
If the registry key is locked, the user you're logged in as is excluded from the ACL.
|
||||
Commonly, when the key is locked, it's owned by `TrustedInstaller`.
|
||||
When that's the case, it's necessary to reclaim ownership of the key to allow making edits to the ACL.
|
||||
There are plenty of ways to do that, I will list two common ones.
|
||||
|
||||
### Using external tools
|
||||
The idea here is to run `regedit` — the registry editor as the `TrustedInstaller` user directly.
|
||||
You can think of `TrustedInstaller` as the Windows equivalent of `root` in Unix-like systems —
|
||||
the superuser.
|
||||
|
||||
While Windows generally has more granular control over permissions,
|
||||
and the idea of having a superuser — **the ultimate authority of the system** — is less prominent and unannounced,
|
||||
`TrustedInstaller` is the closest object to a superuser Windows has.
|
||||
|
||||
For those wondering, I've already explained [here](https://go.enderman.ch/o2KGt) why
|
||||
«sudo for Windows» makes no sense.
|
||||
|
||||
##### Process Hacker <sub>[download](https://go.enderman.ch/process-hacker)</sub>
|
||||

|
||||
|
||||

|
||||
|
||||
##### Winaero Tweaker <sub>[download](https://go.enderman.ch/winaero)</sub>
|
||||

|
||||
|
||||
[Sergey](https://go.enderman.ch/Dr0iS) makes an entirely valid point in his Winaero Tweaker:
|
||||
|
||||
> **Warning!!!**
|
||||
>
|
||||
> Running programs under TrustedInstaller privileges, especially a file manager app or Regedit,exe can be
|
||||
> VERY risky and harmful for your OS. It is like a God mode in a first-person shooter game, where nothing can
|
||||
> stop your actions, so if you execute a malware infected file as TrustedInstaller, it can cause damage to the
|
||||
> Windows operating system. Use this mode only if you perfectly understand what you are doing and if there is
|
||||
> no simpler way than running it as Trusted Installer.
|
||||
|
||||
This is exactly the reason I resort to setting permissions manually.
|
||||
It's a bit more time-consuming, but it's safer.
|
||||
|
||||
### Setting permissions manually
|
||||
The trusty old way to do it is to manually set permissions for the key.
|
||||
Once you get the hang of it, it really isn't that difficult.
|
||||
|
||||

|
||||
|
||||
##### General procedure
|
||||
1. Navigate to the key you want to claim ownership of;
|
||||
2. Open the «Properties» window;
|
||||
3. Head over to the «Advanced» tab;
|
||||
4. The current owner is shown at the top of the «Advanced Security Settings» window. Click «Change»;
|
||||
5. Type out your username in the text area with the «Enter the object name to select (examples)» label;
|
||||
6. Click «OK»;
|
||||
7. Tick the «Replace owner on subcontainers and objects» checkbox;
|
||||
8. Tick the «Replace all child object permission entries» checkbox;
|
||||
9. Click «OK»;
|
||||
10. Pick `Administrators (PC_NAME\Administrators)` in the group selector and then tick «Full Control» checkbox
|
||||
in the «Permissions» window at the bottom;
|
||||
11. You're done! Click «OK» and dismiss any warnings.
|
||||
|
||||
## Common registry paths
|
||||
Here is a list of the most common registry paths.
|
||||
There are as many «sections» as there are extensions, but these are the most common ones.
|
||||
They should cover most of your context menu.
|
||||
|
||||
#### Desktop Background — bottom rows
|
||||
`Computer\HKEY_CLASSES_ROOT\DesktopBackground`
|
||||
|
||||

|
||||
|
||||
#### Folder Background
|
||||
`Computer\HKEY_CLASSES_ROOT\Directory\Background`
|
||||
|
||||

|
||||
|
||||
#### Folder
|
||||
`Computer\HKEY_CLASSES_ROOT\Directory`
|
||||
|
||||

|
||||
|
||||
#### Folder Item
|
||||
Extends [Folder](#folder).
|
||||
`Computer\HKEY_CLASSES_ROOT\Folder`
|
||||
|
||||

|
||||
|
||||
#### Drive
|
||||
`Computer\HKEY_CLASSES_ROOT\Drive`
|
||||
|
||||

|
||||
|
||||
#### Library Folder
|
||||
`Computer\HKEY_CLASSES_ROOT\LibraryFolder`
|
||||
|
||||

|
||||
|
||||
#### Library Background
|
||||
Inherits from [Folder Background](#folder-background).
|
||||
`Computer\HKEY_CLASSES_ROOT\LibraryFolder\background`
|
||||
|
||||

|
||||
|
||||
Despite the inheritance, it cannot parse the `%V` macro, which results in the following error.
|
||||
Blame Microsoft for being as consistent as ever.
|
||||
To my knowledge, there is no way to fix that.
|
||||
|
||||

|
||||
|
||||
#### All Files
|
||||
`Computer\HKEY_CLASSES_ROOT\*`
|
||||
|
||||

|
||||
|
||||
#### All Filesystem Objects
|
||||
Different from [All Files](#all-files) — it's a further generalization that includes system drives,
|
||||
symbolic links and other objects.
|
||||
`Computer\HKEY_CLASSES_ROOT\AllFilesystemObjects`
|
||||
|
||||

|
||||
|
||||
#### File type associations
|
||||
Text: `Computer\HKEY_CLASSES_ROOT\SystemFileAssociations\text`
|
||||
Images: `Computer\HKEY_CLASSES_ROOT\SystemFileAssociations\image`
|
||||
Documents: `Computer\HKEY_CLASSES_ROOT\SystemFileAssociations\document`
|
||||
Audio: `Computer\HKEY_CLASSES_ROOT\SystemFileAssociations\audio`
|
||||
Video: `Computer\HKEY_CLASSES_ROOT\SystemFileAssociations\video`
|
||||
|
||||
## Cleaning up the context menu
|
||||
The least fan-favorite default entries can be removed using Winaero Tweaker.
|
||||
I suggest using it to make the whole cleanup slightly more bearable.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### Some extra common areas
|
||||
The following are fairly common areas Winaero Tweaker doesn't cover.
|
||||
|
||||
::card
|
||||
---
|
||||
type: idea
|
||||
title: Tip
|
||||
---
|
||||
As a rule of thumb, whenever you're dealing with existing shell entries, I _really_ suggest
|
||||
**searching for a way to manage them online** before resorting to any measures I elaborated on in this article.
|
||||
Now if you can't find anything useful online, then anything works.
|
||||
::
|
||||
|
||||
#### Remove «Open PowerShell window here»
|
||||
1. Navigate to `Computer\HKEY_CLASSES_ROOT\Directory\shell\Powershell`;
|
||||
2. [Unlock the key](#dealing-with-locked-keys);
|
||||
3. Create a new `REG_SZ` value called `ProgrammaticAccessOnly` and leave it empty;
|
||||
4. Navigate to `Computer\HKEY_CLASSES_ROOT\Directory\Background\shell\Powershell`;
|
||||
5. [Unlock the key](#dealing-with-locked-keys);
|
||||
6. Create a new `REG_SZ` value called `ProgrammaticAccessOnly` and leave it empty.
|
||||
|
||||
#### Remove «Open Linux shell here»
|
||||
1. Navigate to `Computer\HKEY_CLASSES_ROOT\Directory\shell\WSL`;
|
||||
2. [Unlock the key](#dealing-with-locked-keys);
|
||||
3. Remove the key;
|
||||
4. Navigate to `Computer\HKEY_CLASSES_ROOT\Directory\Background\shell\WSL`;
|
||||
5. [Unlock the key](#dealing-with-locked-keys);
|
||||
6. Create a new `REG_SZ` value called `ProgrammaticAccessOnly` and leave it empty.
|
||||
|
||||
#### Edit the «Edit» text context menu command
|
||||
1. Navigate to `Computer\HKEY_CLASSES_ROOT\SystemFileAssociations\text\shell\edit`;
|
||||
2. Replace all mentions of the previous editor in default values with **Notepad++**
|
||||
or any other text editor you prefer.
|
||||
|
||||
## Bottom line
|
||||
The context menu is a mess, and it's not going to change.
|
||||
It's a part of Windows that's been around for a long time and has been left behind by Microsoft.
|
||||
Shell entries are like differential equations — there's never a general solution.
|
||||
They're a pain to learn, but the acquired skill is worth it.
|
||||
|
||||
No matter how terrible the context menu API is in Windows, there's still a way to customize it to your liking.
|
||||
Hopefully you learned something new from this article
|
||||
and are now able to make your context menu **a bit** more bearable.
|
||||
|
||||
**Spotted a mistake?**
|
||||
Want to add important details I missed or share extra knowledge on the subject?
|
||||
Tag me on [Twitter](https://go.enderman.ch/twitter) or [e-mail me](mailto:[email protected])!
|
||||
All contributions welcome. 😉
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
title: The Hidden Features of the Windows OOBE
|
||||
description: The Windows 11 OOBE is a mess, but it's excellent for bulk deployment and has some hidden features and customization options!
|
||||
authors: ['Enderman']
|
||||
created: 2024-03-01T23:30:10Z
|
||||
updated: 2024-03-02T15:10:58Z
|
||||
draft: false
|
||||
tags: ['windows', 'oobe', 'deployment', 'know how', 'secret features', 'protocols', 'features', 'windows 10', 'windows 11']
|
||||
---
|
||||
|
||||
# The Windows 11 OOBE is a mess...
|
||||
The OOBE (Out-of-Box Experience) is the first thing a user sees when they boot up a new Windows device.
|
||||
It's the first impression of the operating system, and it's important to get it right.
|
||||
At first glance, it seems to be a thing Microsoft surprisingly _has_ gotten right.
|
||||
However, once you take a look under the hood, you find a mess of legacy code, hidden features and undocumented APIs...
|
||||
and suddenly, you take it all back.
|
||||
|
||||
In this article, I aim to take a deep dive into that mess and explore some hidden features and
|
||||
customization options it offers.
|
||||
|
||||
::card
|
||||
---
|
||||
icon: warning
|
||||
title: Unfinished article
|
||||
---
|
||||
This post is a **work in progress**, please check back later for more content!
|
||||
::
|
||||
|
||||
### The OOBE protocol
|
||||
|
||||
The OOBE is a web application, and the `ms-cxh://` protocol is a custom handler that defines a couple of shortcuts to
|
||||
various pages, seemingly, for internal use.
|
||||
|
||||
The list of available protocol strings can be retrieved by scanning Windows binaries for the `ms-cxh://` occurrences.
|
||||
Luckily, Hexacorn did all the heavy lifting for us and shared
|
||||
his findings in [his blog post](https://go.enderman.ch/18n6n).
|
||||
|
||||
The protocol API is **undocumented** by Microsoft, so it's not guaranteed to work in the future.
|
||||
In fact, it has already been partially removed or deprecated in Windows 11 without any notice.
|
||||
|
||||
Microsoft used acronyms in protocol strings, and some of them are quite cryptic, but there's nothing we can't decode.
|
||||
|
||||
|
||||
|
||||
#### Out-of-Box Experience
|
||||
FRX stands for «**F**irst **R**un e**X**perience»;
|
||||
RDX stands for «**R**etail **D**emo e**X**perience».
|
||||
|
||||
* `ms-cxh://FRX/AAD`
|
||||
* `ms-cxh://FRX/INCLUSIVE`
|
||||
* `ms-cxh://FRX/INCLUSIVE?start=OobeProvisioningStatus`
|
||||
* `ms-cxh://FRX/TEAMEDITION`
|
||||
* `ms-cxh://FRXRDXINCLUSIVE`
|
||||
|
||||
#### Microsoft Azure
|
||||
The AAD acronym is commonly used and refers to «**A**zure **A**ctive **D**irectory»;
|
||||
SSPR stands for «**S**elf-**S**ervice **P**assword **R**eset».
|
||||
|
||||
* `ms-cxh://AADPINRESETAUTH`
|
||||
* `ms-cxh://AADSSPR`
|
||||
* `ms-cxh://AADWEBAUTH`
|
||||
|
||||
#### Modern settings
|
||||
The MOSET acronym most likely refers to «**Mo**dern **set**tings»;
|
||||
MAM stands for «**M**obile **A**pplication **M**anagement».
|
||||
MSA stands for «**M**icro**s**oft **A**ccount».
|
||||
|
||||
* `ms-cxh://MOSET/AADLOCAL`
|
||||
* `ms-cxh://MOSET/CONNECTTOWORK`
|
||||
* `ms-cxh://mosetmamconnecttowork?mode=mdm&username=%s&servername=%s`
|
||||
* `ms-cxh://mosetmdmconnecttowork`
|
||||
* `ms-cxh://MOSETMSA`
|
||||
* `ms-cxh://MOSETMSALOCAL`
|
||||
|
||||
#### Microsoft account
|
||||
CFL probably stands for «**C**hange **F**irst **L**ogon»?
|
||||
|
||||
* `ms-cxh://MSACFLPINRESET`
|
||||
* `ms-cxh://MSACFLPINRESETSIGNIN`
|
||||
* `ms-cxh://MSACXSIGNINAUTHONLY`
|
||||
* `ms-cxh://MSACXSIGNINPINADD`
|
||||
* `ms-cxh://MSACXSIGNINPINRESET`
|
||||
* `ms-cxh://MSAPINENROLL`
|
||||
* `ms-cxh://MSAPINRESET`
|
||||
* `ms-cxh://MSARDX`
|
||||
* `ms-cxh://MSASSPR`
|
||||
|
||||
#### Windows Hello for Microsoft Intune
|
||||
NTH stands for «i**NT**une **H**ello»;
|
||||
NGC stands for «**N**ext **G**eneration **C**redential»;
|
||||
ENT stands for «**Ent**erprise»;
|
||||
MDM stands for «**M**obile **D**evice **M**anagement».
|
||||
|
||||
* `ms-cxh://NTH`
|
||||
* `ms-cxh://NTH/AADRECOVERY`
|
||||
* `ms-cxh://NTHAADNGCFIXME`
|
||||
* `ms-cxh://NTHAADNGCONLY`
|
||||
* `ms-cxh://NTHAADNGCRESET`
|
||||
* `ms-cxh://NTHAADNGCRESETDESTRUCTIVE`
|
||||
* `ms-cxh://NTHAADNGCRESETNONDESTRUCTIVE`
|
||||
* `ms-cxh://NTHAADORMDM?ngc=enabled`
|
||||
* `ms-cxh://NTHENTNGCFIXME`
|
||||
* `ms-cxh://NTHENTNGCONLY`
|
||||
* `ms-cxh://NTHENTNGCRESET`
|
||||
* `ms-cxh://NTHENTNGCRESETDESTRUCTIVE`
|
||||
* `ms-cxh://NTHENTORMDM`
|
||||
* `ms-cxh://NTHENTORMDM?ngc=enabled`
|
||||
* `ms-cxh://NTHNGCUPSELL`
|
||||
* `ms-cxh://NTHPRIVACY`
|
||||
* `ms-cxh://RDXRACSKUINCLUSIVE`
|
||||
|
||||
#### Second-chance OOBE
|
||||

|
||||
|
||||
* `ms-cxh://SCOOBE`
|
||||
* `ms-cxh://SCOOBE%ws`
|
||||
* `ms-cxh://SCOOBE/UPGRADE`
|
||||
|
||||
#### Cloud settings
|
||||
* `ms-cxh://SETADDLOCALONLY`
|
||||
* `ms-cxh://SETADDNEWUSER`
|
||||
* `ms-cxh://SETCHANGEPWD`
|
||||
* `ms-cxh://SETPHONEPAIRING`
|
||||
* `ms-cxh://SETPHONEPAIRING?scenarioId=SwiftKeyCloudClipboard`
|
||||
* `ms-cxh://setsqsalocalonly`
|
||||
|
||||
#### Miscellaneous
|
||||
* `ms-cxh://TSET/ADDFAMILY`
|
||||
* `ms-cxh://WLT`
|
||||
* `ms-cxh://WLTUC`
|
||||
@@ -0,0 +1,19 @@
|
||||
// @ts-check
|
||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||
|
||||
export default withNuxt({
|
||||
rules: {
|
||||
'no-unused-vars': 'warn',
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/max-attributes-per-line': [
|
||||
'warn',
|
||||
{
|
||||
singleline: 3,
|
||||
multiline: 1,
|
||||
},
|
||||
],
|
||||
'space-in-parens': 'off',
|
||||
'computed-property-spacing': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
import config from './config'
|
||||
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
app: {
|
||||
head: {
|
||||
htmlAttrs: {
|
||||
lang: config.locale || 'en',
|
||||
},
|
||||
link: [
|
||||
{
|
||||
rel: 'icon',
|
||||
type: 'image/x-icon',
|
||||
href: '/favicon.ico',
|
||||
},
|
||||
],
|
||||
meta: [
|
||||
{
|
||||
charset: 'utf-8',
|
||||
},
|
||||
{
|
||||
name: 'viewport',
|
||||
content: 'width=device-width, initial-scale=1',
|
||||
},
|
||||
{
|
||||
'http-equiv': 'X-UA-Compatible',
|
||||
content: 'ie=edge',
|
||||
},
|
||||
],
|
||||
},
|
||||
pageTransition: { name: 'page', mode: 'out-in' },
|
||||
rootId: 'ender-app',
|
||||
},
|
||||
|
||||
colorMode: {
|
||||
preference: 'system',
|
||||
fallback: 'dark',
|
||||
classPrefix: '',
|
||||
classSuffix: '',
|
||||
componentName: 'NuxtTheme',
|
||||
storageKey: 'ecmatheme',
|
||||
},
|
||||
|
||||
compatibilityDate: '2025-01-26',
|
||||
|
||||
components: {
|
||||
dirs: [
|
||||
{
|
||||
path: '~/components',
|
||||
pathPrefix: false,
|
||||
extensions: ['.vue'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
content: {
|
||||
markdown: {
|
||||
remarkPlugins: ['remark-reading-time'],
|
||||
},
|
||||
highlight: {
|
||||
theme: {
|
||||
default: 'github-dark',
|
||||
light: 'github-light',
|
||||
sepia: 'monokai',
|
||||
},
|
||||
langs: [
|
||||
'shell',
|
||||
'batch',
|
||||
'vb',
|
||||
'ini',
|
||||
'asm',
|
||||
'c',
|
||||
'cpp',
|
||||
'java',
|
||||
'python',
|
||||
'csv',
|
||||
'xml',
|
||||
'json',
|
||||
'yaml',
|
||||
'html',
|
||||
'css',
|
||||
'sass',
|
||||
'php',
|
||||
'js',
|
||||
'ts',
|
||||
'vue',
|
||||
'md',
|
||||
'mdc',
|
||||
'pascal',
|
||||
'lisp',
|
||||
'sql',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
css: ['~/assets/styles/main.scss'],
|
||||
|
||||
devtools: {
|
||||
enabled: true,
|
||||
},
|
||||
|
||||
experimental: {
|
||||
inlineRouteRules: true,
|
||||
},
|
||||
|
||||
future: {
|
||||
compatibilityVersion: 4,
|
||||
},
|
||||
|
||||
googleFonts: {
|
||||
download: true,
|
||||
families: {
|
||||
Lato: true,
|
||||
Alexandria: true,
|
||||
},
|
||||
},
|
||||
|
||||
linkChecker: {
|
||||
failOnError: true,
|
||||
report: {
|
||||
html: true,
|
||||
markdown: true,
|
||||
},
|
||||
},
|
||||
|
||||
modules: [
|
||||
'@pinia/nuxt',
|
||||
'@nuxt/image',
|
||||
'@nuxtjs/seo',
|
||||
'@nuxt/content',
|
||||
'@nuxtjs/google-fonts',
|
||||
'@nuxtjs/tailwindcss',
|
||||
'@nuxtjs/color-mode',
|
||||
'@nuxt/eslint',
|
||||
['@nuxtjs/stylelint-module', { failOnError: true, lintOnStart: false }],
|
||||
],
|
||||
|
||||
nitro: {
|
||||
prerender: {
|
||||
crawlLinks: true,
|
||||
autoSubfolderIndex: true,
|
||||
failOnError: true,
|
||||
routes: ['/robots.txt', '/sitemap.xml'],
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [],
|
||||
|
||||
robots: {
|
||||
enabled: true,
|
||||
blockNonSeoBots: false,
|
||||
credits: false,
|
||||
debug: false,
|
||||
metaTag: true,
|
||||
groups: [],
|
||||
disallow: [' '],
|
||||
},
|
||||
|
||||
site: {
|
||||
env: process.env.NODE_ENV,
|
||||
url: config.url,
|
||||
name: config.name,
|
||||
indexable: true,
|
||||
trailingSlash: false,
|
||||
},
|
||||
|
||||
sitemap: {
|
||||
sources: [
|
||||
/*'/api/__sitemap__/content'*/
|
||||
],
|
||||
cacheMaxAgeSeconds: 360,
|
||||
exclude: [],
|
||||
credits: false,
|
||||
xslColumns: [
|
||||
{ label: 'URL', width: '50%' },
|
||||
{ label: 'Last Modified', select: 'sitemap:lastmod', width: '25%' },
|
||||
{ label: 'Priority', select: 'sitemap:priority', width: '12.5%' },
|
||||
{
|
||||
label: 'Change Frequency',
|
||||
select: 'sitemap:changefreq',
|
||||
width: '12.5%',
|
||||
},
|
||||
],
|
||||
defaults: {
|
||||
lastmod: config.build.date,
|
||||
},
|
||||
},
|
||||
|
||||
tailwindcss: {
|
||||
exposeConfig: true,
|
||||
},
|
||||
|
||||
typescript: {
|
||||
typeCheck: true,
|
||||
},
|
||||
|
||||
vue: {
|
||||
compilerOptions: {
|
||||
isCustomElement: (tag) => tag === 'iconify-icon',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "enderapp",
|
||||
"version": "0.3.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"lint:js": "eslint --ext \".js,.ts,.vue\" --ignore-path .gitignore .",
|
||||
"lint:prettier": "prettier --check .",
|
||||
"lint": "npm run lint:js && npm run lint:style && npm run lint:prettier",
|
||||
"lint:fix": "prettier --write --list-different . && npm run lint:js -- --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/content": "^3.0.0",
|
||||
"@nuxt/eslint": "^0.3.13",
|
||||
"@nuxt/image": "^1.8.1",
|
||||
"@nuxtjs/color-mode": "^3.4.1",
|
||||
"@nuxtjs/google-fonts": "^3.2.0",
|
||||
"@nuxtjs/seo": "^2.1.0",
|
||||
"@nuxtjs/stylelint-module": "^5.2.0",
|
||||
"@nuxtjs/tailwindcss": "^6.12.0",
|
||||
"@pinia/nuxt": "^0.5.1",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"nuxt": "^3.15.3",
|
||||
"pinia": "^2.1.7",
|
||||
"sass-embedded": "^1.82.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.4.5",
|
||||
"vue-tsc": "^2.2.0",
|
||||
"iconify-icon": "^2.1.0",
|
||||
"animate.css": "latest",
|
||||
"remark-reading-time": "^2.0.1",
|
||||
"@date-io/date-fns": "^3.0.0",
|
||||
"date-fns": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"caniuse-lite": "^1.0.30001634",
|
||||
"prettier": "^3.3.2",
|
||||
"stylelint": "^16.6.1",
|
||||
"stylelint-config-recommended-scss": "^14.0.0",
|
||||
"stylelint-config-recommended-vue": "^1.5.0",
|
||||
"stylelint-config-standard-scss": "^13.1.0",
|
||||
"stylelint-config-tailwindcss": "^0.0.7",
|
||||
"stylelint-scss": "^6.3.1",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"browserslist": [
|
||||
">0.3%",
|
||||
"not dead",
|
||||
"defaults",
|
||||
"fully supports es6-module",
|
||||
"maintained node versions"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @see https://prettier.io/docs/en/configuration.html
|
||||
* @type {import("prettier").Config}
|
||||
*/
|
||||
export default {
|
||||
semi: false,
|
||||
quoteProps: 'as-needed',
|
||||
singleQuote: true,
|
||||
useTabs: false,
|
||||
tabWidth: 2,
|
||||
trailingComma: 'all',
|
||||
bracketSpacing: true,
|
||||
}
|
||||
|
After Width: | Height: | Size: 632 B |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 19 KiB |