chore: upgrade to nuxt 4
This commit is contained in:
5
app/app.vue
Normal file
5
app/app.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
52
app/assets/css/base.scss
Normal file
52
app/assets/css/base.scss
Normal file
@@ -0,0 +1,52 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Bitter:wght@300;400;600;700;800;900&display=swap");
|
||||
|
||||
@mixin headings {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
// for that cool wave dark mode effect
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
div#__nuxt {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
main {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
:root {
|
||||
--text-color: #243746;
|
||||
--bg: #f1e7d0;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--text-color: #ebf4f1;
|
||||
--bg: #091a28;
|
||||
}
|
||||
|
||||
.text-bitter {
|
||||
font-family: Bitter, ui-sans-serif, system-ui, -apple-system,
|
||||
BlinkMacSystemFont, "Segoe UI", Roboto, "Open Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
.text-article {
|
||||
font-family: "Source Serif Pro", serif;
|
||||
line-height: 1.8;
|
||||
color: #111;
|
||||
font-size: 1.25rem;
|
||||
}
|
22
app/assets/css/main.scss
Normal file
22
app/assets/css/main.scss
Normal file
@@ -0,0 +1,22 @@
|
||||
@use "base.scss";
|
||||
|
||||
.prose article {
|
||||
@include base.headings {
|
||||
& > a:hover,
|
||||
& > a:active {
|
||||
text-decoration: underline;
|
||||
text-decoration-skip-ink: all;
|
||||
@apply text-blue-700 dark:text-blue-400;
|
||||
&::before {
|
||||
content: "#";
|
||||
position: absolute;
|
||||
opacity: 0.5;
|
||||
left: -2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a:hover {
|
||||
@apply hover:text-blue-700 dark:hover:text-blue-400;
|
||||
}
|
||||
}
|
3
app/assets/images/moon.svg
Normal file
3
app/assets/images/moon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 11.807C10.7418 10.5483 9.88488 8.94484 9.53762 7.1993C9.19037 5.45375 9.36832 3.64444 10.049 2C8.10826 2.38205 6.3256 3.33431 4.92899 4.735C1.02399 8.64 1.02399 14.972 4.92899 18.877C8.83499 22.783 15.166 22.782 19.072 18.877C20.4723 17.4805 21.4245 15.6983 21.807 13.758C20.1625 14.4385 18.3533 14.6164 16.6077 14.2692C14.8622 13.9219 13.2588 13.0651 12 11.807V11.807Z" />
|
||||
</svg>
|
After Width: | Height: | Size: 490 B |
1
app/assets/images/star.svg
Normal file
1
app/assets/images/star.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0" stroke-linecap="round" stroke-linejoin="round" class="feather feather-star"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg>
|
After Width: | Height: | Size: 339 B |
7
app/assets/images/sun.svg
Normal file
7
app/assets/images/sun.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.995 12C6.995 14.761 9.241 17.007 12.002 17.007C14.763 17.007 17.009 14.761 17.009 12C17.009 9.239 14.763 6.993 12.002 6.993C9.241 6.993 6.995 9.239 6.995 12ZM11 19H13V22H11V19ZM11 2H13V5H11V2ZM2 11H5V13H2V11ZM19 11H22V13H19V11Z" />
|
||||
<path d="M5.63702 19.778L4.22302 18.364L6.34402 16.243L7.75802 17.657L5.63702 19.778Z" />
|
||||
<path d="M16.242 6.34405L18.364 4.22205L19.778 5.63605L17.656 7.75805L16.242 6.34405Z" />
|
||||
<path d="M6.34402 7.75902L4.22302 5.63702L5.63802 4.22302L7.75802 6.34502L6.34402 7.75902Z" />
|
||||
<path d="M19.778 18.3639L18.364 19.7779L16.242 17.6559L17.656 16.2419L19.778 18.3639Z" />
|
||||
</svg>
|
After Width: | Height: | Size: 712 B |
56
app/components/BlogStatBox.vue
Normal file
56
app/components/BlogStatBox.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import type { BlogParsedContent } from "@/shared/types";
|
||||
import { calcReadingTime } from "@/shared/metadata";
|
||||
|
||||
const docs = await queryContent<BlogParsedContent>("/blog")
|
||||
.sort({ date: 1 })
|
||||
.where({ _draft: false })
|
||||
.find();
|
||||
|
||||
const latest = docs.at(-1) as BlogParsedContent;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prose dark:prose-invert flex onhover">
|
||||
<HomeStatBox
|
||||
:href="latest._path"
|
||||
color="lightblue"
|
||||
darkcolor="#497482"
|
||||
title="Latest blog post"
|
||||
>
|
||||
<h2 class="m-0 mt-4 mb-1">{{ latest.title }}</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 m-0">
|
||||
<Date :doc="latest" /> · {{ calcReadingTime(latest).minutes }} min read
|
||||
</p>
|
||||
<div class="tag-list mt-1">
|
||||
<Tag
|
||||
v-for="(tag, index) in latest.tags"
|
||||
:key="index"
|
||||
:dest="`/tags/blog/${tag}`"
|
||||
:name="tag"
|
||||
/>
|
||||
</div>
|
||||
<ContentRenderer
|
||||
tag="article"
|
||||
:value="latest"
|
||||
:excerpt="true"
|
||||
class="text-gray-600 dark:text-gray-300 text-base m-0 mt-5"
|
||||
>
|
||||
<ContentRendererMarkdown :value="latest" :excerpt="true" />
|
||||
<template #empty>
|
||||
<p>No description found.</p>
|
||||
</template>
|
||||
</ContentRenderer>
|
||||
</HomeStatBox>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
h2 {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
div.onhover:hover h2 {
|
||||
@apply text-blue-700 dark:text-blue-400;
|
||||
}
|
||||
</style>
|
34
app/components/ButtonToTop.vue
Normal file
34
app/components/ButtonToTop.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<a href="#" class="go-top" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.go-top {
|
||||
--offset: 20rem;
|
||||
position: sticky;
|
||||
bottom: 1rem;
|
||||
left: 1rem;
|
||||
margin-right: 1rem;
|
||||
place-self: end;
|
||||
margin-top: calc(100vh + var(--offset));
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background: #ff8b24;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 0.1rem 0.5rem 0 gray;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
html.dark .go-top {
|
||||
box-shadow: 0 0.1rem 0.5rem 0 black;
|
||||
}
|
||||
|
||||
.go-top:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 30%;
|
||||
transform: translateY(20%) rotate(-45deg);
|
||||
border-top: 0.35rem solid #fff;
|
||||
border-right: 0.35rem solid #fff;
|
||||
}
|
||||
</style>
|
99
app/components/ColourPicker.vue
Normal file
99
app/components/ColourPicker.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import IconSun from "@/assets/images/sun.svg?component";
|
||||
import IconMoon from "@/assets/images/moon.svg?component";
|
||||
|
||||
const colorMode = useColorMode();
|
||||
|
||||
const toggle = () => {
|
||||
colorMode.preference = colorMode.value === "light" ? "dark" : "light";
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label
|
||||
for="dark-toggle"
|
||||
class="toggle-wrapper"
|
||||
aria-label="Dark mode indicator label"
|
||||
>
|
||||
<div class="toggle">
|
||||
<div class="icons">
|
||||
<IconMoon />
|
||||
<IconSun />
|
||||
</div>
|
||||
<input
|
||||
id="dark-toggle"
|
||||
name="dark-toggle"
|
||||
type="checkbox"
|
||||
ref="darkToggleEl"
|
||||
aria-label="Toggle dark mode"
|
||||
@click="toggle"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toggle-wrapper {
|
||||
width: 6rem;
|
||||
display: block;
|
||||
--black: #333333;
|
||||
--white: #f5f5f5;
|
||||
--scale: 2rem;
|
||||
--transition: 0.2s ease;
|
||||
--bg: var(--white);
|
||||
--fg: var(--black);
|
||||
}
|
||||
|
||||
html.dark .toggle-wrapper {
|
||||
--black: #f5f5f5;
|
||||
--white: #333333;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
height: var(--scale);
|
||||
width: calc(var(--scale) * 2);
|
||||
background: var(--fg);
|
||||
border-radius: var(--scale);
|
||||
padding: calc(var(--scale) * 0.175);
|
||||
position: relative;
|
||||
margin: auto;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.toggle::before {
|
||||
content: "";
|
||||
display: block;
|
||||
height: calc(var(--scale) * 0.65);
|
||||
width: calc(var(--scale) * 0.65);
|
||||
border-radius: 50%;
|
||||
background: var(--bg);
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
transform: translate(0);
|
||||
transition: transform var(--transition), background var(--transition);
|
||||
}
|
||||
|
||||
html.dark .toggle::before {
|
||||
transform: translateX(calc(var(--scale)));
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.toggle .icons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.toggle .icons svg {
|
||||
transform: scale(0.7);
|
||||
z-index: 0;
|
||||
fill: var(--bg);
|
||||
}
|
||||
</style>
|
55
app/components/CommitStatBox.vue
Normal file
55
app/components/CommitStatBox.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import type { GithubPushEvent } from "@/shared/github";
|
||||
|
||||
const FEED_URL = "https://api.github.com/users/potatoeggy/events";
|
||||
|
||||
const { data: results } = await useFetch<GithubPushEvent[]>(FEED_URL, {
|
||||
onResponse(res) {
|
||||
res.response.json;
|
||||
},
|
||||
});
|
||||
|
||||
const latestEvent = results.value?.find(
|
||||
(event: GithubPushEvent) => event.type === "PushEvent"
|
||||
);
|
||||
|
||||
const latestCommitSha = latestEvent.payload.head;
|
||||
|
||||
const imgUrl = computed(() =>
|
||||
results.value
|
||||
? `https://opengraph.githubassets.com/hash/${latestEvent.repo.name}/commit/${latestCommitSha}`
|
||||
: ""
|
||||
);
|
||||
const href = computed(() =>
|
||||
results.value
|
||||
? `https://github.com/${latestEvent.repo.name}/commit/${latestCommitSha}`
|
||||
: ""
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prose dark:prose-invert">
|
||||
<HomeStatBox
|
||||
:href
|
||||
id="github-commit-a"
|
||||
color="lightgray"
|
||||
darkcolor="slategray"
|
||||
title="Latest commit"
|
||||
:clearstyles="true"
|
||||
>
|
||||
<img
|
||||
class="m-0 w-full h-full"
|
||||
:src="imgUrl"
|
||||
id="github-commit-img"
|
||||
alt="Latest GitHub commit"
|
||||
/>
|
||||
<!--
|
||||
<div>
|
||||
<h2>{{ title }}</h2>
|
||||
<p v-if="description">{{ description }}</p>
|
||||
</div>
|
||||
-->
|
||||
<noscript> Enable JavaScript to see the latest commit! </noscript>
|
||||
</HomeStatBox>
|
||||
</div>
|
||||
</template>
|
13
app/components/Date.vue
Normal file
13
app/components/Date.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { getPrettyDate, getUtcDate } from "@/shared/metadata";
|
||||
import type { AnyParsedContent } from "@/shared/types";
|
||||
|
||||
const { doc } = defineProps<{ doc: AnyParsedContent }>();
|
||||
|
||||
const prettyDate = getPrettyDate(doc);
|
||||
const utcDate = getUtcDate(doc);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<time pubdate :datetime="utcDate">{{ prettyDate }}</time>
|
||||
</template>
|
203
app/components/HamburgerMenu.vue
Normal file
203
app/components/HamburgerMenu.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import { navItems } from "@/data/navItems";
|
||||
|
||||
const getSvgIcon = async (name: string) => {
|
||||
const module = await import(
|
||||
`../assets/images/nav/${name.toLowerCase()}.svg?raw`
|
||||
);
|
||||
return module.default;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="hamburger">
|
||||
<input
|
||||
class="checkbox"
|
||||
type="checkbox"
|
||||
id="checkbox"
|
||||
aria-label="Hamburger menu toggle"
|
||||
/>
|
||||
<label
|
||||
class="checkbox-label"
|
||||
for="checkbox"
|
||||
aria-label="Hamburger menu indicator label"
|
||||
>
|
||||
<svg class="ham ham-rotate" viewBox="0 0 100 100" width="60">
|
||||
<path
|
||||
class="line top"
|
||||
d="m 30,33 h 40 c 0,0 9.044436,-0.654587 9.044436,-8.508902 0,-7.854315 -8.024349,-11.958003 -14.89975,-10.85914 -6.875401,1.098863 -13.637059,4.171617 -13.637059,16.368042 v 40"
|
||||
/>
|
||||
<path class="line middle" d="m 30,50 h 40" />
|
||||
<path
|
||||
class="line bottom"
|
||||
d="m 30,67 h 40 c 12.796276,0 15.357889,-11.717785 15.357889,-26.851538 0,-15.133752 -4.786586,-27.274118 -16.667516,-27.274118 -11.88093,0 -18.499247,6.994427 -18.435284,17.125656 l 0.252538,40"
|
||||
/>
|
||||
</svg>
|
||||
</label>
|
||||
<ul class="drawer prose dark:prose-invert">
|
||||
<li class="m-0" v-for="(item, index) in navItems" :key="index">
|
||||
<!-- stupid vite doesn't let require work
|
||||
i should have just hardcoded the navbar items -->
|
||||
<a :href="item.href" class="p-2 flex gap-2">
|
||||
<img
|
||||
:src="`/nav/${item.title.toLowerCase()}.svg`"
|
||||
class="m-0"
|
||||
preload="auto"
|
||||
:alt="`${item.title} logo`"
|
||||
/>
|
||||
{{ item.title }}
|
||||
</a>
|
||||
<hr class="m-2" v-if="index !== navItems.length - 1" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
input.checkbox {
|
||||
outline: none;
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
input.checkbox ~ .drawer {
|
||||
opacity: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
position: absolute;
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
input.checkbox:checked ~ .drawer,
|
||||
.drawer:hover {
|
||||
/** input.checkbox:focus:not(:checked) ~ .drawer,
|
||||
* input.checkbox:hover ~ .drawer,
|
||||
*
|
||||
* play with focus to make it so that you can click outside
|
||||
* of the hamburger to close it
|
||||
* problem with focus is that pressing the menu again doesn't close it
|
||||
* also so that you can hover over it to open it — these are
|
||||
* surprisingly annoying
|
||||
*/
|
||||
opacity: 1;
|
||||
transform: scale(1) translate(0.5rem, 3.25rem);
|
||||
}
|
||||
|
||||
.drawer {
|
||||
--drawer-bg: white;
|
||||
--drawer-border-bg: gray;
|
||||
transition: transform var(--trans), opacity var(--trans), border var(--trans),
|
||||
background var(--trans);
|
||||
padding: 1rem;
|
||||
right: 0;
|
||||
background: var(--drawer-bg);
|
||||
border: 1px solid var(--drawer-border-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
border-radius: 0.5rem;
|
||||
width: 12rem;
|
||||
|
||||
--drawer-drop-color: gray;
|
||||
box-shadow: 0 0.25rem 0.5rem 0 var(--drawer-drop-color);
|
||||
}
|
||||
|
||||
html.dark .drawer {
|
||||
--drawer-bg: #222;
|
||||
--drawer-border-bg: darkslategray;
|
||||
--drawer-drop-color: black;
|
||||
}
|
||||
|
||||
.drawer::before {
|
||||
content: "";
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
|
||||
--tri-size: 0.6rem;
|
||||
border-left: var(--tri-size) solid transparent;
|
||||
border-right: var(--tri-size) solid transparent;
|
||||
border-bottom: var(--tri-size) solid var(--drawer-border-bg);
|
||||
right: 1.75rem;
|
||||
top: calc(-1 * var(--tri-size));
|
||||
transition: border var(--trans);
|
||||
}
|
||||
|
||||
.drawer::after {
|
||||
content: "";
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
|
||||
--tri-size: 0.56rem;
|
||||
border-left: var(--tri-size) solid transparent;
|
||||
border-right: var(--tri-size) solid transparent;
|
||||
border-bottom: var(--tri-size) solid var(--drawer-bg);
|
||||
right: 1.8rem;
|
||||
top: -0.53rem; /*calc(-1 * var(--tri-size));*/
|
||||
transition: border var(--trans);
|
||||
}
|
||||
|
||||
.drawer li {
|
||||
list-style: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.drawer li a {
|
||||
/* overwrite tailwind */
|
||||
text-decoration: none;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.drawer li a:hover,
|
||||
.drawer li a:active {
|
||||
--drawer-active-color: lightgray;
|
||||
background: var(--drawer-active-color);
|
||||
}
|
||||
|
||||
html.dark .drawer li a {
|
||||
--drawer-active-color: darkslategray;
|
||||
}
|
||||
|
||||
html.dark .drawer img {
|
||||
filter: invert(1); /* brightness didn't work */
|
||||
}
|
||||
|
||||
/* hamburger animation */
|
||||
|
||||
.ham {
|
||||
cursor: pointer;
|
||||
transition: transform 400ms;
|
||||
user-select: none;
|
||||
height: 3.75rem;
|
||||
}
|
||||
|
||||
.line {
|
||||
fill: none;
|
||||
transition: stroke-dasharray 400ms, stroke-dashoffset 400ms;
|
||||
stroke: #000;
|
||||
stroke-width: 5.5;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
html.dark .line {
|
||||
stroke: #fff;
|
||||
}
|
||||
.ham .top {
|
||||
stroke-dasharray: 40 139;
|
||||
}
|
||||
.ham .bottom {
|
||||
stroke-dasharray: 40 180;
|
||||
}
|
||||
input.checkbox:checked ~ label.checkbox-label .ham .top {
|
||||
stroke-dashoffset: -98px;
|
||||
}
|
||||
input.checkbox:checked ~ label.checkbox-label .ham .bottom {
|
||||
stroke-dashoffset: -138px;
|
||||
}
|
||||
|
||||
input.checkbox:checked ~ label.checkbox-label .ham-rotate {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
</style>
|
101
app/components/HeaderLoop.vue
Normal file
101
app/components/HeaderLoop.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ strings: string[]; class?: string }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1 :class="[props.class, 'text-loop relative text-center w-full h-16']">
|
||||
<span class="text absolute w-full" v-for="s in props.strings" :key="s">
|
||||
{{ s }}
|
||||
</span>
|
||||
</h1>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "sass:math";
|
||||
@mixin text-loop($els) {
|
||||
.text-loop {
|
||||
overflow: hidden;
|
||||
$duration: 3s;
|
||||
|
||||
@if $els > 1 {
|
||||
& > span {
|
||||
display: block;
|
||||
opacity: 0;
|
||||
@for $i from 1 through $els {
|
||||
&:nth-child(#{$i}) {
|
||||
animation: move-test-#{$i} $duration * $els infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@for $i from 1 through $els {
|
||||
@keyframes move-test-#{$i} {
|
||||
$interval: calc(100% / $els);
|
||||
$upper_bound: $interval * $i;
|
||||
$lower_bound: $interval * ($i - 1);
|
||||
|
||||
// we try to make the previous exit and the next enter
|
||||
// at the same time, also taking care of negatives
|
||||
|
||||
// for i = 1, this is negative, so start the animation at the end of the cycle
|
||||
@if $i > 1 {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
#{$lower_bound - $interval * 0.05} {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
|
||||
#{$lower_bound} {
|
||||
opacity: 1;
|
||||
transform: translateY(0%);
|
||||
}
|
||||
|
||||
#{$lower_bound + $interval * 0.95} {
|
||||
opacity: 1;
|
||||
transform: translateY(0%);
|
||||
}
|
||||
|
||||
#{$upper_bound} {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
@if $i == 1 {
|
||||
// reset el 1
|
||||
#{100% - $interval * 0.05} {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0%);
|
||||
}
|
||||
} @else {
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For one element, we have the following pattern. To expand it to 2+
|
||||
* els, we divide 100% by the number of els and turn on the animation
|
||||
* only at the correct time.
|
||||
* -5%: invis
|
||||
* 0%: vis
|
||||
* 95%: vis
|
||||
* 100%: invis
|
||||
*/
|
||||
|
||||
@include text-loop(3);
|
||||
</style>
|
87
app/components/HomeStatBox.vue
Normal file
87
app/components/HomeStatBox.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import type { Color, ViewportLength } from "csstype";
|
||||
|
||||
const {
|
||||
color = "pink",
|
||||
darkcolor = "#c88994",
|
||||
clearstyles = false,
|
||||
...props
|
||||
} = defineProps<{
|
||||
href?: string;
|
||||
id?: string;
|
||||
color?: Color;
|
||||
darkcolor?: Color;
|
||||
title?: string;
|
||||
clearstyles?: boolean;
|
||||
forceheight?: ViewportLength<"rem">;
|
||||
}>();
|
||||
|
||||
const padding = clearstyles ? "0" : "1rem";
|
||||
const height = props.forceheight ?? "100%";
|
||||
|
||||
// v-bind DOES NOT WORK on initial render
|
||||
// so unfortunately we have to use the old way
|
||||
|
||||
const cssVars = {
|
||||
"--padding": padding,
|
||||
"--height": height,
|
||||
"--color": color,
|
||||
"--darkcolor": darkcolor,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a class="no-underline inline-block flex flex-col items-stretch" :href :id>
|
||||
<div class="container box" :style="cssVars">
|
||||
<p class="m-0 w-full title">{{ title }}</p>
|
||||
<div class="main-content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
/* make sure width is good for fullscreen 1080p,
|
||||
* fullscreen 1080p at 1.25 scaling,
|
||||
* mobile
|
||||
*/
|
||||
width: 28rem;
|
||||
height: var(--height);
|
||||
border: 0.5rem solid var(--color);
|
||||
border-radius: 0.5rem;
|
||||
transition: transform 0.2s ease;
|
||||
box-shadow: 0 0.1rem 0.5rem 0 gray;
|
||||
}
|
||||
|
||||
.container:hover,
|
||||
.container:active {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
html.dark .container {
|
||||
border: 0.5rem solid var(--darkcolor);
|
||||
box-shadow: 0 0.1rem 0.5rem 0 black;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: var(--padding);
|
||||
padding-top: 0;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.title {
|
||||
background: var(--color);
|
||||
}
|
||||
|
||||
html.dark .title {
|
||||
background: var(--darkcolor);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.container {
|
||||
width: 90vw;
|
||||
}
|
||||
}
|
||||
</style>
|
128
app/components/Navbar.vue
Normal file
128
app/components/Navbar.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import ColourPicker from "./ColourPicker.vue";
|
||||
import { navItems } from "~/data/navItems";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="flex items-center justify-between">
|
||||
<ul>
|
||||
<li class="home-text"><a href="/">Oeufs?</a></li>
|
||||
<li v-for="(item, index) in navItems" :key="index">
|
||||
<a :href="item.href" class="flex gap-2">
|
||||
<img
|
||||
:src="`/nav/${item.title.toLowerCase()}.svg`"
|
||||
:alt="`${item.title} logo`"
|
||||
/>
|
||||
{{ item.title }}</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="flex items-center">
|
||||
<ColourPicker />
|
||||
<div class="hamburger">
|
||||
<HamburgerMenu />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
nav {
|
||||
--nav-drop-color: lightgray;
|
||||
height: 4rem;
|
||||
width: 100%;
|
||||
box-shadow: 0 0.25rem 0.5rem 0 var(--nav-drop-color);
|
||||
padding: 1rem;
|
||||
/* main stuff is z-index 1 and the hamburger must be above everything else */
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
html.dark nav {
|
||||
--nav-drop-color: black;
|
||||
}
|
||||
|
||||
html.dark nav img {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
li {
|
||||
font-size: large;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
li:hover:not(.home-text),
|
||||
li:active:not(.home-text) {
|
||||
--nav-active-color: lightgray;
|
||||
background: var(--nav-active-color);
|
||||
}
|
||||
|
||||
html.dark li:hover,
|
||||
html.dark li:active {
|
||||
--nav-active-color: darkslategray;
|
||||
}
|
||||
|
||||
li.home-text {
|
||||
font-size: x-large;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
width: 0rem;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
--trans: 0.2s ease;
|
||||
--box-trans-time: 0.4s;
|
||||
transition:
|
||||
opacity var(--trans),
|
||||
transform var(--trans),
|
||||
gap var(--trans),
|
||||
width var(--trans),
|
||||
box-shadow var(--box-trans-time) ease,
|
||||
filter var(--trans),
|
||||
padding-left var(--trans),
|
||||
padding-right var(--trans);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.hamburger {
|
||||
display: flex;
|
||||
width: 4rem;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
li:not(.home-text) {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
padding: 0;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
/* accessibility? screw accessibility
|
||||
* i want my pretty animations
|
||||
*/
|
||||
}
|
||||
|
||||
ul {
|
||||
gap: 0rem;
|
||||
}
|
||||
|
||||
nav {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
html.dark svg {
|
||||
fill: white;
|
||||
}
|
||||
</style>
|
58
app/components/PostPreviewCard.vue
Normal file
58
app/components/PostPreviewCard.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import type { AnyParsedContent } from "@/shared/types";
|
||||
import { calcReadingTime } from "@/shared/metadata";
|
||||
import { SpecialTags } from "@/data/specialTags";
|
||||
import IconStar from "@/assets/images/star.svg?component";
|
||||
|
||||
const { post, type } = defineProps<{
|
||||
post: AnyParsedContent;
|
||||
type: "stories" | "blog";
|
||||
highlighttags?: string[];
|
||||
}>();
|
||||
|
||||
const readingTime = calcReadingTime(post);
|
||||
const descText =
|
||||
type === "stories"
|
||||
? `${readingTime.words.total} words`
|
||||
: `${readingTime.minutes} min read`;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="break-words max-w-full rounded-lg p-4 shadow-md border border-2 border-gray-300 dark:border-gray-600"
|
||||
>
|
||||
<h3 class="m-0 flex items-center gap-1.5">
|
||||
<a :href="`/tags/${type}/featured`" v-if="post.tags.includes('featured')">
|
||||
<IconStar class="fill-yellow-500 outline-none" />
|
||||
</a>
|
||||
<a
|
||||
:href="post._path"
|
||||
class="no-underline text-left text-2xl sm:text-2xl font-bold hover:text-blue-700 dark:hover:text-blue-400 leading-tight transition"
|
||||
>
|
||||
{{ post.title }}
|
||||
</a>
|
||||
</h3>
|
||||
<p class="my-1 text-sm"><Date :doc="post" /> · {{ descText }}</p>
|
||||
<div class="flex flex-wrap">
|
||||
<template v-for="(tag, index) in post.tags" :key="index">
|
||||
<Tag
|
||||
:dest="`/tags/${type}/${tag}`"
|
||||
:name="tag"
|
||||
:highlight="highlighttags?.includes(tag)"
|
||||
v-if="!SpecialTags.includes(tag)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<ContentRenderer :value="post" :excerpt="true" tag="section">
|
||||
<template #empty>No excerpt available.</template>
|
||||
</ContentRenderer>
|
||||
<div class="text-right" v-if="!post.nopreview">
|
||||
<a
|
||||
:href="post._path"
|
||||
class="no-underline hover:underline font-semibold text-blue-700 dark:text-blue-400"
|
||||
>
|
||||
Continue reading →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
147
app/components/ProjectCard.vue
Normal file
147
app/components/ProjectCard.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import type { Project } from "@/data/projects";
|
||||
import { unref as _unref } from "vue";
|
||||
const { project } = defineProps<{
|
||||
project: Project;
|
||||
reverse?: boolean;
|
||||
}>();
|
||||
|
||||
const imgUrl = project.img ? `url(/images/projects/${project.img})` : "none";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a :href="project.href" class="no-underline project-anchor">
|
||||
<div class="card flex items-center justify-between">
|
||||
<div class="card-text h-full px-4 py-2">
|
||||
<div class="h-full flex flex-col justify-between">
|
||||
<div>
|
||||
<h3 class="m-0 font-bold font-sans">{{ project.name }}</h3>
|
||||
<div class="flex gap-1 items-center flex-nowrap">
|
||||
<img
|
||||
class="h-5 w-5 m-0"
|
||||
:src="`/images/langs/${lang}.svg`"
|
||||
v-for="(lang, index) in project.langs"
|
||||
:key="index"
|
||||
:alt="`${lang} logo`"
|
||||
/>
|
||||
<span
|
||||
class="text-xs text-gray-500 dark:text-gray-300 whitespace-nowrap"
|
||||
>· {{ project.license ?? "no license" }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col justify-between grow">
|
||||
<p
|
||||
class="desc-text text-gray-600 dark:text-gray-200 mt-3 mb-0 text-left text-sm"
|
||||
>
|
||||
{{ project.description }}
|
||||
</p>
|
||||
<p
|
||||
class="desc-text text-gray-600 dark:text-gray-200 text-left text-sm m-0 whitespace-nowrap"
|
||||
>
|
||||
{{ project.longDescription }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-img h-full p-4 flex" :style="{ '--imgurl': imgUrl }" />
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.project-anchor {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.project-anchor:hover h3 {
|
||||
@apply text-blue-700 dark:text-blue-400;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 0.2rem solid pink;
|
||||
background: white;
|
||||
border-radius: 1.5rem 0 1.5rem 0;
|
||||
height: 12rem;
|
||||
line-height: 1.25;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 0.1rem 0.5rem 0 gray;
|
||||
}
|
||||
|
||||
html.dark .card {
|
||||
border: 0.2rem solid rgb(126, 93, 98);
|
||||
background: #444;
|
||||
box-shadow: 0 0.1rem 0.5rem 0 black;
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.card:active {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.card-text {
|
||||
width: 25%;
|
||||
background: white;
|
||||
border-radius: 1.5rem 0 0 0;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
html.dark .card-text {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.card-img {
|
||||
width: 75%;
|
||||
background: var(--imgurl);
|
||||
background-color: rgb(255, 237, 241);
|
||||
background-position: right 90% top 15%;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
border-radius: 0 0 1.5rem 100%;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
html.dark .card-img {
|
||||
background-color: rgb(180, 136, 143);
|
||||
}
|
||||
|
||||
.desc-text {
|
||||
width: 140%;
|
||||
/* 140% is too close */
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
a.unclickable {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 720px) {
|
||||
.card-text {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.card-img {
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
.desc-text {
|
||||
width: 136%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 540px) {
|
||||
.card-text {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
.card-img {
|
||||
width: 55%;
|
||||
}
|
||||
|
||||
.desc-text {
|
||||
width: 120%;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
}
|
||||
</style>
|
87
app/components/ServiceCard.vue
Normal file
87
app/components/ServiceCard.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
const { img } = defineProps<{
|
||||
name: string;
|
||||
href: string;
|
||||
img: string;
|
||||
unclickable?: boolean;
|
||||
broken?: boolean;
|
||||
}>();
|
||||
|
||||
const imgUrl = `/images/services/${img}`;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
:href="unclickable ? '' : href"
|
||||
:class="['no-underline', { unclickable: unclickable || broken, broken }]"
|
||||
>
|
||||
<div class="card flex flex-col items-center justify-around">
|
||||
<img class="m-0" :src="imgUrl" :alt="`${name} logo`" />
|
||||
<h3 class="m-0">{{ name }}</h3>
|
||||
<p class="desc-text text-gray-600 dark:text-gray-200"><slot /></p>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
img {
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1rem;
|
||||
border: 0.2rem solid pink;
|
||||
background: rgb(255, 237, 241);
|
||||
border-radius: 0.5rem;
|
||||
width: 12rem;
|
||||
height: 12rem;
|
||||
line-height: 1.25;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 0.1rem 0.5rem 0 gray;
|
||||
}
|
||||
|
||||
a.broken::before {
|
||||
content: "PANQUIA IS ON FIRE";
|
||||
position: absolute;
|
||||
color: red;
|
||||
transform: rotate(-40deg);
|
||||
font-size: 1.5rem;
|
||||
text-align: center;
|
||||
z-index: 2;
|
||||
top: 32.5%;
|
||||
left: -8%;
|
||||
width: 125%;
|
||||
font-family: "Roboto", sans-serif;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
a.broken > .card {
|
||||
filter: grayscale(100%);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
html.dark .card {
|
||||
border: 0.2rem solid rgb(126, 93, 98);
|
||||
background: rgb(110, 90, 92);
|
||||
box-shadow: 0 0.1rem 0.5rem 0 black;
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.card:active {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.desc-text {
|
||||
font-size: 0.8rem;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
a.unclickable {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
a.unclickable .card {
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
56
app/components/StoryStatBox.vue
Normal file
56
app/components/StoryStatBox.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import type { StoryParsedContent } from "@/shared/types";
|
||||
import { calcReadingTime } from "@/shared/metadata";
|
||||
|
||||
const docs = await queryContent<StoryParsedContent>("/stories")
|
||||
.sort({ date: 1 })
|
||||
.where({ _draft: false })
|
||||
.find();
|
||||
|
||||
const latest = docs.at(-1) as StoryParsedContent;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prose dark:prose-invert flex onhover">
|
||||
<HomeStatBox
|
||||
:href="latest._path"
|
||||
color="lightgreen"
|
||||
darkcolor="#2c8a2c"
|
||||
title="Latest story"
|
||||
>
|
||||
<h2 class="m-0 mt-4 mb-1">{{ latest.title }}</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 m-0">
|
||||
<Date :doc="latest" /> · {{ calcReadingTime(latest).words.total }} words
|
||||
</p>
|
||||
<div class="tag-list mt-1">
|
||||
<Tag
|
||||
v-for="(tag, index) in latest.tags"
|
||||
:key="index"
|
||||
:dest="`/tags/stories/${tag}`"
|
||||
:name="tag"
|
||||
/>
|
||||
</div>
|
||||
<ContentRenderer
|
||||
tag="article"
|
||||
:value="latest"
|
||||
:excerpt="true"
|
||||
class="text-gray-600 dark:text-gray-300 text-base m-0 mt-5 text-ellipsis"
|
||||
>
|
||||
<ContentRendererMarkdown :value="latest" :excerpt="true" />
|
||||
<template #empty>
|
||||
<p>No description found.</p>
|
||||
</template>
|
||||
</ContentRenderer>
|
||||
</HomeStatBox>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h2 {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
div.onhover:hover h2 {
|
||||
@apply text-blue-700 dark:text-blue-400;
|
||||
}
|
||||
</style>
|
26
app/components/Tag.vue
Normal file
26
app/components/Tag.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
const { highlight } = defineProps<{
|
||||
name: string;
|
||||
dest: string;
|
||||
highlight?: boolean;
|
||||
}>();
|
||||
|
||||
// const isLinkableTag = !props.name.includes(" ");
|
||||
const isLinkableTag = true;
|
||||
const tagClass = [
|
||||
"inline-block text-xs rounded-lg py-1 px-2 mt-1 mr-1 transition border border-pink-200 dark:border-pink-900 border-2 font-medium no-underline",
|
||||
{ "bg-pink-200 dark:bg-pink-900": highlight },
|
||||
{ "shadow-md": isLinkableTag },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="tagClass">
|
||||
<a :href="dest" v-if="isLinkableTag">
|
||||
{{ name }}
|
||||
</a>
|
||||
<div v-else>
|
||||
{{ name }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
15
app/components/content/ProseImg.vue
Normal file
15
app/components/content/ProseImg.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
const { src, alt = "" } = defineProps<{ src: string; alt?: string }>();
|
||||
|
||||
const imgSrc =
|
||||
src.startsWith("http://") || src.startsWith("https://")
|
||||
? src
|
||||
: `/images/posts/${src}`;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<figure class="flex flex-col items-center">
|
||||
<img :src="imgSrc" class="drop-shadow-lg" :alt="alt" />
|
||||
<figcaption class="text-center" v-if="alt">{{ alt }}</figcaption>
|
||||
</figure>
|
||||
</template>
|
89
app/components/index/about.vue
Normal file
89
app/components/index/about.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { projects } from "@/data/projects";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prose dark:prose-invert w-full flex flex-col mt-9">
|
||||
<h1 class="text-center mb-0">Fun things</h1>
|
||||
<p class="text-center">(aka my programming projects)</p>
|
||||
<div class="flex flex-col items-center justify-around gap-5 mt-6">
|
||||
<ProjectCard
|
||||
v-for="(proj, index) in projects"
|
||||
:project="proj"
|
||||
:key="index"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h1 id="about" class="text-center mb-4 mt-8">About</h1>
|
||||
|
||||
<!-- this could be in markdown but eh -->
|
||||
<p>
|
||||
Hello! It's very nice to meet you — I'm a student who is quite passionate
|
||||
about some subjects but is quite lazy in every other.
|
||||
</p>
|
||||
<p>
|
||||
I've dabbled extensively and non-extensively in a variety of topics to
|
||||
play with, including:
|
||||
</p>
|
||||
<ul>
|
||||
<li>competitive programming on DMOJ</li>
|
||||
<li>Linux and server administration</li>
|
||||
<li>web development</li>
|
||||
<li>hackathons</li>
|
||||
<li>ski instruction</li>
|
||||
<li>writing of literature</li>
|
||||
<li>video game console emulation</li>
|
||||
</ul>
|
||||
<p>…and other things that I'm forgetting right now.</p>
|
||||
<p>
|
||||
I have three server machines at home — a Dell OptiPlex 780, a Dell
|
||||
Latitude E5520, and a custom-built PC. One of them is a laptop and
|
||||
<s>I'm surprised it hasn't burnt up yet </s>
|
||||
<span class="redphasis">it has burnt up.</span>
|
||||
</p>
|
||||
<h3>Custom PC ("hwaboon")</h3>
|
||||
<ul>
|
||||
<li><strong>CPU:</strong> AMD Ryzen 7700X (8c/16t)</li>
|
||||
<li><strong>GPU:</strong> Integrated</li>
|
||||
<li><strong>RAM:</strong> 2× 16 GB DDR5</li>
|
||||
<li><strong>Storage:</strong> Crucial P3 1 TB SSD</li>
|
||||
<li><strong>OS:</strong> Arch Linux</li>
|
||||
</ul>
|
||||
<h3>OptiPlex 780 ("asvyn")</h3>
|
||||
<ul>
|
||||
<li><strong>CPU:</strong> Intel Core 2 Duo E8400 (2c/2t)</li>
|
||||
<li><strong>GPU:</strong> AMD ATI Radeon HD 3450</li>
|
||||
<li><strong>RAM:</strong> 2× 2 GB DDR3</li>
|
||||
<li><strong>Storage:</strong> Western Digital 150 GB hard drive</li>
|
||||
<li><strong>OS:</strong> Arch Linux</li>
|
||||
</ul>
|
||||
<h3>Latitude E5520 ("panquia")</h3>
|
||||
<ul>
|
||||
<li><strong>CPU:</strong> Intel Core i5-3320M (2c/4t)</li>
|
||||
<li><strong>GPU:</strong> Integrated</li>
|
||||
<li><strong>RAM:</strong> 10 GB</li>
|
||||
<li><strong>Storage:</strong> 300 GB hard drive</li>
|
||||
<li><strong>OS:</strong> Arch Linux</li>
|
||||
<li>
|
||||
<strong>Status: </strong>
|
||||
<span class="redphasis">ON FIRE.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
p {
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.redphasis {
|
||||
font-weight: bold;
|
||||
color: red;
|
||||
}
|
||||
</style>
|
68
app/components/index/services.vue
Normal file
68
app/components/index/services.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div
|
||||
class="container prose dark:prose-invert w-full flex flex-col items-center mt-9"
|
||||
>
|
||||
<h1 class="m-0">Services</h1>
|
||||
<p class="prose dark:prose-invert">
|
||||
This site is statically generated using
|
||||
<a href="https://v3.nuxtjs.org">Nuxt.js</a> with the help of templates and
|
||||
Markdown — because really, writing HTML by hand is tedious and I don't
|
||||
know why I ever tried — and its
|
||||
<a href="https://github.com/potatoeggy/public">source is available here</a
|
||||
>.
|
||||
</p>
|
||||
<!-- i could make this a list but god i'm so tired with nuxt -->
|
||||
<div class="flex justify-around flex-wrap gap-8 items-center">
|
||||
<ServiceCard
|
||||
name="Gitea"
|
||||
href="https://git.eggipelago.com"
|
||||
img="gitea.svg"
|
||||
>
|
||||
Self-hosted GitHub
|
||||
</ServiceCard>
|
||||
<ServiceCard
|
||||
name="Eifueo"
|
||||
href="https://eifueo.eggipelago.com"
|
||||
img="eifueo.svg"
|
||||
>
|
||||
Note collection
|
||||
</ServiceCard>
|
||||
<ServiceCard
|
||||
name="Primoprod"
|
||||
href="https://primoprod.vercel.app"
|
||||
img="primogem.webp"
|
||||
>
|
||||
Wish simulator
|
||||
</ServiceCard>
|
||||
<ServiceCard
|
||||
name="Calibre"
|
||||
href="https://calibre.eggipelago.com"
|
||||
img="calibre-web.webp"
|
||||
>
|
||||
Kobo Cloud
|
||||
</ServiceCard>
|
||||
<ServiceCard
|
||||
name="Jellyfin"
|
||||
href="https://jellyfin.eggipelago.com"
|
||||
img="jellyfin.svg"
|
||||
>
|
||||
FOSS media server
|
||||
</ServiceCard>
|
||||
<ServiceCard
|
||||
name="Minecraft"
|
||||
href="minecraft.eggipelago.com"
|
||||
img="minecraft.svg"
|
||||
unclickable
|
||||
broken
|
||||
>
|
||||
Whitelisted
|
||||
</ServiceCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
max-width: unset;
|
||||
}
|
||||
</style>
|
13
app/composables/metadata.ts
Normal file
13
app/composables/metadata.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Set the page title in the format [title] | [site name].
|
||||
* @param title The title string.
|
||||
*/
|
||||
export function useTitle(title: string, description?: string) {
|
||||
useHead({
|
||||
title: `${title} | Oeufs?`,
|
||||
meta: [
|
||||
{ name: "viewport", content: " width=device-width,initial-scale=1" },
|
||||
{ name: "description", content: description ?? "" },
|
||||
],
|
||||
});
|
||||
}
|
110
app/layouts/default.vue
Normal file
110
app/layouts/default.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { revisions } from "@/data/siteRevisions";
|
||||
useHead({ title: "Oeufs?" });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center w-full h-full justify-between">
|
||||
<Navbar />
|
||||
<slot />
|
||||
<footer
|
||||
class="flex items-center justify-between p-3 bg-gray-100 w-full text-sm dark:bg-gray-800 flex-col md:flex-row gap-2"
|
||||
>
|
||||
<label class="flex items-center gap-2">
|
||||
<p>Revision:</p>
|
||||
<!--
|
||||
the onchange is so bad - i'd rather it be done through vue
|
||||
but nuxt is genuinely screwing me over here
|
||||
|
||||
ig r4 has to be in next.js
|
||||
-->
|
||||
<select
|
||||
class="p-2 border rounded-lg dark:bg-[#222]"
|
||||
onchange="location = this.value"
|
||||
>
|
||||
<option v-for="(r, i) in revisions" :key="i" :value="r.url">
|
||||
{{ r.title }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="flex flex-col items-center">
|
||||
<p>
|
||||
Licensed under the AGPL-3.0 on
|
||||
<a class="underline" href="https://github.com/potatoeggy/public">
|
||||
GitHub</a
|
||||
>
|
||||
and
|
||||
<a class="underline" href="https://git.eggipelago.com/eggy/public">
|
||||
Gitea
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-36"></div>
|
||||
</footer>
|
||||
</div>
|
||||
<slot name="top-button" />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
html {
|
||||
background: white;
|
||||
color: black;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
background 0.2s ease;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
background: #222;
|
||||
color: white;
|
||||
}
|
||||
|
||||
html::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: #222;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.2s ease;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* div#__nuxt {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
it's better if everything is sort of long but that is not the case
|
||||
*/
|
||||
|
||||
html.dark::before {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
main {
|
||||
width: 80%;
|
||||
max-width: 60rem;
|
||||
margin: auto;
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
--footer-drop-color: lightgray;
|
||||
transition: background 0.2s ease;
|
||||
box-shadow: 0 -0.05rem 0.75rem 0 var(--footer-drop-color);
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
html.dark footer {
|
||||
--footer-drop-color: black;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
main {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
</style>
|
17
app/layouts/withtop.vue
Normal file
17
app/layouts/withtop.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import Default from "./default.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Default>
|
||||
<slot />
|
||||
<template #top-button> <ButtonToTop /> </template>
|
||||
</Default>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
div#__nuxt {
|
||||
display: grid;
|
||||
grid-template-columns: auto 0;
|
||||
}
|
||||
</style>
|
13
app/pages/404.vue
Normal file
13
app/pages/404.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
useTitle("404 - Not Found", "You're lost!");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="prose dark:prose-invert max-w-3xl transition">
|
||||
<h1 class="mb-1">404 - Not Found</h1>
|
||||
<p>
|
||||
You're lost! Don't worry, here's a link
|
||||
<a href="/">back to the home page.</a>
|
||||
</p>
|
||||
</main>
|
||||
</template>
|
78
app/pages/[...slug].vue
Normal file
78
app/pages/[...slug].vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import type { AnyParsedContent } from "@/shared/types";
|
||||
import { calcReadingTime } from "@/shared/metadata";
|
||||
|
||||
const route = useRoute();
|
||||
// definePageMeta({
|
||||
// layout: "withtop",
|
||||
// });
|
||||
|
||||
// we're not using ContentDoc because i need control
|
||||
const doc = await queryContent<AnyParsedContent>(route.path).findOne();
|
||||
const type = route.path.startsWith("/stories")
|
||||
? "stories"
|
||||
: route.path.startsWith("/blog")
|
||||
? "blog"
|
||||
: "unknown";
|
||||
|
||||
const descText =
|
||||
type === "stories"
|
||||
? `${calcReadingTime(doc).words.total} words`
|
||||
: `${calcReadingTime(doc).minutes} min read`;
|
||||
useTitle(doc.title, doc.description);
|
||||
|
||||
const captionText =
|
||||
type === "stories" ? "Story" : type === "blog" ? "Blog post" : "";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="container prose dark:prose-invert w-full">
|
||||
<p class="m-0 uppercase font-mono text-sm" v-if="captionText">
|
||||
{{ captionText }}
|
||||
</p>
|
||||
<h1 class="m-0">{{ doc.title }}</h1>
|
||||
<p class="my-2"><Date :doc="doc" /> · {{ descText }}</p>
|
||||
<div class="flex flex-wrap">
|
||||
<Tag
|
||||
v-for="(tag, index) in doc.tags"
|
||||
:dest="`/tags/${type}/${tag}`"
|
||||
:key="index"
|
||||
:name="tag"
|
||||
/>
|
||||
</div>
|
||||
<ContentRenderer :value="doc" tag="article" class="pt-0 w-full">
|
||||
<template #empty>
|
||||
<p>No description found.</p>
|
||||
</template>
|
||||
<template #not-found>
|
||||
<h1>404 - Not Found</h1>
|
||||
<p>
|
||||
Thanks for dropping by! But the page you're looking for can't be
|
||||
found.
|
||||
</p>
|
||||
</template>
|
||||
</ContentRenderer>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
width: 80%;
|
||||
max-width: 80ch;
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.container {
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.container h1 {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
</style>
|
42
app/pages/blog.vue
Normal file
42
app/pages/blog.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import type { BlogParsedContent } from "@/shared/types";
|
||||
|
||||
useTitle("Blog", "Ramblings and ideas");
|
||||
//definePageMeta({ layout: "withtop" });
|
||||
|
||||
// TODO: paginate stories
|
||||
const docs = await queryContent<BlogParsedContent>("/blog")
|
||||
.sort({ date: -1 })
|
||||
.where({ _draft: false })
|
||||
.find();
|
||||
|
||||
const tags = new Set(
|
||||
docs
|
||||
.map((p) => p.tags)
|
||||
.flat()
|
||||
.sort()
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
class="flex flex-col grow prose dark:prose-invert max-w-3xl gap-6 transition"
|
||||
>
|
||||
<h1 class="mb-0">Blog</h1>
|
||||
<div class="m-0">
|
||||
Filter:
|
||||
<Tag
|
||||
:dest="`/tags/blog/${tag}`"
|
||||
v-for="(tag, index) in tags"
|
||||
:key="index"
|
||||
:name="tag"
|
||||
/>
|
||||
</div>
|
||||
<PostPreviewCard
|
||||
v-for="(post, index) in docs"
|
||||
:key="index"
|
||||
:post
|
||||
type="blog"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
39
app/pages/index.vue
Normal file
39
app/pages/index.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import Services from "@/components/index/services.vue";
|
||||
import About from "@/components/index/about.vue";
|
||||
|
||||
//definePageMeta({ layout: "withtop" });
|
||||
useTitle("Home", "Personal website!");
|
||||
|
||||
const welcomeStrings = ["Welcome!", "Bienvenue!", "欢迎!"];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="flex flex-col items-center justify-around gap-8">
|
||||
<div class="flex flex-col items-center">
|
||||
<HeaderLoop class="text-bitter font-bold" :strings="welcomeStrings" />
|
||||
<p>What are you here to see?</p>
|
||||
<p>
|
||||
For my portfolio, please visit
|
||||
<a class="underline" href="https://github.com/potatoeggy">GitHub</a>
|
||||
instead.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-around items-start w-full flex-wrap gap-x-8 gap-y-10"
|
||||
>
|
||||
<BlogStatBox />
|
||||
<StoryStatBox />
|
||||
<CommitStatBox />
|
||||
</div>
|
||||
|
||||
<Services />
|
||||
<About />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
}
|
||||
</style>
|
42
app/pages/stories.vue
Normal file
42
app/pages/stories.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import type { StoryParsedContent } from "@/shared/types";
|
||||
|
||||
useTitle("Stories", "Fantasies and worlds");
|
||||
//definePageMeta({ layout: "withtop" });
|
||||
|
||||
// TODO: paginate stories
|
||||
const docs = await queryContent<StoryParsedContent>("/stories")
|
||||
.sort({ date: -1 })
|
||||
.where({ _draft: false })
|
||||
.find();
|
||||
|
||||
const tags = new Set(
|
||||
docs
|
||||
.map((p) => p.tags)
|
||||
.flat()
|
||||
.sort()
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
class="flex flex-col grow prose dark:prose-invert max-w-3xl gap-6 transition"
|
||||
>
|
||||
<h1 class="mb-0">Stories</h1>
|
||||
<div class="m-0">
|
||||
Filter:
|
||||
<Tag
|
||||
:dest="`/tags/stories/${tag}`"
|
||||
v-for="(tag, index) in tags"
|
||||
:key="index"
|
||||
:name="tag"
|
||||
/>
|
||||
</div>
|
||||
<PostPreviewCard
|
||||
v-for="(story, index) in docs"
|
||||
:key="index"
|
||||
:post="story"
|
||||
type="stories"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
42
app/pages/tags/blog/[tag].vue
Normal file
42
app/pages/tags/blog/[tag].vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { tagInfo, type TagData } from "@/data/tagInfo";
|
||||
import type { BlogParsedContent } from "@/shared/types";
|
||||
|
||||
const route = useRoute();
|
||||
//definePageMeta({ layout: "withtop" });
|
||||
|
||||
const tag =
|
||||
typeof route.params.tag === "string" ? route.params.tag : route.params.tag[0];
|
||||
|
||||
const details: TagData = tagInfo[tag] ?? {};
|
||||
|
||||
const docs = await queryContent<BlogParsedContent>("/blog")
|
||||
.sort({ date: -1 })
|
||||
.where({ _draft: false, tags: { $contains: tag } })
|
||||
.find();
|
||||
|
||||
const title = details.name ?? `"${tag}"`;
|
||||
useTitle(title + " Posts", details.description);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
class="prose dark:prose-invert max-w-3xl flex flex-col grow gap-6 transition"
|
||||
>
|
||||
<div>
|
||||
<h1 class="mb-0">{{ title }} Posts</h1>
|
||||
<p
|
||||
v-if="details.description"
|
||||
v-html="details.description"
|
||||
class="mt-2"
|
||||
></p>
|
||||
</div>
|
||||
<PostPreviewCard
|
||||
v-for="(post, index) in docs"
|
||||
:key="index"
|
||||
:post
|
||||
:highlighttags="[tag]"
|
||||
type="blog"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
42
app/pages/tags/stories/[tag].vue
Normal file
42
app/pages/tags/stories/[tag].vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { tagInfo, type TagData } from "@/data/tagInfo";
|
||||
import type { StoryParsedContent } from "@/shared/types";
|
||||
|
||||
const route = useRoute();
|
||||
//definePageMeta({ layout: "withtop" });
|
||||
|
||||
const tag =
|
||||
typeof route.params.tag === "string" ? route.params.tag : route.params.tag[0];
|
||||
|
||||
const details: TagData = tagInfo[tag] ?? {};
|
||||
|
||||
const docs = await queryContent<StoryParsedContent>("/stories")
|
||||
.sort({ date: -1 })
|
||||
.where({ _draft: false, tags: { $contains: tag } })
|
||||
.find();
|
||||
|
||||
const title = details.name ?? `"${tag}"`;
|
||||
useTitle(title + " Stories", details.description);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
class="prose dark:prose-invert max-w-3xl flex flex-col grow gap-6 transition"
|
||||
>
|
||||
<div>
|
||||
<h1 class="mb-0">{{ title }} Stories</h1>
|
||||
<p
|
||||
v-if="details.description"
|
||||
v-html="details.description"
|
||||
class="mt-2"
|
||||
></p>
|
||||
</div>
|
||||
<PostPreviewCard
|
||||
v-for="(story, index) in docs"
|
||||
:key="index"
|
||||
:post="story"
|
||||
:highlighttags="[tag]"
|
||||
type="stories"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
Reference in New Issue
Block a user