chore: upgrade to nuxt 4

This commit is contained in:
eggy
2025-10-19 15:32:55 +08:00
parent e814c77333
commit 21c717ed79
50 changed files with 3353 additions and 4155 deletions

5
app/app.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

52
app/assets/css/base.scss Normal file
View 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
View 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;
}
}

View 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

View 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

View 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

View 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>

View 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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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>

View 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
View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>

View 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>

View 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>