Extract Vue frontend into separate container and add stp_wasm crate
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m58s

Move Vue app from nginx/vue/ to top-level vue/ with its own Dockerfile,
update docker-compose configs and nginx proxy to serve from the new
container, and add initial Rust WASM crate (stp_wasm). Also fix .gitignore
to exclude Rust target/ directories.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 16:40:45 +00:00
parent 2b84730126
commit d3d3269d49
182 changed files with 215 additions and 34 deletions

12
vue/src/App.vue Normal file
View File

@@ -0,0 +1,12 @@
<script setup>
import { RouterView } from "vue-router";
import Navbar from "@/components/Navbar.vue";
import Footer from "@/components/Footer.vue";
</script>
<template>
<Navbar class="no-print sticky" />
<RouterView />
<!-- <Footer style="height: 10vh" /> -->
</template>

299
vue/src/assets/styles.css Normal file
View File

@@ -0,0 +1,299 @@
@import "tailwindcss";
/* PRINTING */
@media print {
.no-print,
.no-print * {
display: none !important;
margin: 0px;
padding: 0px;
width: 0x;
height: 0px;
}
}
/* END OF PRINTING */
/* FONTS */
@font-face {
font-family: "big_noodle_titling";
src: url("/fonts/big_noodle_titling.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "CreatoDisplay";
src: url("/fonts/CreatoDisplay-Bold.otf") format("opentype");
font-weight: normal;
font-style: normal;
}
/* END OF FONTS */
/* VARIABLES */
:root {
/* RED, WHITE, BLACK are standard*/
--portal_grey: #dddddd;
--portal_orange: #ff9a00;
--portal_light_orange: #ff5d00;
--portal_blue: #0065ff;
--portal_light_blue: #00a2ff;
/* MAIN COLORS */
--primary: #55ffbb;
--secondary: #62ff57;
--tertiary: #ff579a;
--quaternary: #024942;
/* BACKGROUND COLORS */
--bg_primary: #1b110e;
--bg_secondary: #000;
--link: #222;
--bdr: 2px;
--spacing: 3px;
/* FONTS USED */
--font_heading: big_noodle_titling;
--font_default: CreatoDisplay;
}
@theme {
--color-primary: var(--primary);
--color-secondary: var(--secondary);
--color-tertiary: var(--tertiary);
--color-quaternary: var(--quaternary);
--color-bg_primary: var(--bg_primary);
--color-bg_secondary: var(--bg_secondary);
--color-link: var(--link);
--borderWidth-primary: var(--primary);
--borderWidth-secondary: var(--secondary);
--borderWidth-tertiary: var(--tertiary);
--font-heading: var(--font_heading);
--default-font-family: var(--font_default);
}
/* END OF VARIABLES */
/* ELEMENTS */
body {
margin: 0 auto;
width: 100vw;
height: 100vh;
}
main {
@apply overflow-y-scroll w-full h-full p-10;
}
input {
@apply text-secondary border-primary border;
}
small {
@apply text-tertiary;
}
code {
@apply text-tertiary;
}
ul {
@apply text-tertiary;
}
li {
@apply text-tertiary;
}
h1,
h2,
h3,
h4 {
@apply m-1 font-heading text-primary;
}
h3,
h4 {
@apply text-lg;
}
h1 {
@apply text-2xl;
}
h2 {
@apply text-xl;
}
p {
@apply text-secondary;
}
a {
@apply text-primary bg-link text-center font-heading tracking-wide;
}
input,
textarea {
@apply text-primary border p-2 w-full;
}
input::placeholder,
textarea::placeholder {
@apply text-secondary opacity-50;
}
table {
@apply border-primary border text-primary;
}
td {
@apply gap-1;
}
tr {
@apply border-primary border-b text-primary;
}
th {
@apply pr-3 pl-3 border-r border-dotted border-tertiary;
}
td {
@apply pr-3 pl-3;
}
/* END OF ELEMENTS */
/* CLASSES */
.img-stamp {
width: 99px;
height: 55px;
}
/* BORDERS */
.bdr-1 {
@apply border-30;
border-image: url("/img/borders/border1.gif") 30 round;
}
.bdr-1-inv {
@apply border-30;
border-image: url("/img/borders/border1inv.gif") 30 round;
}
.bdr-2 {
@apply border-5;
border-image: url("/img/borders/border4.gif") 7 round;
}
.bdr-cv {
@apply border-30;
border-image: url("/img/borders/bordercv.png") 30 round;
}
/* A5 Page */
.a5page-landscape {
@apply m-0 box-content;
height: 148mm;
width: 210mm;
}
.a5page-portrait {
@apply m-0 box-content;
width: 148mm;
height: 210mm;
}
/* A4 Page */
.a4page-portrait {
@apply m-0 box-content;
width: 210mm;
height: 297mm;
}
.a4page-landscape {
@apply m-0 box-content;
height: 210mm;
width: 297mm;
}
/* END OF CLASSES */
/* PHONE */
@media (max-width: 850px) {
.a4page-portrait {
width: 100%;
/* fill mobile width */
height: fit-content;
margin: 0 auto;
/* center horizontally */
box-sizing: border-box;
}
.a4page-landscape {
width: 100%;
/* fill mobile width */
height: fit-content;
margin: 0 auto;
/* center horizontally */
box-sizing: border-box;
}
}
@media (max-width: 600px) {
.a5page-portrait {
width: 100%;
height: fit-content;
margin: 0 auto;
box-sizing: border-box;
}
.a5page-landscape {
width: 100%;
height: fit-content;
margin: 0 auto;
box-sizing: border-box;
}
}
.tl {
@apply absolute top-0 left-0;
}
.tr {
@apply absolute top-0 right-0;
}
.bl {
@apply absolute bottom-0 left-0;
}
.br {
@apply absolute bottom-0 right-0;
}
.background {
@apply fixed;
}
.halftone {
--dot_size: 1px;
--bg_size: 3px;
--bg_pos: calc(var(--bg_size) / 2);
--blur: 0%;
background-color: var(--bg_secondary);
background-image: radial-gradient(circle at center,
var(--bg_primary) var(--dot_size),
transparent var(--blur));
background-size: var(--bg_size) var(--bg_size);
background-position: 0 0;
}

View File

@@ -0,0 +1,3 @@
<template>
<footer></footer>
</template>

View File

@@ -0,0 +1,67 @@
<script setup>
import Headline from "@/components/text/Headline.vue";
import { computed } from "vue";
import { useRoute } from "vue-router";
const route = useRoute();
const parentPath = computed(() => {
const segments = route.path.split("/").filter(Boolean);
if (segments.length == 1) {
return "/";
} else {
segments.pop();
return segments.length ? "/" + segments.join("/") : null;
}
});
const inHome = computed(() => {
return route.path == "/" || route.path == "/stp";
});
const faces = [
"^_^",
"¯\\_(ツ)_/¯",
"(◕‿◕✿)",
"ಠ_ಠ",
"ʘ‿ʘ",
"^̮^",
">_>",
"¬_¬",
"˙ ͜ʟ˙",
"( ͡° ͜ʖ ͡°)",
"[̲̅$̲̅(̲̅5̲̅)̲̅$̲̅]",
"(ง'̀-'́)ง",
"\ (•◡•) /",
"( ͡ᵔ ͜ʖ ͡ᵔ )",
"ᕙ(⇀‸↼‶)ᕗ",
"⚆ _ ⚆",
"(。◕‿◕。)",
"(╯°□°)╯︵ ʞooqǝɔɐɟ",
"̿ ̿ ̿'̿'\̵͇̿̿\з=(•_•)=ε/̵͇̿̿/'̿'̿ ̿",
"(☞゚ヮ゚)☞ ☜(゚ヮ゚☜)",
];
const faces_string = faces.join(" ");
</script>
<template>
<nav class="flex flex-row w-full h-fit border border-primary bg-bg_primary">
<RouterLink class="bdr-2 bg-bg_primary" to="/" v-if="!inHome">
<span>HOME</span>
</RouterLink>
<RouterLink class="bdr-2 bg-bg_primary" v-if="parentPath" :to="parentPath">
<span>UP</span>
</RouterLink>
<Headline class="border flex-1 max-w-full">
<code class="whitespace-pre">{{ faces_string }}</code>
</Headline>
</nav>
</template>
<style scoped>
.left {
position: fixed;
top: 0;
left: 0;
}
</style>

View File

@@ -0,0 +1,57 @@
<template>
<div class="container">
<img src="/img/borders/utena.png" class="flower tl antirotate" />
<img src="/img/borders/utena.png" class="flower tr rotate" />
<img src="/img/borders/utena.png" class="flower bl rotate" />
<img src="/img/borders/utena.png" class="flower br antirotate" />
<slot />
</div>
</template>
<style scoped>
.container {
position: relative;
margin: 100px;
}
.flower {
position: absolute;
width: 150px;
}
.tl {
top: -80px;
left: -80px;
--start: 0deg;
}
.tr {
top: -80px;
right: -80px;
--start: 90deg;
}
.bl {
bottom: -80px;
left: -80px;
--start: 180deg;
}
.br {
bottom: -80px;
right: -80px;
--start: 270deg;
}
.rotate {
animation: spin 3s linear infinite;
}
.antirotate {
animation: spin 3s linear infinite reverse;
}
@keyframes spin {
from {
transform: rotate(var(--start));
}
to {
transform: rotate(calc(var(--start) + 360deg));
}
}
</style>

View File

@@ -0,0 +1,88 @@
<script setup>
import { ref, onMounted, useTemplateRef, onUnmounted } from "vue";
import { getRandomColor } from "@/js/utils";
const container = ref(null);
// List of (offset, width)
function generateOffsets(width = 100, step = 10, n = 20) {
return Array.from({ length: n }, (_, i) => ({
width,
offset: step * i,
color: getRandomColor(),
}));
}
const offsets = ref(generateOffsets((150, 15, 10)));
let rafId;
const speed = 0.5; // pixels per frame
function animate() {
const ctnr = container.value;
for (const item of offsets.value) {
const width = Math.max(ctnr.offsetWidth, item.width);
console.log(ctnr.offsetWidth);
item.offset -= speed;
if (item.offset <= -width) {
item.color = getRandomColor();
item.offset = 0;
}
}
rafId = requestAnimationFrame(animate);
}
onMounted(() => {
rafId = requestAnimationFrame(animate);
});
onUnmounted(() => {
cancelAnimationFrame(rafId);
});
</script>
<template>
<div class="bg-primary container" ref="container">
<div :key="index" v-for="(item, index) in offsets">
<div
:style="{
width: item.width + 'px',
translate: item.offset + 'px',
backgroundColor: item.color,
}"
class="item item1"
/>
<div
:style="{
width: item.width + 'px',
right: -item.width + 'px',
translate: item.offset + 'px',
backgroundColor: item.color,
}"
class="item item2"
/>
</div>
</div>
</template>
<style scoped>
.container {
position: relative;
overflow: hidden;
width: 100%;
will-change: transform;
}
.item {
opacity: 40%;
height: 100%;
position: absolute;
}
.item1 {
left: 0px;
top: 0px;
}
.item2 {
top: 0px;
}
</style>

View File

@@ -0,0 +1,11 @@
<script setup></script>
<template>
<button
class="text-primary bg-link text-center border cursor-pointer transition-colors duration-150 ease-in-out hover:bg-bg_primary active:scale-95"
>
<slot />
</button>
</template>
<style scoped></style>

View File

@@ -0,0 +1,31 @@
<script setup>
import { ref } from "vue";
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:modelValue"]);
function toggle() {
emit("update:modelValue", !props.modelValue);
}
</script>
<template>
<button
@click="toggle"
class="box-content border-2 border-primary w-20 h-fit rounded-full cursor-pointer"
:class="[props.modelValue ? 'bg-bg_secondary' : 'bg-bg_primary']"
>
<svg
viewBox="0 0 40 40"
class="w-10 h-10 transition-all duration-300 ease-in-out"
:class="[props.modelValue ? 'ml-10' : 'ml-0']"
>
<circle class="fill-primary" cx="20" cy="20" r="20" />
</svg>
</button>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<div class="w-full border-b border-primary">
<h1 class="pl-2 m-0">
<slot />
</h1>
</div>
</template>

View File

@@ -0,0 +1,85 @@
<script setup>
import { onMounted, useTemplateRef, onUnmounted } from "vue";
const container = useTemplateRef("container");
const item1 = useTemplateRef("item1");
let offset = 0;
let cachedWidth = 0;
let rafId;
const speed = 0.5; // pixels per frame
function measureWidth() {
const ctnr = container.value;
const it1 = item1.value;
if (ctnr && it1) {
cachedWidth = Math.max(ctnr.offsetWidth, it1.scrollWidth);
}
}
function animate() {
const ctnr = container.value;
if (!ctnr || cachedWidth === 0) {
rafId = requestAnimationFrame(animate);
return;
}
offset -= speed;
if (offset <= -cachedWidth) {
offset += cachedWidth;
}
ctnr.style.transform = `translateX(${offset}px)`;
rafId = requestAnimationFrame(animate);
}
let resizeObserver;
onMounted(() => {
measureWidth();
rafId = requestAnimationFrame(animate);
resizeObserver = new ResizeObserver(measureWidth);
resizeObserver.observe(container.value);
});
onUnmounted(() => {
cancelAnimationFrame(rafId);
resizeObserver?.disconnect();
});
</script>
<template>
<div class="root">
<div class="container" ref="container">
<div ref="item1">
<slot />
</div>
<div>
<slot />
</div>
</div>
</div>
</template>
<style scoped>
.root {
overflow: hidden;
}
.container {
width: fit-content;
height: fit-content;
display: grid;
grid-auto-flow: column;
grid-auto-columns: max-content;
/* Each column fits its content */
overflow-x: visible;
will-change: transform;
gap: 10em;
}
</style>

View File

@@ -0,0 +1,39 @@
<script setup>
import { computed } from "vue";
const props = defineProps({
href: { type: String, default: "" },
to: { type: String, default: "" },
target: { type: String, default: undefined },
rel: { type: String, default: undefined },
});
const computedRel = computed(() => {
if (props.rel !== undefined) return props.rel;
if (props.target === "_blank") return "noopener noreferrer";
return undefined;
});
</script>
<template>
<RouterLink v-if="to" :to="to" class="inline-link">
<slot />
</RouterLink>
<a v-else :href="href" :target="target" :rel="computedRel" class="inline-link">
<slot />
</a>
</template>
<style scoped>
.inline-link {
color: var(--primary);
font-weight: bold;
font-style: italic;
text-decoration: none;
transition: color 0.15s ease;
}
.inline-link:hover {
color: var(--tertiary);
}
</style>

View File

@@ -0,0 +1,38 @@
<script setup>
import { computed } from "vue";
const props = defineProps({
href: { type: String, default: "" },
to: { type: String, default: "" },
target: { type: String, default: undefined },
rel: { type: String, default: undefined },
bare: { type: Boolean, default: false },
});
const computedRel = computed(() => {
if (props.rel !== undefined) return props.rel;
if (props.target === "_blank") return "noopener noreferrer";
return undefined;
});
</script>
<template>
<RouterLink v-if="to" :to="to" :class="{ link: !bare }">
<slot />
</RouterLink>
<a v-else :href="href" :target="target" :rel="computedRel" :class="{ link: !bare }">
<slot />
</a>
</template>
<style scoped>
.link {
color: var(--primary);
text-decoration: none;
transition: color 0.15s ease;
}
.link:hover {
color: var(--tertiary);
}
</style>

View File

@@ -0,0 +1,5 @@
<template>
<p class="p-1">
<slot />
</p>
</template>

View File

@@ -0,0 +1,37 @@
<script setup>
import { ref } from "vue";
import ToggleButton from "@/components/input/ToggleButton.vue";
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:modelValue"]);
const toggleButtonRef = ref(null);
const updateValue = (newValue) => {
emit("update:modelValue", newValue);
};
const handleClick = () => {
toggleButtonRef.value?.$el?.click();
};
</script>
<template>
<div
class="w-full border-b border-primary cursor-pointer"
@click="handleClick"
>
<h1 class="pl-2 m-0">
<slot />
</h1>
<ToggleButton
class="pointer-events-none"
:model-value="props.modelValue"
@update:model-value="updateValue"
ref="toggleButtonRef"
/>
</div>
</template>

View File

@@ -0,0 +1,78 @@
<template>
<div ref="container" @mouseover="handleHover" class="overflow-y-auto">
<slot />
</div>
</template>
<script setup>
import { useTemplateRef, onMounted, onBeforeUnmount } from "vue";
const container = useTemplateRef("container");
const SPEED = 0.0005; // % per frame
const PAUSE = 2000; // ms at top/bottom
let pos = 0;
let direction = 1; // 1 = down, -1 = up
let timeoutId;
let timeoutId2;
let cachedScrollHeight = 0;
function measureScrollHeight() {
const el = container.value;
if (el) cachedScrollHeight = el.scrollHeight;
}
function handleHover() {
cancelAnimationFrame(timeoutId);
clearTimeout(timeoutId2);
timeoutId2 = setTimeout(
() => (timeoutId = requestAnimationFrame(tick)),
PAUSE,
);
}
function tick() {
const el = container.value;
if (!el || cachedScrollHeight === 0) {
timeoutId = requestAnimationFrame(tick);
return;
}
const reachedBottom = pos <= 0;
const reachedTop = pos >= 1;
if (reachedBottom) {
pos = 0.001;
direction = 1;
handleHover();
return;
} else if (reachedTop) {
pos = 0.999;
direction = -1;
handleHover();
return;
}
pos += direction * SPEED;
el.scrollTop = pos * cachedScrollHeight;
timeoutId = requestAnimationFrame(tick);
}
let resizeObserver;
onMounted(() => {
measureScrollHeight();
timeoutId = requestAnimationFrame(tick);
resizeObserver = new ResizeObserver(measureScrollHeight);
resizeObserver.observe(container.value);
});
onBeforeUnmount(() => {
cancelAnimationFrame(timeoutId);
resizeObserver?.disconnect();
});
</script>

View File

@@ -0,0 +1,200 @@
<script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from "vue";
import Button from "@/components/input/Button.vue";
import { useMessagesStore } from "@/stores/messages";
import { useAuthStore } from "@/stores/auth";
import Header from "@/components/text/Header.vue";
import Link from "@/components/text/Link.vue";
const messagesStore = useMessagesStore();
const authStore = useAuthStore();
const messages = computed(() => messagesStore.messages);
const messageInput = ref("");
const messagesContainer = ref(null);
const messagesInner = ref(null);
const fileInput = ref(null);
const isNearBottom = ref(true);
const SCROLL_THRESHOLD = 100;
let resizeObserver = null;
function scrollToBottom() {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
}
}
function scrollToBottomIfNear() {
if (isNearBottom.value) {
scrollToBottom();
}
}
function onScroll() {
if (!messagesContainer.value) return;
const { scrollHeight, scrollTop, clientHeight } = messagesContainer.value;
isNearBottom.value = scrollHeight - scrollTop - clientHeight < SCROLL_THRESHOLD;
}
function goToBottom() {
isNearBottom.value = true;
scrollToBottom();
}
watch(
() => messages.value.length,
() => {
nextTick(scrollToBottomIfNear);
},
);
function sendMessage() {
const text = messageInput.value.trim();
if (!text) return;
isNearBottom.value = true;
messagesStore.sendMessage(text);
messageInput.value = "";
}
async function onFileSelected(e) {
const file = e.target.files[0];
if (!file) return;
isNearBottom.value = true;
await messagesStore.uploadAndSendFile(file);
fileInput.value.value = "";
}
function isImageUrl(url) {
return /\.(jpg|jpeg|png|gif|webp)$/i.test(url);
}
function isVideoUrl(url) {
return /\.(mp4|webm|ogg|mov)$/i.test(url);
}
function isSafeFileUrl(url) {
return typeof url === "string" && url.startsWith("/uploads/");
}
const urlRegex = /(https?:\/\/[^\s<]+)/g;
function parseMessageParts(text) {
const parts = [];
let lastIndex = 0;
let match;
while ((match = urlRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push({
type: "text",
value: text.slice(lastIndex, match.index),
});
}
parts.push({ type: "link", value: match[1] });
lastIndex = urlRegex.lastIndex;
}
if (lastIndex < text.length) {
parts.push({ type: "text", value: text.slice(lastIndex) });
}
return parts;
}
onMounted(() => {
messagesStore.connect();
if (messagesContainer.value) {
messagesContainer.value.addEventListener("scroll", onScroll, { passive: true });
}
if (messagesInner.value) {
resizeObserver = new ResizeObserver(scrollToBottomIfNear);
resizeObserver.observe(messagesInner.value);
}
scrollToBottom();
});
onUnmounted(() => {
messagesStore.disconnect();
if (messagesContainer.value) {
messagesContainer.value.removeEventListener("scroll", onScroll);
}
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
});
</script>
<template>
<div class="chat-root flex-col flex min-h-0">
<Header>Chat</Header>
<div ref="messagesContainer" class="flex flex-col flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-2 min-w-0">
<div ref="messagesInner">
<p v-for="message in messages" :key="message.id" class="break-words min-w-0 w-full">
<span class="text-tertiary">{{ message.authorId }}:</span>
<template
v-for="(part, i) in parseMessageParts(message.text || '')"
:key="i"
>
<Link
v-if="part.type === 'link'"
bare
:href="part.value"
target="_blank"
class="text-primary underline break-all"
>{{ part.value }}</Link
>
<span v-else>{{ part.value }}</span>
</template>
<template v-if="message.fileUrl && isSafeFileUrl(message.fileUrl)">
<img
v-if="isImageUrl(message.fileUrl)"
:src="message.fileUrl"
loading="lazy"
class="w-full max-w-full max-h-48 rounded block"
/>
<video
v-else-if="isVideoUrl(message.fileUrl)"
:src="message.fileUrl"
controls
preload="none"
class="w-full max-w-full max-h-48 rounded block"
/>
<Link v-else bare :href="message.fileUrl" target="_blank" class="underline break-all">{{
message.fileUrl.split("/").pop()
}}</Link>
</template>
</p>
</div>
</div>
<div>
<input v-model="messageInput" @keyup.enter="sendMessage" />
<input
ref="fileInput"
type="file"
class="hidden"
@change="onFileSelected"
/>
<div class="flex gap-2">
<Button class="flex-1" @click="sendMessage">Send</Button>
<Button
v-if="authStore.user.admin"
class="flex-1"
@click="fileInput.click()"
>Attach</Button
>
<Button v-if="!isNearBottom" class="flex-1" @click="goToBottom">Bottom</Button>
</div>
</div>
</div>
</template>
<style scoped>
@media (max-width: 850px) {
.chat-root {
max-height: 400px;
}
}
</style>

View File

@@ -0,0 +1,37 @@
<script setup>
import { useHomeDataStore } from "@/stores/homeData";
import { storeToRefs } from "pinia";
import Header from "@/components/text/Header.vue";
import Link from "@/components/text/Link.vue";
const homeData = useHomeDataStore();
const { gitFeed: feed, loaded } = storeToRefs(homeData);
</script>
<template>
<div class="flex flex-col text-center min-h-0 h-full overflow-x-hidden">
<Header class="text-left">Commits</Header>
<div v-if="!loaded" class="flex-1 overflow-y-auto">
<p>Loading latest activity...</p>
</div>
<div
v-else-if="feed"
class="flex-1 flex flex-col overflow-y-auto overflow-x-hidden"
>
<h3>Last git activity</h3>
<img :src="feed.avatarUrl" alt="User avatar" class="avatar" />
<Link :href="feed.repoUrl">
<h3>repo: {{ feed.repoName }}</h3>
</Link>
<p>Action: {{ feed.opType }}</p>
<p>Message: {{ feed.commitMessage }}</p>
<small> {{ new Date(feed.createdAt).toLocaleString() }}</small>
</div>
<div v-else class="flex-1 overflow-y-auto">
<p>No activity found.</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,73 @@
<script setup>
import { ref } from "vue";
import Link from "@/components/text/Link.vue";
import ToggleHeader from "@/components/text/ToggleHeader.vue";
const props = defineProps({
items: {
type: Array,
required: true,
},
variant: {
type: String,
default: "list",
},
title: {
type: String,
default: "",
},
});
const show = ref(false);
</script>
<template>
<div v-if="title" class="h-fit w-full">
<ToggleHeader v-model="show" class="justify-between flex">
{{ title }}
</ToggleHeader>
<template v-if="show">
<Link
v-if="variant === 'list'"
v-for="(item, i) in items"
:key="i"
:href="item.link"
>
<p class="bdr-2 bg-bg_tertiary">{{ item.name }}</p>
</Link>
<table class="w-full" v-else>
<tbody>
<tr v-for="item in items" :key="item.id">
<th>{{ item.type }}</th>
<td v-if="item.link">
<Link :href="item.link">{{ item.name }}</Link>
</td>
<td v-else>{{ item.name }}</td>
</tr>
</tbody>
</table>
</template>
</div>
<template v-else>
<template v-if="variant === 'list'">
<Link
v-for="(item, i) in items"
:key="i"
:href="item.link"
>
<p class="bdr-2 bg-bg_tertiary">{{ item.name }}</p>
</Link>
</template>
<table class="w-full" v-else>
<tbody>
<tr v-for="item in items" :key="item.id">
<th>{{ item.type }}</th>
<td v-if="item.link">
<Link :href="item.link">{{ item.name }}</Link>
</td>
<td v-else>{{ item.name }}</td>
</tr>
</tbody>
</table>
</template>
</template>

View File

@@ -0,0 +1,22 @@
<script setup>
import MarkdownIt from "markdown-it";
import { katex } from "@mdit/plugin-katex";
const mdIt = MarkdownIt().use(katex);
//.use(wiki);
const props = defineProps({
source: String,
});
</script>
<template>
<div
v-html="mdIt.render(props.source)"
class="flex flex-col items-center"
></div>
</template>
<style>
@import "katex/dist/katex.min.css";
</style>

View File

@@ -0,0 +1,93 @@
<script setup>
import Button from "@/components/input/Button.vue";
</script>
<template>
<audio/>
<div class="musicPlayerGrid">
<div class="album_cover">
<img src="/img/Untitled.png"></img>
</div>
<div class="controls">
<div class="sliders">
<div class="timeline"/>
<div class="volume"/>
</div>
<div class="buttons">
<div class="rewind"/>
<div class="playPause"/>
<div class="fastforward"/>
</div>
</div>
</div>
</template>
<style scoped>
.musicPlayerGrid {
display: grid;
grid-gap: 5px;
grid-template-rows: repeat(4, 1fr);
background-color: blue;
align-items: stretch; /* rows (block axis) */
justify-items: stretch; /* columns (inline axis) */
padding: 5px;
}
img {
width: 100%;
}
.album_cover {
grid-row: 1 / span 3;
background-color: grey;
box-sizing: border-box;
}
.controls {
width: 100%;
grid-row: 4 / span 1;
box-sizing: border-box;
display: grid;
grid-template-rows: repeat(4, 1fr);
grid-gap: 5px;
}
.sliders {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-gap: 5px;
}
.timeline {
grid-column: 1;
background-color: white;
}
.volume {
grid-column: 2;
background-color: white;
}
.buttons {
background-color: black;
grid-row: 2 / -1;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-gap: 5px;
}
.rewind {
grid-column: 1;
background-color: grey;
}
.fastforward {
grid-column: 4;
background-color: grey;
}
.playPause {
grid-column: 2/span 2;
background-color: grey;
}
</style>

View File

@@ -0,0 +1,43 @@
<script setup>
import { computed } from "vue";
const props = defineProps({
objArr: {
type: Array,
required: true,
},
});
const resolvedColumns = computed(() => {
const keys = new Set();
for (const obj of props.objArr) {
Object.keys(obj).forEach((key) => keys.add(key));
}
return Array.from(keys).map((key) => ({
key,
label: key,
}));
});
</script>
<template>
<table>
<thead>
<tr>
<th v-for="col in resolvedColumns" :key="col.key">
{{ col.label }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in objArr" :key="rowIndex">
<td v-for="col in resolvedColumns" :key="col.key">
{{ row[col.key] ?? "" }}
</td>
</tr>
</tbody>
</table>
</template>

View File

@@ -0,0 +1,56 @@
<template>
<div v-if="streamLive" class="overflow-hidden">
<Header>Radio</Header>
<img src="/img/tmpen31z3pe.PNG" />
<audio controls :src="streamUrl" ref="audio"></audio>
</div>
<div v-else>
<Header>Radio</Header>
<img src="/img/tmpen31z3pe.PNG" />
<div class="m-1 text-center">
<p>Radio is offline. Message for info!</p>
<Button class="w-full" @click="checkStream()">Check Stream</Button>
</div>
</div>
</template>
<script setup>
import Button from "@/components/input/Button.vue";
import Header from "@/components/text/Header.vue";
import { ref, useTemplateRef, onMounted, nextTick } from "vue";
import axios from "axios";
const streamUrl = ref("");
const streamLive = ref(false);
const audio = useTemplateRef("audio");
async function checkStream() {
try {
await axios.head("/radio/stream");
if (!streamLive.value) {
streamLive.value = true;
streamUrl.value = "/radio/stream";
await nextTick();
if (audio.value) {
audio.value.load();
audio.value.volume = 0.2;
}
}
} catch (err) {
streamLive.value = false;
}
}
onMounted(() => {
checkStream();
setInterval(checkStream, 120000);
});
</script>
<style scoped>
img {
width: 100%;
max-height: 150px;
object-fit: cover;
}
</style>

View File

@@ -0,0 +1,23 @@
<script setup>
import { computed } from "vue";
const props = defineProps({
linkArr: {
type: Array,
required: true,
},
});
const keys = ["name", "link"];
</script>
<template>
<RouterLink
class="bdr-2 bg-bg_primary"
v-for="(row, rowIndex) in linkArr"
:key="rowIndex"
:to="row.link"
>
{{ row.name }}
</RouterLink>
</template>

View File

@@ -0,0 +1,77 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
import Header from "@/components/text/Header.vue";
const props = defineProps({
images: {
type: Array,
required: true,
},
interval: {
type: Number,
default: 10000,
},
});
const currentIndex = ref(0);
const currentComment = computed(() => props.images[currentIndex.value].comment);
const currentUrl = computed(() => props.images[currentIndex.value].url);
let nextId;
function nextImage() {
clearTimeout(nextId);
currentIndex.value = (currentIndex.value + 1) % props.images.length;
nextId = setTimeout(nextImage, props.interval);
}
onMounted(() => {
nextId = setTimeout(nextImage, props.interval);
});
onUnmounted(() => {
clearTimeout(nextId);
});
</script>
<template>
<div class="slideshow-wrapper">
<Transition name="fade">
<div class="image-viewer" @click="nextImage" :key="currentIndex">
<Header v-if="currentComment">
{{ currentComment }}
</Header>
<img :src="currentUrl" alt="Image Viewer" loading="lazy" />
</div>
</Transition>
</div>
</template>
<style scoped>
.slideshow-wrapper {
display: grid;
width: 100%;
overflow: hidden;
}
.image-viewer {
grid-area: 1 / 1;
width: 100%;
overflow: hidden;
}
img {
width: 100%;
object-fit: cover;
display: block;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,38 @@
<script setup>
import Header from "@/components/text/Header.vue";
import { ref } from "vue";
const time = ref("");
const weekday = ref("");
const day = ref("");
const month = ref("");
function updateDateTime() {
const date = new Date();
day.value = date.getDate();
time.value = date.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
});
weekday.value = date.toLocaleDateString("en-GB", { weekday: "long" });
month.value = date.toLocaleDateString("en-GB", { month: "long" });
}
updateDateTime();
setInterval(updateDateTime, 60000);
</script>
<template>
<div class="flex flex-col">
<Header>{{ weekday }} {{ day }}, {{ month }}</Header>
<h1>{{ time }}</h1>
</div>
</template>
<style scoped>
div {
text-align: center;
padding: 4px;
}
</style>

View File

@@ -0,0 +1,124 @@
<script setup>
import Button from "@/components/input/Button.vue";
import Header from "@/components/text/Header.vue";
import { ref } from "vue";
const timer = ref(null);
const finished = ref(true);
const paused = ref(true);
const minutesInput = ref(0);
const secondsInput = ref(0);
const minutes = ref(0);
const seconds = ref(0);
const audio = new Audio("/sound/auughhh.mp3");
function tick() {
seconds.value++;
if (seconds.value === 60) {
minutes.value++;
seconds.value = 0;
}
if (minutes.value >= minutesInput.value) {
if (seconds.value >= secondsInput.value) {
finished.value = true;
playFinishedSound();
clearInterval(timer.value);
}
}
}
function startTimer() {
finished.value = false;
paused.value = false;
timer.value = setInterval(tick, 1000);
}
function pauseTimer() {
if (finished.value) return;
if (paused.value) {
timer.value = setInterval(tick, 1000);
paused.value = false;
} else {
clearInterval(timer.value);
paused.value = true;
}
}
function resetTimer() {
finished.value = true;
paused.value = true;
clearInterval(timer.value);
minutes.value = 0;
seconds.value = 0;
}
function playFinishedSound() {
audio.play();
}
</script>
<template>
<div class="timer-root flex flex-col gap-1 p-1 items-center">
<Header>Timer</Header>
<div v-if="finished && paused" class="flex flex-col">
<div class="flex flex-row p-2 place-content-around">
<input
class="w-2/3"
v-model="minutesInput"
type="range"
min="0"
max="59"
/>
<p>{{ minutesInput }}m</p>
</div>
<div class="flex flex-row p-2 place-content-around">
<input
class="w-2/3"
v-model="secondsInput"
type="range"
min="0"
max="59"
/>
<p>{{ secondsInput }}s</p>
</div>
<Button @click="startTimer">Proceed</Button>
</div>
<div v-if="finished && !paused" class="flex flex-col">
<h1>Timer finished!</h1>
<Button @click="resetTimer">Reset</Button>
</div>
<div v-if="!finished && paused" class="flex flex-col">
<h1>Paused</h1>
<Button @click="resetTimer">Reset</Button>
</div>
<div v-if="!finished && !paused" class="flex flex-col">
<p>
{{ minutes.toString().padStart(2, "0") }}:{{
seconds.toString().padStart(2, "0")
}}
</p>
<p>
{{ minutesInput.toString().padStart(2, "0") }}:{{
secondsInput.toString().padStart(2, "0")
}}
</p>
<Button @click="pauseTimer">Pause</Button>
</div>
</div>
</template>
<style scoped>
@media (max-width: 850px) {
.timer-root {
padding: 2px;
gap: 2px;
}
}
</style>

View File

@@ -0,0 +1,67 @@
<template>
<div
ref="container"
class="overflow-auto w-full h-full"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseLeave"
:style="{ cursor: isDragging ? 'grabbing' : 'grab' }"
>
<slot />
</div>
</template>
<script setup>
import { ref } from "vue";
const container = ref(null);
const isDragging = ref(false);
const startX = ref(0);
const startY = ref(0);
const scrollLeft = ref(0);
const scrollTop = ref(0);
const handleMouseDown = (e) => {
isDragging.value = true;
startX.value = e.pageX - container.value.offsetLeft;
startY.value = e.pageY - container.value.offsetTop;
scrollLeft.value = container.value.scrollLeft;
scrollTop.value = container.value.scrollTop;
// Prevent text selection while dragging
e.preventDefault();
};
const handleMouseMove = (e) => {
if (!isDragging.value) return;
e.preventDefault();
const x = e.pageX - container.value.offsetLeft;
const y = e.pageY - container.value.offsetTop;
const walkX = (x - startX.value) * 1; // Multiply by scroll speed factor
const walkY = (y - startY.value) * 1;
container.value.scrollLeft = scrollLeft.value - walkX;
container.value.scrollTop = scrollTop.value - walkY;
};
const handleMouseUp = () => {
isDragging.value = false;
};
const handleMouseLeave = () => {
isDragging.value = false;
};
</script>
<style scoped>
/* Prevent text selection while dragging */
div[style*="cursor: grabbing"] {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
</style>

View File

@@ -0,0 +1,30 @@
<script setup>
import { computed } from "vue";
const props = defineProps({
sourceArr: {
type: Array,
required: true,
},
});
function sourceType(link) {
return "video/" + link.split(".").pop();
}
const keys = ["name", "link"];
</script>
<template>
<video
v-for="(source, rowIndex) in sourceArr"
:key="rowIndex"
class="bdr-1"
width="300"
height="400"
controls
preload="none"
>
<source :src="source.link" :type="sourceType(source.link)" />
</video>
</template>

View File

@@ -0,0 +1,11 @@
<template>
<div class="a4page-portrait bdr-1 flex flex-col relative overflow-scroll">
<RouterLink to="/" class="bdr-2">
<img src="/img/memes/epic.jpeg" />
</RouterLink>
<h1>Click her, she will take you home</h1>
<span style="height: 100px"></span>
<h1>WIP</h1>
<h4>Sorry for taking you here</h4>
</div>
</template>

7
vue/src/graphql.js Normal file
View File

@@ -0,0 +1,7 @@
import axios from "axios";
export async function gql(query, variables = {}) {
const res = await axios.post("/api/graphql", { query, variables });
if (res.data.errors && !res.data.data) throw new Error(res.data.errors[0].message);
return res.data.data;
}

View File

@@ -0,0 +1,347 @@
function integerDigits(n, b = 10, length = null) {
// Get the list of digits in base b
const digits = [];
while (n > 0) {
digits.push(n % b);
n = Math.floor(n / b);
}
digits.reverse(); // Reverse the list to get digits in big endian order
// Pad with zeros if length is specified
if (length !== null) {
const padding = Array(Math.max(0, length - digits.length)).fill(0);
return padding.concat(digits);
}
return digits;
}
function* cartesianProduct(...arrays) {
// Generator for cartesian product
if (arrays.length === 0) {
yield [];
return;
}
const [first, ...rest] = arrays;
if (rest.length === 0) {
for (const item of first) {
yield [item];
}
} else {
for (const item of first) {
for (const combo of cartesianProduct(...rest)) {
yield [item, ...combo];
}
}
}
}
function tuplesFromList(lst, n) {
const arrays = Array(n).fill(lst);
return Array.from(cartesianProduct(...arrays));
}
function tuplesFromMultipleLists(...lists) {
return Array.from(cartesianProduct(...lists));
}
function flattenTuples(tuples) {
return tuples.flat();
}
function partition(lst, n) {
const result = [];
for (let i = 0; i < lst.length; i += n) {
result.push(lst.slice(i, i + n));
}
return result;
}
function pick(pickList, lst) {
const trues = [];
const falses = [];
for (let i = 0; i < pickList.length; i++) {
if (pickList[i]) {
trues.push(lst[i]);
} else {
falses.push(lst[i]);
}
}
return [trues, falses];
}
function factorial(n) {
if (n < 0) return NaN;
if (n === 0 || n === 1) return 1;
let result = 1;
for (let i = 2; i <= n; i++) {
result *= i;
}
return result;
}
function unrankPermutation(r, lst) {
const n = lst.length;
r -= 1; // Convert r to 0-indexed
const permutation = [];
const availableElements = [...lst];
for (let i = n; i > 0; i--) {
const fact = factorial(i - 1); // (n-1)!
const index = Math.floor(r / fact); // Find the index of the current element
permutation.push(availableElements.splice(index, 1)[0]); // Add the element and remove it from available
r %= fact; // Update r to find the next element
}
return permutation;
}
export function toMaRule(sn, dn, n, k) {
if (n < 1 || n % 2 === 0) {
throw new Error("n must be >= 1 and odd");
}
const inputs = tuplesFromList([...Array(k).keys()], n);
const directions = integerDigits(dn, 2, Math.pow(k, n)).map((x) =>
Math.pow(-1, x),
);
const snDigits = integerDigits(sn, k, n * Math.pow(k, n));
const outputs = partition(snDigits, n);
const rules = {};
for (let i = 0; i < inputs.length; i++) {
rules[JSON.stringify(inputs[i])] = [outputs[i], directions[i]];
}
return rules;
}
export function toReversibleMaRule(bn, pn, n, k) {
if (n < 1 || n % 2 === 0) {
throw new Error("n must be >= 1 and odd");
}
const inputs = tuplesFromList([...Array(k).keys()], n);
const blockers = tuplesFromList([...Array(k).keys()], n - 2);
const blockSelect = pick(integerDigits(bn, 2, Math.pow(k, n - 2)), blockers);
const rightBlockers = blockSelect[0];
const leftBlockers = blockSelect[1];
const twoFair = tuplesFromList([...Array(k).keys()], 2);
const leftOutputs = tuplesFromMultipleLists(leftBlockers, twoFair).map(
(x) => [flattenTuples(x), -1],
);
const rightOutputs = tuplesFromMultipleLists(twoFair, rightBlockers).map(
(x) => [flattenTuples(x), 1],
);
const outputs = [...leftOutputs, ...rightOutputs];
const rankedOutputs = unrankPermutation(pn, outputs);
const rules = {};
for (let i = 0; i < inputs.length; i++) {
rules[JSON.stringify(inputs[i])] = rankedOutputs[i];
}
return rules;
}
export function maStep(rules, state, r) {
/**
* Apply one step of the mobile automaton rules
*
* Args:
* rules (object): Dictionary of rules where key is input tuple and value is [output_tuple, direction]
* state (array): [list, head] where list is current state and head is current position
* r (number): Radius of the neighborhood (window size = 2r + 1)
*
* Returns:
* array: [new_list, new_head] or [[], -1] if out of bounds
*/
const [currentList, head] = state;
// Check bounds
if (head - r <= 0 || head + r >= currentList.length) {
return [[], -1];
}
// Get the window of elements centered at head
const window = currentList.slice(head - r, head + r + 1);
// Apply rule
const ruleKey = JSON.stringify(window);
const [newWindow, direction] = rules[ruleKey];
// Create new list with replaced elements
const newList = [...currentList];
for (let i = 0; i < newWindow.length; i++) {
newList[head - r + i] = newWindow[i];
}
return [newList, head + direction];
}
export function ma(rules, initialState, t) {
/**
* Perform t steps of the mobile automaton
*
* Args:
* rules (object): Dictionary of rules
* initialState (array): Initial [list, head] state
* t (number): Number of steps to perform
*
* Returns:
* array: List of states at each time step
*/
// Calculate radius from first rule key length
const firstKey = Object.keys(rules)[0];
const r = JSON.parse(firstKey).length / 2;
const states = [initialState];
let currentState = initialState;
for (let i = 0; i < t; i++) {
currentState = maStep(rules, currentState, r);
states.push(currentState);
// Stop if we hit an invalid state
if (currentState[0].length === 0) {
break;
}
}
return states;
}
export function cyclicMaStep(rules, state, r) {
/**
* Cyclic version: indexing wraps around the array.
*/
const [currentList, head] = state;
const n = currentList.length;
// --- Cyclic window extraction ---
const window = [];
for (let i = -r; i <= r; i++) {
window.push(currentList[(head + i + n) % n]);
}
// Apply rule
const ruleKey = JSON.stringify(window);
const [newWindow, direction] = rules[ruleKey];
// --- Cyclic writeback ---
const newList = [...currentList];
for (let offset = 0; offset < newWindow.length; offset++) {
newList[(head - r + offset + n) % n] = newWindow[offset];
}
// Move head cyclically
const newHead = (head + direction + n) % n;
return [newList, newHead];
}
export function cyclicMa(rules, initialState, t) {
/**
* Perform t steps of the mobile automaton
*
* Args:
* rules (object): Dictionary of rules
* initialState (array): Initial [list, head] state
* t (number): Number of steps to perform
*
* Returns:
* array: List of states at each time step
*/
// Calculate radius from first rule key length
const firstKey = Object.keys(rules)[0];
const r = JSON.parse(firstKey).length / 2;
const states = [initialState];
let currentState = initialState;
for (let i = 0; i < t; i++) {
currentState = cyclicMaStep(rules, currentState, r);
states.push(currentState);
// Stop if we hit an invalid state
if (currentState[0].length === 0) {
break;
}
}
return states;
}
// export function renderToCanvas(canvas, width, height) {
// let states = Array.from({ length: height }, () =>
// Array.from({ length: width }, () => Math.round(Math.random() + 0.4)),
// );
// }
export function renderMaToCanvas(canvas, width, height, sn = 0, dn = 0) {
if (sn == 0) {
const min = 1500000;
const max = 2000000;
sn = Math.floor(Math.random() * (max - min + 1)) + min;
}
if (dn == 0) {
const min = 100;
const max = 200;
dn = Math.floor(Math.random() * (max - min + 1)) + min;
}
const r = 1;
const n = 2 * r + 1;
const rules = toMaRule(sn, dn, n, 2);
let states = Array.from({ length: height }, () =>
Array.from({ length: width }, () => Math.round(Math.random() + 0.4)),
);
let head = Math.floor(width / 2) % width;
let row_num = 0;
const ctx = canvas.getContext("2d");
const img = ctx.createImageData(width, height);
const data = img.data;
const colorOn = [10, 60, 130]; // dark blue (active cell)
const colorOff = [10, 70, 110]; // darker blue (inactive cell)
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
const color = states[y][x] ? colorOn : colorOff;
data[idx] = color[0]; // R
data[idx + 1] = color[1]; // G
data[idx + 2] = color[2]; // B
data[idx + 3] = 255; // A
}
}
ctx.putImageData(img, 0, 0);
function step() {
// calculate new state
let [newState, newHead] = cyclicMaStep(rules, [states[row_num], head], r);
states[row_num] = newState;
// write changed cells to ImageData
for (let x = head - r; x <= head + r; x++) {
const idx = (row_num * width + x) * 4;
const val = newState[x] ? colorOn : colorOff;
data[idx] = val[0]; // R
data[idx + 1] = val[1]; // G
data[idx + 2] = val[2]; // B
data[idx + 3] = 255; // A
}
// update canvas (only this row)
ctx.putImageData(img, head - r, row_num, 0, 0, n, 1);
// advance row and head
row_num = (row_num + 1) % height;
head = newHead;
requestAnimationFrame(step);
}
requestAnimationFrame(step);
}

17
vue/src/js/utils.js Normal file
View File

@@ -0,0 +1,17 @@
export function shuffleArray(array) {
for (var i = array.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var temp = array[i];
array[i] = array[j];
array[j] = temp;
}
}
export function getRandomColor() {
var letters = "0123456789ABCDEF";
var color = "#";
for (var i = 0; i < 6; i++) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
}

12
vue/src/main.js Normal file
View File

@@ -0,0 +1,12 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import "./assets/styles.css";
const app = createApp(App);
app.use(router);
app.use(createPinia());
app.mount("#app");

70
vue/src/router/index.js Normal file
View File

@@ -0,0 +1,70 @@
import { createRouter, createWebHistory } from "vue-router";
import Landing from "@/views/Landing.vue";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
name: "landing",
component: Landing,
},
{
path: "/stp",
name: "home",
component: () => import("@/views/home/Home.vue"),
},
{
path: "/cv",
name: "cv",
component: () => import("../views/CV/CV.vue"),
},
{
path: "/admin",
name: "admin",
component: () => import("../views/admin/Admin.vue"),
},
{
path: "/bookmarks",
name: "bookmarks",
component: () => import("../views/Bookmarks.vue"),
},
{
path: "/notes/:path(.*)*",
name: "notes",
component: () => import("../views/Notes.vue"),
},
{
path: "/shrines",
name: "shrine links",
component: () => import("../views/Shrines.vue"),
},
{
path: "/shrines/gto",
name: "gto shrine",
component: () => import("../views/shrines/GTO.vue"),
},
{
path: "/shrines/skipskipbenben",
name: "skipskipbenben shrine",
component: () => import("../views/shrines/Skipskipbenben.vue"),
},
{
path: "/shrines/evangelion",
name: "evangelion shrine",
component: () => import("../views/shrines/Evangelion.vue"),
},
{
path: "/shrines/demoman",
name: "demoman shrine",
component: () => import("../views/shrines/Demoman.vue"),
},
{
path: "/:pathMatch(.*)*",
name: "404",
component: () => import("../views/404.vue"),
},
],
});
export default router;

View File

@@ -0,0 +1,36 @@
import { defineStore } from "pinia";
import { computed, ref, watch } from "vue";
import { useHomeDataStore } from "@/stores/homeData";
const activity_template = {
type: "activity",
name: "nameof",
createdAt: Date.now(),
};
export const useActivityStore = defineStore("activity", () => {
const activity = ref([activity_template]);
const activityCount = computed(() => activity.value.length);
const homeData = useHomeDataStore();
watch(
() => homeData.activities,
(newActivities) => {
if (newActivities.length > 0) {
activity.value = newActivities;
}
},
{ immediate: true },
);
async function fetchActivity() {
await homeData.fetchAll();
}
return {
activity,
activityCount,
fetchActivity,
};
});

90
vue/src/stores/auth.js Normal file
View File

@@ -0,0 +1,90 @@
import { defineStore } from "pinia";
import { computed, ref, watch } from "vue";
import { gql } from "@/graphql";
import { useHomeDataStore } from "@/stores/homeData";
export const useAuthStore = defineStore("auth", () => {
const user = ref({});
const loggedIn = computed(() => !!user.value.username);
const homeData = useHomeDataStore();
watch(
() => homeData.me,
(me) => {
if (me) {
user.value = me;
}
},
{ immediate: true },
);
async function logOut() {
try {
await gql(`mutation { logout }`);
} catch (err) {
console.error(err);
}
user.value = {};
}
async function logIn(username, password) {
try {
const data = await gql(
`mutation Login($input: LoginInput!) { login(input: $input) { user { id username admin } } }`,
{ input: { username, password } },
);
user.value = data.login.user;
} catch (err) {
console.error(err);
}
}
async function createUser(username, password) {
try {
const data = await gql(
`mutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { id username admin } }`,
{ input: { username, password } },
);
return data.createUser;
} catch (err) {
console.error(err);
throw err;
}
}
async function refreshToken() {
try {
const data = await gql(
`mutation { refreshToken { user { id username admin } } }`,
);
user.value = data.refreshToken.user;
} catch (err) {
console.log(err);
}
}
async function setUserAdmin(userId, admin) {
try {
const data = await gql(
`mutation SetUserAdmin($id: ID!, $admin: Boolean!) { setUserAdmin(id: $id, admin: $admin) { id username admin } }`,
{ id: userId, admin },
);
return data.setUserAdmin;
} catch (err) {
console.error(err);
throw err;
}
}
return {
user,
loggedIn,
logIn,
refreshToken,
logOut,
createUser,
setUserAdmin,
};
});

View File

@@ -0,0 +1,36 @@
import { defineStore } from "pinia";
import { computed, ref, watch } from "vue";
import { useHomeDataStore } from "@/stores/homeData";
const favorite_template = {
type: "favorite",
name: "nameof",
createdAt: Date.now(),
};
export const useFavoritesStore = defineStore("favorites", () => {
const favorites = ref([favorite_template]);
const favoritesCount = computed(() => favorites.value.length);
const homeData = useHomeDataStore();
watch(
() => homeData.favorites,
(newFavorites) => {
if (newFavorites.length > 0) {
favorites.value = newFavorites;
}
},
{ immediate: true },
);
async function fetchFavorites() {
await homeData.fetchAll();
}
return {
favorites,
favoritesCount,
fetchFavorites,
};
});

View File

@@ -0,0 +1,74 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { gql } from "@/graphql";
import axios from "axios";
export const useHomeDataStore = defineStore("homeData", () => {
const loaded = ref(false);
const error = ref(null);
const me = ref(null);
const posts = ref([]);
const favorites = ref([]);
const activities = ref([]);
const spotifyRecent = ref([]);
const rowingSessions = ref([]);
const gitFeed = ref(null);
const radioLive = ref(false);
async function fetchAll() {
try {
const [data] = await Promise.all([
gql(`
query HomeData {
posts { id title content createdAt updatedAt author { id username } }
favorites { id type name link createdAt }
activities { id type name link createdAt }
spotifyRecent { track { name album { name images { url } } artists { name } } playedAt }
rowingSessions { id date time distance timePer500m calories }
giteaFeed { avatarUrl repoUrl repoName opType commitMessage createdAt }
me { id username admin }
}
`),
fetchRadioStatus(),
]);
posts.value = data.posts;
favorites.value = data.favorites;
activities.value = data.activities;
spotifyRecent.value = data.spotifyRecent;
rowingSessions.value = data.rowingSessions;
gitFeed.value = data.giteaFeed || null;
me.value = data.me || null;
loaded.value = true;
} catch (err) {
console.error("HomeData fetch failed:", err);
error.value = err;
}
}
async function fetchRadioStatus() {
try {
await axios.head("/radio/stream");
radioLive.value = true;
} catch {
radioLive.value = false;
}
}
fetchAll();
return {
loaded,
error,
me,
posts,
favorites,
activities,
spotifyRecent,
rowingSessions,
gitFeed,
radioLive,
fetchAll,
fetchRadioStatus,
};
});

103
vue/src/stores/messages.js Normal file
View File

@@ -0,0 +1,103 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import axios from "axios";
function getWebSocketURL() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}/api/ws`;
}
export const useMessagesStore = defineStore("messages", () => {
const socket = ref(null);
const messages = ref([]);
const isConnected = ref(false);
const lastError = ref(null);
let intentionalClose = false;
let reconnectDelay = 1000;
let reconnectTimer = null;
const messagesCount = computed(() => messages.value.length);
function connect() {
if (socket.value && isConnected.value) return;
intentionalClose = false;
socket.value = new WebSocket(getWebSocketURL());
socket.value.onopen = () => {
isConnected.value = true;
lastError.value = null;
reconnectDelay = 1000;
messages.value = [];
};
socket.value.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
messages.value.push(data);
} catch {
messages.value.push({ text: event.data });
}
};
socket.value.onerror = (error) => {
lastError.value = error;
};
socket.value.onclose = () => {
isConnected.value = false;
socket.value = null;
if (!intentionalClose) {
reconnectTimer = setTimeout(() => {
connect();
}, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
}
};
}
function disconnect() {
intentionalClose = true;
clearTimeout(reconnectTimer);
if (!socket.value) return;
socket.value.close();
socket.value = null;
isConnected.value = false;
}
function sendMessage(text) {
if (!socket.value || !isConnected.value) return;
socket.value.send(JSON.stringify({ text }));
}
function clearMessages() {
messages.value = [];
}
async function uploadAndSendFile(file) {
try {
const formData = new FormData();
formData.append("file", file);
const res = await axios.post("/api/messages/upload", formData);
const { url } = res.data;
if (!socket.value || !isConnected.value) return;
socket.value.send(JSON.stringify({ text: "", fileUrl: url }));
} catch (err) {
lastError.value = err;
}
}
return {
messages,
isConnected,
lastError,
messagesCount,
connect,
disconnect,
sendMessage,
clearMessages,
uploadAndSendFile,
};
});

48
vue/src/stores/models.js Normal file
View File

@@ -0,0 +1,48 @@
export class User {
constructor({ id, createdAt, updatedAt, deletedAt, username, admin }) {
this.id = id;
this.createdAt = new Date(createdAt);
this.updatedAt = new Date(updatedAt);
this.deletedAt = deletedAt ? new Date(deletedAt) : null;
this.username = username;
this.admin = admin;
}
}
export class Message {
constructor({ id, text, author, createdAt, deletedAt }) {
this.id = id;
this.content = text;
this.author = author ? new User(author) : null;
this.createdAt = new Date(createdAt);
this.deletedAt = deletedAt ? new Date(deletedAt) : null;
}
}
export class Post {
constructor({
id,
title,
author,
authorID,
content,
createdAt,
updatedAt,
deletedAt,
}) {
this.id = id;
this.title = title;
this.authorID = authorID;
this.author = author ? new User(author) : null;
this.content = content;
this.createdAt = new Date(createdAt);
this.updatedAt = new Date(updatedAt);
this.deletedAt = deletedAt ? new Date(deletedAt) : null;
}
}
// Utility function to parse posts from API
export function parsePosts(postsArray) {
if (!Array.isArray(postsArray)) return [];
return postsArray.map((post) => new Post(post));
}

57
vue/src/stores/posts.js Normal file
View File

@@ -0,0 +1,57 @@
import { defineStore } from "pinia";
import { computed, ref, watch } from "vue";
import { gql } from "@/graphql";
import { useHomeDataStore } from "@/stores/homeData";
const post_template = {
title: "Can't fetch from the db yo",
content:
"This is meant to be pulling from a database, but for some reason that isn't working and this is filler text that should hopefully never see the light of day. If you are reading this, something has gone horribly, horribly wrong. Please start crying and prepare for the incoming wrath of hell. Furthermore, this is very, very long because I am trying to test the scroll feature so thank you ^_^.",
author: {
username: "stp",
},
createdAt: Date.now(),
};
export const usePostsStore = defineStore("posts", () => {
const posts = ref([post_template]);
const postsCount = computed(() => posts.value.length);
const homeData = useHomeDataStore();
watch(
() => homeData.posts,
(newPosts) => {
if (newPosts.length > 0) {
posts.value = newPosts;
}
},
{ immediate: true },
);
async function fetchPosts() {
await homeData.fetchAll();
}
async function deletePost(post) {
try {
await gql(
`mutation DeletePost($id: ID!) { deletePost(id: $id) { id } }`,
{ id: post.id },
);
console.log("Deleted:", post.id);
await homeData.fetchAll();
} catch (err) {
console.error("Delete failed:", err);
}
}
return {
posts,
postsCount,
fetchPosts,
deletePost,
};
});

59
vue/src/stores/songs.js Normal file
View File

@@ -0,0 +1,59 @@
import { defineStore } from "pinia";
import { ref, computed, watch } from "vue";
import { gql } from "@/graphql";
import { useHomeDataStore } from "@/stores/homeData";
const song_template = {
track: {
name: "^_^",
album: { name: "", images: [{ url: "/img/Untitled.png" }] },
artists: [{ name: ">_<" }],
},
};
export const useSongsStore = defineStore("songs", () => {
const songs = ref([song_template]);
const songsCount = computed(() => songs.value.length);
const homeData = useHomeDataStore();
watch(
() => homeData.spotifyRecent,
(newSongs) => {
if (newSongs.length > 0) {
songs.value = newSongs;
}
},
{ immediate: true },
);
async function fetchSongs() {
try {
const data = await gql(`
query {
spotifyRecent {
track {
name
album { name images { url } }
artists { name }
}
playedAt
}
}
`);
if (Array.isArray(data.spotifyRecent) && data.spotifyRecent.length > 0) {
songs.value = data.spotifyRecent;
}
} catch (err) {
console.error("Cannot connect to Spotify API", err);
}
}
return {
songs,
songsCount,
fetchSongs,
};
});

13
vue/src/views/404.vue Normal file
View File

@@ -0,0 +1,13 @@
<template>
<main class="flex flex-col items-center">
<div
class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll"
>
<h1>404</h1>
<RouterLink to="/" class="bdr-2">
<img src="/img/memes/epic.jpeg" />
</RouterLink>
<h1>Click her, she will take you home</h1>
</div>
</main>
</template>

255
vue/src/views/Bookmarks.vue Normal file
View File

@@ -0,0 +1,255 @@
<script setup>
import LinkTable from "@/components/util/LinkTable.vue";
const links = [
[
"Reading Links",
[
{
name: "Substack",
link: "https://substack.com/",
},
{
name: "Medium",
link: "https://medium.com/",
},
{
name: "4Chan",
link: "https://www.4chan.org/",
},
],
],
[
"Job Links",
[
{
name: "LinkedIn",
link: "https://www.linkedin.com/",
},
{
name: "Jack and Jill",
link: "https://app.jackandjill.ai",
},
{
name: "LinkedIn",
link: "https://www.linkedin.com/",
},
{
name: "Prospects",
link: "https://www.prospects.ac.uk/",
},
{
name: "GOV",
link: "https://findajob.dwp.gov.uk",
},
{
name: "Glassdoor",
link: "https://www.glassdoor.co.uk/",
},
{
name: "Indeed",
link: "https://www.indeed.co.uk/",
},
],
],
[
"Learning Links",
[
{
name: "Leetcode",
link: "https://leetcode.com/",
},
{
name: "ISLP",
link: "https://hastie.su.domains/ISLP/ISLP_website.pdf.download.html",
},
],
],
[
"Social Links",
[
{
name: "Outlook",
link: "https://outlook.live.com/",
},
{
name: "Gmail",
link: "https://mail.google.com/",
},
{
name: "Whatsapp",
link: "https://web.whatsapp.com/",
},
],
],
[
"Radio links",
[
{
name: "Radio Helsinki",
link: "https://www.radiohelsinki.fi/",
},
{
name: "Palanga Street Radio",
link: "https://palanga.live/",
},
{
name: "IDA Radio",
link: "https://idaidaida.net/",
},
{
name: "Tīrkultūra",
link: "https://www.tirkultura.lv/",
},
],
],
[
"Hacking Links",
[
{
name: "pwn.college",
link: "https://pwn.college/",
},
{
name: "OSINT Framework",
link: "https://osintframework.com/",
},
{
name: "OverTheWire",
link: "https://overthewire.org/",
},
{
name: "TryHackMe",
link: "https://tryhackme.com/",
},
],
],
[
"Chinese Links",
[
{
name: "MDBG Chinese Dictionary",
link: "https://www.mdbg.net/chinese/dictionary",
},
{
name: "Stroke Order",
link: "https://www.strokeorder.com/",
},
{
name: "HSK 1 Peking University",
link: "https://youtube.com/playlist?list=PLVWfp7qXLmKVfSUkucXErLncKn-JqgBbK&si=2ytO3inS8-iOAOx2",
},
{
name: "Stroke Order",
link: "https://www.strokeorder.com/",
},
{
name: "Offbeat Mandarin",
link: "https://www.youtube.com/@OffbeatMandarin",
},
],
],
[
"Art links",
[
{
name: "Frida Kahlo",
link: "https://www.fridakahlo.org/",
},
{
name: "Cameron's World",
link: "https://www.cameronsworld.net/",
},
{
name: "Neocities",
link: "https://neocities.org/",
},
],
],
[
"Vue links",
[
{
name: "Vue",
link: "https://vuejs.org/guide/introduction.html",
},
{
name: "Vue Router",
link: "https://router.vuejs.org/introduction.html",
},
{
name: "Pinia",
link: "https://pinia.vuejs.org/introduction.html",
},
],
],
[
"Go links",
[
{
name: "Golang",
link: "https://golang.org/doc/",
},
{
name: "Gin Gonic",
link: "https://gin-gonic.com/en/docs/introduction/",
},
{
name: "GORM",
link: "https://gorm.io/gen/index.html",
},
],
],
[
"Doc links",
[
{
name: "Rust",
link: "https://doc.rust-lang.org/stable/book/index.html",
},
{
name: "Javascript",
link: "https://developer.mozilla.org/en-US/docs/Web/JavaScript",
},
{
name: "Python",
link: "https://docs.python.org/3/",
},
],
],
[
"Article links",
[
{
name: "Go and GORM",
link: "https://medium.com/@chaewonkong/learn-go-understanding-and-implementing-foreign-keys-with-gorm-6d7608e1dbf6",
},
{
name: "JWT Auth in GO",
link: "https://medium.com/monstar-lab-bangladesh-engineering/jwt-auth-in-go-dde432440924",
},
{
name: "Websockets in GO",
link: "https://medium.com/@tanngontn/golang-gin-framework-with-normal-websocket-and-websocket-with-producer-is-rabbitmq-guide-93cad7d290f7",
},
],
],
];
</script>
<template>
<main class="items-center flex flex-col halftone">
<div
class="a4page-portrait bdr-1 flex flex-row flex-wrap overflow-x-auto gap-1"
>
<div class="w-full h-fit">
<LinkTable
class="flex flex-col flex-wrap"
v-for="link in links"
:title="link[0]"
:items="link[1]"
/>
</div>
</div>
</main>
</template>

359
vue/src/views/CV/CV.vue Normal file
View File

@@ -0,0 +1,359 @@
<script setup>
import Project from "./Project.vue";
</script>
<template>
<main>
<div class="no-print w-full h-20">
</div>
<div class="a4page">
<div class="flex flex-row justify-between">
<h1 class="name">Adam French</h1>
<div class="contact-details text-right">
<p>+447563266931</p>
<p>adam.a.french@outlook.com</p>
<h4>
<a href="https://www.adam-french.co.uk">
www.adam-french.co.uk
</a>
</h4>
</div>
</div>
<h2>Profile</h2>
<p>
First Class Honours graduate in Computer Science with Mathematics
from the University of Leeds (81.1%), with a year abroad at the
University of Waterloo. Proficient in full-stack development,
systems programming, and CI/CD automation. Eager to contribute to
a collaborative engineering team, apply strong academic
foundations to real-world problems, and grow through hands-on
experience.
</p>
<h2>Skills</h2>
<div class="skills-grid">
<div><strong>Languages</strong><br /><small>Go, Rust, Python, JavaScript / TypeScript, SQL</small></div>
<div><strong>Frontend</strong><br /><small>Vue, React / Redux, Svelte, Tailwind CSS, WebAssembly</small></div>
<div><strong>Backend / Infra</strong><br /><small>Nginx, Docker, PostgreSQL, SQLite, JWT Auth, Git Actions</small></div>
</div>
<h2>Projects</h2>
<Project class="border-b border-dotted">
<template v-slot:left>
<h4>
<a
href="https://www.adam-french.co.uk/gitea/adamf/web_server.git"
>
web_server.git
</a>
</h4>
</template>
<template v-slot:top>
<small>
Nginx, Vue, Postgres, Docker, Go, Python, Rust Wasm,
Git Actions, JWT Auth
</small>
<small>2025</small>
</template>
<p>
Self-hosted personal website with a fully automated CI/CD
pipeline. Iterated across diverse tech stacks including
Svelte, React/Redux, SQLite, Rust Actix, and Deno.
</p>
</Project>
<Project class="border-b border-dotted">
<template v-slot:left>
<h4>
<a
href="https://www.adam-french.co.uk/gitea/adamf/tour.git"
>
tour.git
</a>
</h4>
</template>
<template v-slot:top>
<small>Rust</small>
<small>2026</small>
</template>
<p>
CLI tool for building and navigating interactive code
tutorials, with version-traversal semantics inspired by Git.
</p>
</Project>
<Project class="border-b border-dotted">
<template v-slot:left>
<h4>
<a
href="https://www.adam-french.co.uk/gitea/adamf/rust-raytracer.git"
>
rust-raytracer.git
</a>
</h4>
</template>
<template v-slot:top>
<small>Rust, Linear Algebra, Multithreading</small>
<small>2023</small>
</template>
<p>
Parallelised recursive ray tracer for realistic 3D rendering.
Emphasised algorithmic efficiency and low-level memory
management in Rust.
</p>
</Project>
<Project>
<template #left>
<h4>
<a
class="text-center w-full"
href="https://community.wolfram.com/groups/-/m/t/3210947"
>
Wolfram Summer School
</a>
</h4>
</template>
<template #top>
<small>Wolfram Mathematica</small>
<small>2024</small>
</template>
<p>
Research project on Mobile Automata with data visualisation
and academic presentation. Delivered within a tight deadline
in collaboration with academic mentors.
</p>
</Project>
<h2>Education</h2>
<div class="w-full h-fit flex-row flex gap-5">
<div class="flex-1 border-r border-dotted pr-3">
<h3>
<a href="https://www.adam-french.co.uk/pdf/transcript.pdf">
University of Leeds
</a>
</h3>
<div
class="flex-row flex place-content-between m-auto place-items-center"
>
<small>81.1% First Class Honours</small>
<small>20212025</small>
</div>
<small>BSc Computer Science with Mathematics (International)</small>
<ul>
<li>Algorithms & Data Structures I & II</li>
<li>Compiler Design and Construction</li>
<li>Formal Languages & Finite Automata</li>
<li>Graph Algorithms & Complexity Theory</li>
<li>Machine Learning · Databases · Computer Processors</li>
<li>Probability and Statistics I</li>
</ul>
</div>
<div class="flex-1 pl-3">
<h3>University of Waterloo</h3>
<div
class="flex-row flex place-content-between m-auto place-items-center"
>
<small>Year abroad</small>
<small>20232024</small>
</div>
<ul>
<li>Applied Cryptography</li>
<li>Introduction to Computer Graphics</li>
<li>Introduction to Rings and Fields with Applications</li>
</ul>
</div>
</div>
</div>
<div class="no-print w-full h-20">
</div>
<div class="a4page">
<div class="flex-1 pl-3">
<h2>Experience</h2>
<Project>
<template #left>
<p>Hospitality</p>
</template>
<template #top>
<small>Cashier, Bartender, Waiter</small>
<small>20182023</small>
</template>
<p>
Worked at <em>Belgrave Music Hall</em>,
<em>The Crown and Anchor</em>, and
<em>BFI Riverfront Kitchen</em>. Developed
communication, composure under pressure, and
reliability in customer-facing roles.
</p>
</Project>
<h2>Interests</h2>
<ul>
<li>Leetcode daily competitive problem solving</li>
<li>Learning Mandarin</li>
<li>Rhythm Games</li>
<li>Climbing · Gym</li>
<li>Board games · Meetup.com</li>
</ul>
</div>
</div>
<div class="no-print w-full h-20">
</div>
</main>
</template>
<style scoped>
/* Fonts */
@font-face {
font-family: "big_noodle_titling";
src: url("/fonts/big_noodle_titling.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "CreatoDisplay";
src: url("/fonts/CreatoDisplay-Bold.otf") format("opentype");
font-weight: normal;
font-style: normal;
}
/* Variables */
* {
--primary: black;
--secondary: #0000ff;
--tertiary: #ff0000;
--quaternary: #cccccc;
--background: white;
--font-heading: big_noodle_titling;
--font-text: CreatoDisplay;
--font-size-name: 2.5em;
--font-size-text: 100%;
--font-size-small: 0.9em;
--font-size-heading: 2.1em;
--font-size-subheading: 1.7em;
--font-size-subsubheading: 1.4em;
}
/* A4 Page */
.a4page {
line-height: 1.6;
font-family: var(--font-text);
width: 210mm;
height: 297mm;
padding: 5mm;
box-sizing: border-box;
background-color: var(--background);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
border: 1px solid var(--primary);
overflow: hidden;
margin: auto auto;
}
/* Component Styling */
main {
padding: 0px;
display: flex;
flex-direction: column;
height: fit-content;
}
span {
height: 2em;
}
h1,
h2,
h3,
h4 {
border: none;
color: var(--primary);
font-family: var(--font-heading);
text-transform: capitalize;
}
h1 {
font-size: var(--font-size-heading);
}
h2 {
border-bottom: 1px solid var(--primary);
font-size: var(--font-size-subheading);
}
h3 {
font-size: var(--font-size-subsubheading);
}
a:hover {
color: var(--tertiary);
}
a {
background-color: transparent;
color: var(--secondary);
}
p {
margin-bottom: 0.2em;
color: var(--primary);
font-size: var(--font-size-text);
}
table {
color: var(--secondary);
border-collapse: collapse;
border: 1px solid black;
}
td {
color: var(--secondary);
border-top: 1px solid var(--tertiary);
padding: 1px 10px 1px 10px;
font-size: var(--font-size-text);
text-align: left;
}
th {
color: var(--secondary);
border: 2px solid var(--tertiary);
padding: 1px 0px 1px 7px;
font-family: var(--font-heading);
font-size: var(--font-size-subsubheading);
background-color: var(--quaternary);
text-align: left;
}
@media print {
.no-print {
display: none !important;
}
}
small {
font-size: var(--font-size-small);
color: var(--primary);
}
ul {
font-size: var(--font-size-small);
margin: 0;
padding-left: 1.2em;
}
li {
font-size: var(--font-size-small);
color: var(--primary);
}
.skills-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.3em 1em;
margin-bottom: 0.2em;
}
</style>

View File

@@ -0,0 +1,17 @@
<script setup></script>
<template>
<div class="flex-row flex">
<div class="w-2/7 p-5 m-auto">
<slot name="left" />
</div>
<div class="w-full p-2">
<div
class="flex-row flex place-content-between m-auto place-items-center"
>
<slot name="top" />
</div>
<slot />
</div>
</div>
</template>

56
vue/src/views/Landing.vue Normal file
View File

@@ -0,0 +1,56 @@
<script setup>
import Link from "@/components/text/Link.vue";
import InlineLink from "@/components/text/InlineLink.vue";
import Header from "@/components/text/Header.vue";
import Paragraph from "@/components/text/Paragraph.vue";
const links = [
{ name: "GitHub", href: "https://github.com/SteveThePug" },
{ name: "Gitea", href: "/gitea/explore/repos" },
{ name: "Spotify", href: "https://open.spotify.com/user/stevethepug" },
];
</script>
<template>
<main class="halftone flex justify-center px-4 py-16">
<div class="max-w-xl w-full flex flex-col gap-12">
<section>
<Header>Adam French</Header>
<Paragraph>
Junior software engineer focused on full-stack development,
systems programming, and infrastructure. First Class Honours
in Computer Science with Mathematics from Leeds and
Waterloo.
</Paragraph>
</section>
<section>
<Header>About</Header>
<Paragraph>
This website is self-hosted and has a lot more on it than it
needs to. Please have a look at my
<InlineLink to="/cv">CV</InlineLink> for a full breakdown of
my experience, projects, and skills. Please visit
<InlineLink to="/stp">STP</InlineLink> for the prefered but
less professional experience.
</Paragraph>
</section>
<nav class="navRow flex flex-row flex-wrap gap-4 justify-around">
<Link to="/cv"> CV </Link>
<Link to="/stp"> STP </Link>
<Link href="mailto:adam.a.french@outlook.com"> Email </Link>
<Link v-for="link in links" :key="link.name" :href="link.href">
{{ link.name }}
</Link>
</nav>
</div>
</main>
</template>
<style scoped>
.navRow > a {
padding: 0.5rem 1rem;
border: 1px solid currentColor;
}
</style>

75
vue/src/views/Notes.vue Normal file
View File

@@ -0,0 +1,75 @@
<script setup>
import Markdown from "@/components/util/Markdown.vue";
import { ref, onMounted } from "vue";
import axios from "axios";
import { useRoute } from "vue-router";
const file = ref(null);
const filename = ref("");
const last_edited = ref(null);
// if the address is https://www.adam-french.co.uk/notes/PATH
// request from https://www.adam-french.co.uk/api/notes/PATH
const route = useRoute();
const pathArray = route.params.path;
const path = Array.isArray(pathArray) ? pathArray.join("/") : pathArray;
const url = `/api/notes/${path}`;
function getFilename(headers) {
const disposition = headers["content-disposition"];
if (!disposition) return null;
const match = disposition.match(/filename="?([^"]+)"?/);
return match ? match[1] : null;
}
async function fetchFile() {
const response = await axios.get(url, { responseType: "blob" });
filename.value = getFilename(response.headers);
const lastModified = response.headers["last-modified"];
last_edited.value = lastModified ? new Date(lastModified) : null;
if (filename.value.toLowerCase().endsWith(".md")) {
const text = await response.data.text();
file.value = fixLinks(text);
} else {
file.value = response.data;
}
}
function fixLinks(filedata) {
return filedata.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
if (
url.startsWith("http://") ||
url.startsWith("https://") ||
url.startsWith("#") ||
url.startsWith("./") ||
url.startsWith("../") ||
url.startsWith("//")
) {
return match;
}
return `[${text}](/notes/${url})`;
});
}
onMounted(fetchFile);
</script>
<template>
<main class="items-center flex flex-col">
<div class="background halftone" />
<div
v-if="file"
class="a4page-portrait border-primary-1 flex flex-col relative overflow-scroll gap-1 bg-bg_primary"
>
<h1>{{ filename }}</h1>
<small>{{ last_edited }}</small>
<Markdown class="flex-1 border-box text-wrap" :source="file" />
</div>
<div v-else>Loading</div>
</main>
</template>

20
vue/src/views/Shrines.vue Normal file
View File

@@ -0,0 +1,20 @@
<script setup>
import RouterTable from "@/components/util/RouterTable.vue";
const shrine_links = [
{ name: "Demoman", link: "/shrines/demoman" },
{ name: "Evangelion", link: "/shrines/evangelion" },
{ name: "GTO", link: "/shrines/gto" },
{ name: "Skipskipbenben", link: "/shrines/skipskipbenben" },
];
</script>
<template>
<main class="items-center flex flex-col">
<div class="background halftone" />
<div
class="a4page-portrait bdr-1 flex flex-col relative overflow-scroll gap-1"
>
<RouterTable :linkArr="shrine_links" />
</div>
</main>
</template>

View File

@@ -0,0 +1,28 @@
<script setup>
import { ref } from "vue";
import { useAuthStore } from "@/stores/auth";
import Login from "./Login.vue";
import CreateUser from "./CreateUser.vue";
import CreatePost from "./CreatePost.vue";
import CreateFavorite from "./CreateFavorite.vue";
import CreateActivity from "./CreateActivity.vue";
import CreateRowing from "./CreateRowing.vue";
import ManageUsers from "./ManageUsers.vue";
const auth = useAuthStore();
</script>
<template>
<main class="halftone justify-center flex flex-row w-full h-full">
<div class="a5page-portrait bdr-1 flex flex-col">
<Login class="bdr-2 bg-bg_primary" />
<CreateUser class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
<CreatePost class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
<CreateFavorite class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
<CreateActivity class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
<CreateRowing class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
<ManageUsers class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
</div>
</main>
</template>

View File

@@ -0,0 +1,35 @@
<script setup>
import Button from "@/components/input/Button.vue";
import { ref } from "vue";
import { gql } from "@/graphql";
const type = ref("");
const name = ref("");
const link = ref("");
async function post() {
try {
const data = await gql(
`mutation CreateActivity($input: CreateActivityInput!) { createActivity(input: $input) { id } }`,
{ input: { type: type.value, name: name.value, link: link.value || undefined } },
);
type.value = "";
name.value = "";
link.value = "";
console.log(data.createActivity);
} catch (err) {
console.error(err);
}
}
</script>
<template>
<div class="flex flex-col">
<h1>Create Activity</h1>
<input type="text" v-model="type" placeholder="Type" @keyup.enter="post" />
<input type="text" v-model="name" placeholder="Name" @keyup.enter="post" />
<input type="text" v-model="link" placeholder="Link" @keyup.enter="post" />
<Button @click="post">Upload</Button>
</div>
</template>

View File

@@ -0,0 +1,35 @@
<script setup>
import Button from "@/components/input/Button.vue";
import { ref } from "vue";
import { gql } from "@/graphql";
const type = ref("");
const name = ref("");
const link = ref("");
async function post() {
try {
const data = await gql(
`mutation CreateFavorite($input: CreateFavoriteInput!) { createFavorite(input: $input) { id } }`,
{ input: { type: type.value, name: name.value, link: link.value || undefined } },
);
type.value = "";
name.value = "";
link.value = "";
console.log(data.createFavorite);
} catch (err) {
console.error(err);
}
}
</script>
<template>
<div class="flex flex-col">
<h1>Create Favorite</h1>
<input type="text" v-model="type" placeholder="Type" @keyup.enter="post" />
<input type="text" v-model="name" placeholder="Name" @keyup.enter="post" />
<input type="text" v-model="link" placeholder="Link" @keyup.enter="post" />
<Button @click="post">Upload</Button>
</div>
</template>

View File

@@ -0,0 +1,36 @@
<script setup>
import Button from "@/components/input/Button.vue";
import { ref } from "vue";
import { gql } from "@/graphql";
const title = ref("");
const content = ref("");
async function post() {
try {
const data = await gql(
`mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { id } }`,
{ input: { title: title.value, content: content.value } },
);
title.value = "";
content.value = "";
console.log(data.createPost);
} catch (err) {
console.error(err);
}
}
</script>
<template>
<div class="flex flex-col">
<h1>Create Post</h1>
<input type="text" v-model="title" placeholder="Title" @keyup.enter="post" />
<textarea
class="h-50"
v-model="content"
placeholder="Content"
></textarea>
<Button @click="post">Upload</Button>
<!-- make textarea take up most the space -->
</div>
</template>

View File

@@ -0,0 +1,51 @@
<script setup>
import Button from "@/components/input/Button.vue";
import { ref } from "vue";
import axios from "axios";
const images = ref([]);
const results = ref([]);
function onFileChange(e) {
images.value = Array.from(e.target.files);
results.value = [];
}
async function submit() {
if (!images.value.length) return;
results.value = images.value.map((f) => ({ name: f.name, status: "Uploading..." }));
await Promise.all(
images.value.map(async (file, i) => {
const formData = new FormData();
formData.append("image", file);
try {
const res = await axios.post("/api/rowing", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
const mins = Math.floor(res.data.Time / 1e9 / 60);
const secs = String(Math.floor((res.data.Time / 1e9) % 60)).padStart(2, "0");
results.value[i].status = `${res.data.Distance}m in ${mins}:${secs}`;
results.value[i].ok = true;
} catch (err) {
results.value[i].status = err.response?.data?.error || "Upload failed";
results.value[i].ok = false;
}
})
);
images.value = [];
}
</script>
<template>
<div class="flex flex-col gap-2">
<h1>Create Rowing</h1>
<input type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple @change="onFileChange" />
<Button @click="submit">Upload</Button>
<div v-for="r in results" :key="r.name">
<span class="text-primary">{{ r.name }}: </span>
<span :class="r.ok ? 'text-secondary' : 'text-red-500'">{{ r.status }}</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,45 @@
<script setup>
import Button from "@/components/input/Button.vue";
import { ref } from "vue";
import { useAuthStore } from "@/stores/auth";
import { gql } from "@/graphql";
const auth = useAuthStore();
const username = ref("");
const password = ref("");
const message = ref("");
const error = ref("");
async function handleCreate() {
message.value = "";
error.value = "";
try {
const data = await gql(
`mutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { id username } }`,
{ input: { username: username.value, password: password.value } },
);
message.value = `User "${data.createUser.username}" created successfully.`;
username.value = "";
password.value = "";
} catch (err) {
error.value = err.message || "Failed to create user.";
}
}
</script>
<template>
<div v-if="auth.loggedIn && auth.user.admin" class="flex flex-col">
<h1>Create User</h1>
<p v-if="message" class="text-green-500">{{ message }}</p>
<p v-if="error" class="text-red-500">{{ error }}</p>
<input type="text" v-model="username" placeholder="Username" @keyup.enter="handleCreate" />
<input type="password" v-model="password" placeholder="Password" @keyup.enter="handleCreate" />
<Button @click="handleCreate">Create Account</Button>
</div>
<div v-else-if="auth.loggedIn" class="flex flex-col">
<p>You do not have permission to create users.</p>
</div>
<div v-else class="flex flex-col">
<p>You must be logged in as an admin to create users.</p>
</div>
</template>

View File

@@ -0,0 +1,34 @@
<script setup>
import { ref, onMounted, computed } from "vue";
import { useAuthStore } from "@/stores/auth";
import Button from "@/components/input/Button.vue";
const auth = useAuthStore();
const username = ref("");
const password = ref("");
function handleLogin() {
auth.logIn(username.value, password.value);
}
function handleLogout() {
auth.logOut();
}
</script>
<template>
<div v-if="auth.loggedIn" class="flex flex-col">
<h1>Logged in</h1>
<p>{{ auth.user.id }}</p>
<p>{{ auth.user.username }}</p>
<p>{{ auth.user.admin }}</p>
<Button @click="handleLogout">Log Out</Button>
</div>
<div v-else class="flex flex-col">
<h1>Login</h1>
<input type="text" v-model="username" placeholder="Username" @keyup.enter="handleLogin" />
<input type="password" v-model="password" placeholder="Password" @keyup.enter="handleLogin" />
<Button @click="handleLogin">Log In</Button>
</div>
</template>

View File

@@ -0,0 +1,45 @@
<script setup>
import Button from "@/components/input/Button.vue";
import { ref, onMounted } from "vue";
import { useAuthStore } from "@/stores/auth";
import { gql } from "@/graphql";
const auth = useAuthStore();
const users = ref([]);
async function fetchUsers() {
try {
const data = await gql(`query { users { id username admin } }`);
users.value = data.users;
} catch (err) {
console.error(err);
}
}
async function toggleAdmin(user) {
try {
const data = await auth.setUserAdmin(user.id, !user.admin);
user.admin = data.admin;
} catch (err) {
console.error(err);
}
}
onMounted(fetchUsers);
</script>
<template>
<div class="flex flex-col">
<h1>Manage Users</h1>
<div v-for="user in users" :key="user.id" class="flex flex-row items-center gap-2">
<span>{{ user.username }}</span>
<span v-if="user.admin">(admin)</span>
<Button
v-if="user.id !== auth.user.id"
@click="toggleAdmin(user)"
>
{{ user.admin ? "Demote" : "Promote" }}
</Button>
</div>
</div>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { useTemplateRef, ref, onMounted, onUnmounted } from 'vue';
const display = useTemplateRef('display')
const displayText = ref("");
const charHeight: number = 14;
const charWidth: number = charHeight * 0.6;
let n: number;
let m: number;
function setup() {
display.value.style.fontSize = `${charHeight}px`;
display.value.style.lineHeight = `${charHeight}px`;
fillDisplay()
}
function fillDisplay() {
// M rows N columns
m = Math.floor(display.value.offsetHeight / charHeight);
n = Math.floor(display.value.offsetWidth / charWidth);
const row = ' '.repeat(n);
displayText.value = (row + '\n').repeat(m - 1) + row
}
function close() {
displayText.value = ""
}
onMounted(() => {
setup()
})
onUnmounted(() => {
close()
})
</script>
<template>
<pre class="overflow-scroll w-full h-full bg-black text-white m-0 p-0" id="container" ref="display">{{ displayText
}}</pre>
</template>

View File

@@ -0,0 +1,15 @@
<script setup>
import Slideshow from "@/components/util/Slideshow.vue";
const images = [
{ url: "/img/memes/pidgeon.gif", comment: "鸟" },
// { url: "/img/memes/no_slip.png" },
// { url: "/img/memes/epic.jpeg" },
// { url: "/img/bedroom/img2.png", comment: "办公桌" },
// { url: "/img/bedroom/img1.png", comment: "床" },
];
</script>
<template>
<Slideshow :images="images" />
</template>

View File

@@ -0,0 +1,18 @@
<script setup>
import AutoScroll from "@/components/util/AutoScroll.vue";
import LinkTable from "@/components/util/LinkTable.vue";
import Header from "@/components/text/Header.vue";
import { useActivityStore } from "@/stores/activity";
const activityStore = useActivityStore();
</script>
<template>
<div class="flex flex-col items-center">
<Header>Consumption</Header>
<AutoScroll class="flex-1 w-full">
<LinkTable variant="table" class="w-full" :items="activityStore.activity" />
</AutoScroll>
</div>
</template>

View File

@@ -0,0 +1,22 @@
<script setup>
import Header from "@/components/text/Header.vue";
import LinkTable from "@/components/util/LinkTable.vue";
import AutoScroll from "@/components/util/AutoScroll.vue";
import { useFavoritesStore } from "@/stores/favorites";
const favoritesStore = useFavoritesStore();
</script>
<template>
<div class="flex flex-col items-center">
<Header>favs</Header>
<AutoScroll class="w-full flex-1">
<LinkTable
variant="table"
class="w-full"
:items="favoritesStore.favorites"
/>
</AutoScroll>
</div>
</template>

View File

@@ -0,0 +1,82 @@
<script setup>
import Button from "@/components/input/Button.vue";
import Markdown from "@/components/util/Markdown.vue";
import Header from "@/components/text/Header.vue";
import { ref, computed, onBeforeMount } from "vue";
import { useAuthStore } from "@/stores/auth";
import { usePostsStore } from "@/stores/posts";
const authStore = useAuthStore();
const postsStore = usePostsStore();
const idx = ref(0);
const leftCap = computed(() => idx.value === 0);
const rightCap = computed(() => idx.value === postsStore.postsCount - 1);
const post = computed(() => postsStore.posts[idx.value]);
const userOwnsPost = computed(
() => post.value.author.username == authStore.user.username,
);
function nextPost() {
if (idx.value < postsStore.postsCount - 1) {
idx.value++;
}
}
function prevPost() {
if (idx.value > 0) {
idx.value--;
}
}
function deletePost() {
postsStore.deletePost(post.value);
}
</script>
<template>
<div class="flex flex-col flex-1 min-h-0">
<Header>{{ post.title }}</Header>
<div
class="flex flex-col flex-1 min-h-0 p-1 overflow-auto text-left items-start justify-start"
>
<small
>Created at:
{{ new Date(post.createdAt).toLocaleString() }}</small
>
<small>By: {{ post.author.username }}</small>
<Markdown
class="flex-1 border-box text-wrap"
:source="post.content"
/>
<div class="flex flex-row w-full">
<Button
class="flex-1 border-box"
v-if="!leftCap"
@click="prevPost"
>
Prev
</Button>
<Button
class="flex-1 border-box"
v-if="!rightCap"
@click="nextPost"
>
Next
</Button>
</div>
<Button class="w-full" v-if="userOwnsPost" @click="deletePost"
>Delete</Button
>
</div>
</div>
</template>
<style scoped>
img {
width: 100%;
}
</style>

View File

@@ -0,0 +1,26 @@
<script setup>
import Header from "@/components/text/Header.vue";
import LinkTable from "@/components/util/LinkTable.vue";
const gym = [
{ name: "Row", type: "30 min" },
{ name: "Run", type: "5k" },
{ name: "Pushup & Squat", type: "50" },
];
</script>
<template>
<div class="flex flex-col place-content-between items-center">
<Header>Gym</Header>
<p>I'm not a gym geek</p>
<p>4/7 days I do:</p>
<div class="overflow-scroll w-full border-box">
<LinkTable variant="table" class="w-full" :items="gym" />
</div>
</div>
</template>
<style scoped>
img {
width: 100%;
}
</style>

318
vue/src/views/home/Gym2.vue Normal file
View File

@@ -0,0 +1,318 @@
<script setup>
import { ref, computed } from "vue";
import Header from "@/components/text/Header.vue";
import { useHomeDataStore } from "@/stores/homeData";
import { storeToRefs } from "pinia";
const store = useHomeDataStore();
const { loaded, error, rowingSessions } = storeToRefs(store);
const rows = computed(() => rowingSessions.value.slice().reverse());
const loading = computed(() => !loaded.value);
const metric = ref("distance");
const hovered = ref(null);
const METRICS = [
{ key: "distance", label: "Distance (m)", color: "#55ffbb" },
{ key: "timePer500m", label: "Pace /500m", color: "#ff579a" },
{ key: "calories", label: "Calories", color: "#62ff57" },
];
const activeMetric = computed(() =>
METRICS.find((m) => m.key === metric.value),
);
// SVG layout constants
const W = 290;
const H = 120;
const PL = 46; // padding left
const PT = 8; // padding top
const PR = 8; // padding right
const PB = 28; // padding bottom
const PLOT_W = W - PL - PR;
const PLOT_H = H - PT - PB;
const chartData = computed(() =>
rows.value.map((r) => ({
date: new Date(r.date),
value: r[metric.value],
raw: r,
})),
);
const minVal = computed(() => Math.min(...chartData.value.map((d) => d.value)));
const maxVal = computed(() => Math.max(...chartData.value.map((d) => d.value)));
const points = computed(() => {
const data = chartData.value;
const n = data.length;
if (!n) return [];
const min = minVal.value;
const range = maxVal.value - min || 1;
return data.map((d, i) => ({
x: PL + (n <= 1 ? PLOT_W / 2 : (i / (n - 1)) * PLOT_W),
y: PT + PLOT_H - ((d.value - min) / range) * PLOT_H,
date: d.date,
value: d.value,
raw: d.raw,
}));
});
const polyline = computed(() =>
points.value.map((p) => `${p.x},${p.y}`).join(" "),
);
const xLabels = computed(() => {
const data = chartData.value;
const pts = points.value;
if (!data.length) return [];
const indices = new Set([
0,
Math.floor((data.length - 1) / 2),
data.length - 1,
]);
return [...indices].map((i) => ({
x: pts[i].x,
label: data[i].date.toLocaleDateString("en-GB", {
month: "short",
day: "numeric",
}),
}));
});
const yLabels = computed(() => {
const min = minVal.value;
const max = maxVal.value;
return [0, 0.5, 1].map((t) => {
const raw = Math.round(min + t * (max - min));
return {
y: PT + PLOT_H - t * PLOT_H,
label: metric.value === "timePer500m" ? formatTime(raw) : raw,
};
});
});
function formatTime(secs) {
const m = Math.floor(secs / 60);
const s = Math.round(secs % 60);
return `${m}:${String(s).padStart(2, "0")}`;
}
function formatValue(key, val) {
if (key === "timePer500m") return formatTime(val) + " /500m";
if (key === "distance") return val + " m";
if (key === "calories") return Math.round(val) + " kcal";
return val;
}
</script>
<template>
<div class="flex flex-col h-full overflow-hidden">
<Header>Rowing</Header>
<div v-if="loading" class="flex-1 flex items-center justify-center">
<p>Loading...</p>
</div>
<div v-else-if="error" class="flex-1 flex items-center justify-center">
<p class="text-tertiary text-xs">{{ error }}</p>
</div>
<div v-else class="flex flex-col flex-1 px-1 pb-1 gap-1 overflow-hidden">
<!-- Metric tabs -->
<div class="flex gap-1 pt-1">
<button
v-for="m in METRICS"
:key="m.key"
class="metric-btn text-xs px-2 py-0.5 font-heading border"
:style="{
borderColor: m.color,
color: metric === m.key ? '#1b110e' : m.color,
backgroundColor: metric === m.key ? m.color : 'transparent',
}"
@click="metric = m.key"
>
{{ m.label }}
</button>
</div>
<!-- SVG Chart -->
<div class="flex-1 relative">
<svg
:viewBox="`0 0 ${W} ${H}`"
width="100%"
height="100%"
preserveAspectRatio="none"
class="overflow-visible"
>
<!-- Grid lines -->
<line
v-for="yl in yLabels"
:key="yl.y"
:x1="PL"
:y1="yl.y"
:x2="W - PR"
:y2="yl.y"
stroke="var(--quaternary)"
stroke-width="0.5"
/>
<!-- Area fill -->
<polygon
v-if="points.length"
:points="`${PL},${PT + PLOT_H} ${polyline} ${W - PR},${PT + PLOT_H}`"
:fill="activeMetric.color"
fill-opacity="0.08"
/>
<!-- Line -->
<polyline
v-if="points.length"
:points="polyline"
:stroke="activeMetric.color"
stroke-width="1.5"
fill="none"
stroke-linejoin="round"
stroke-linecap="round"
/>
<!-- Data points -->
<circle
v-for="(p, i) in points"
:key="i"
:cx="p.x"
:cy="p.y"
:r="hovered === i ? 4 : 2"
:fill="activeMetric.color"
style="cursor: pointer"
@mouseenter="hovered = i"
@mouseleave="hovered = null"
/>
<!-- Y axis labels -->
<text
v-for="yl in yLabels"
:key="`y${yl.y}`"
:x="PL - 3"
:y="yl.y + 3"
text-anchor="end"
font-size="10"
fill="var(--primary)"
font-family="var(--font_heading)"
>
{{ yl.label }}
</text>
<!-- X axis labels -->
<text
v-for="xl in xLabels"
:key="`x${xl.x}`"
:x="xl.x"
:y="H - 4"
text-anchor="middle"
font-size="10"
fill="var(--primary)"
font-family="var(--font_heading)"
>
{{ xl.label }}
</text>
<!-- Axes -->
<line
:x1="PL"
:y1="PT"
:x2="PL"
:y2="PT + PLOT_H"
stroke="var(--primary)"
stroke-width="0.5"
/>
<line
:x1="PL"
:y1="PT + PLOT_H"
:x2="W - PR"
:y2="PT + PLOT_H"
stroke="var(--primary)"
stroke-width="0.5"
/>
<!-- Tooltip -->
<g v-if="hovered !== null && points[hovered]">
<rect
:x="Math.min(points[hovered].x + 4, W - 85)"
:y="points[hovered].y - 20"
width="82"
height="32"
fill="var(--bg_primary)"
:stroke="activeMetric.color"
stroke-width="0.5"
rx="1"
/>
<text
:x="Math.min(points[hovered].x + 7, W - 82)"
:y="points[hovered].y - 6"
font-size="12"
fill="var(--secondary)"
font-family="var(--font_heading)"
>
{{
points[hovered].date.toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "2-digit",
})
}}
</text>
<text
:x="Math.min(points[hovered].x + 7, W - 82)"
:y="points[hovered].y + 8"
font-size="14"
:fill="activeMetric.color"
font-family="var(--font_heading)"
>
{{ formatValue(metric, points[hovered].value) }}
</text>
</g>
</svg>
</div>
<!-- Summary stats -->
<div class="flex justify-between text-xs border-t border-quaternary pt-1">
<div class="flex flex-col items-center">
<span class="text-primary font-heading">{{ rows.length }}</span>
<span class="text-quaternary" style="font-size: 0.6rem"
>sessions</span
>
</div>
<div class="flex flex-col items-center">
<span class="text-primary font-heading"
>{{
rows.reduce((s, r) => s + r.distance, 0).toLocaleString()
}}m</span
>
<span class="text-quaternary" style="font-size: 0.6rem"
>total dist</span
>
</div>
<div class="flex flex-col items-center">
<span class="text-primary font-heading">{{
formatTime(
rows.reduce((s, r) => s + r.timePer500m, 0) / (rows.length || 1),
)
}}</span>
<span class="text-quaternary" style="font-size: 0.6rem"
>avg pace</span
>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.metric-btn {
cursor: pointer;
transition:
background-color 0.15s,
color 0.15s;
letter-spacing: 0.03em;
}
</style>

217
vue/src/views/home/Home.vue Normal file
View File

@@ -0,0 +1,217 @@
<script setup>
import Timer from "@/components/util/Timer.vue";
import Time from "@/components/util/Time.vue";
import Radio from "@/components/util/Radio.vue";
import Elle from "@/components/elle/Elle.vue";
import Chat from "@/components/util/Chat.vue";
import MusicPlayer from "@/components/util/MusicPlayer.vue";
import CommitHistory from "@/components/util/CommitHistory.vue";
import Intro from "./Intro.vue";
import Intro2 from "./Intro2.vue";
import BadApple from "./BadApple.vue";
import Miku from "./Miku.vue";
import Stamps from "./Stamps.vue";
import Listening from "./Listening.vue";
import Links from "./Links.vue";
import Feed from "./Feed.vue";
import Collage from "./Collage.vue";
import Favorites from "./Favorites.vue";
// import Gym from "./Gym.vue";
import Gym2 from "./Gym2.vue";
import Consumption from "./Consumption.vue";
</script>
<template>
<main class="halftone justify-center flex flex-row w-full h-full">
<div class="outerWrap flex flex-row" style="height: 310mm">
<div class="sidebar flex-1 flex flex-col m-10 w-60 gap-2">
<div
class="flex-1 flex flex-col min-h-0 background-children border-children gap-2"
>
<Chat class="flex-1 min-h-0" />
</div>
<div class="sidebar-image">
<Miku class="border-tertiary border bg-bg_secondary" />
</div>
</div>
<div
class="a4page-portrait homeGrid relative background-children border-children bdr-1"
>
<!-- <Intro class="intro" /> -->
<Intro2 class="intro" />
<!-- <BadApple class="intro" /> -->
<Listening class="listening" />
<Stamps class="stamps" />
<Feed class="feed" />
<Links class="links" />
<Collage class="collage" />
<Consumption class="consumption" />
<Favorites class="favorites" />
<!-- <Gym class="gym" /> -->
<Gym2 class="gym" />
</div>
<div class="sidebar flex-1 flex flex-col m-10 w-60 gap-2">
<div
class="flex-1 flex flex-col min-h-0 background-children border-children gap-2"
>
<Time />
<Timer />
<Radio />
<CommitHistory class="flex-1" />
<!-- <Elle class="flex-1" /> -->
<!-- <MusicPlayer /> -->
</div>
<div class="sidebar-image">
<img
src="/img/memes/fire-woman.gif"
class="border-tertiary border"
loading="lazy"
/>
</div>
</div>
</div>
</main>
</template>
<style scoped>
.border-children > * {
border: 2px solid var(--quaternary);
}
.background-children > * {
background-color: var(--bg_primary);
}
.homeGrid {
display: grid;
gap: 5px;
grid-template-columns: repeat(10, 1fr);
grid-template-rows: repeat(10, 1fr);
}
@media (max-width: 1200px) {
.outerWrap {
flex-direction: column;
align-items: stretch;
}
.homeGrid {
order: -1;
width: 100%;
height: 350mm;
margin-inline: 0;
box-sizing: border-box;
}
.sidebar {
width: 100%;
margin: 5px 10px;
flex-direction: column;
align-items: center;
gap: 8px;
}
}
@media (max-width: 850px) {
.homeGrid {
display: flex;
flex-direction: column;
height: auto;
}
.stamps {
max-height: 130px;
}
.sidebar {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 8px;
justify-items: stretch;
}
.sidebar > * {
max-width: none;
width: auto;
}
.sidebar-image {
max-height: 200px;
overflow: hidden;
}
.sidebar-image :deep(img) {
max-height: 200px;
object-fit: contain;
display: block;
margin-inline: auto;
}
}
@media (max-width: 500px) {
main {
overflow-x: hidden;
}
.outerWrap {
max-width: 100vw;
}
.sidebar {
margin: 5px 0;
}
}
.intro {
grid-column: 1 / span 6;
grid-row: 1 / span 4;
}
.listening {
grid-column: 7 / span 4;
grid-row: 1 / span 3;
}
.stamps {
grid-column: 7 / span 4;
grid-row: 4 / span 1;
}
.feed {
grid-column: 1 / span 3;
grid-row: 5 / span 4;
}
.links {
grid-column: 4 / span 2;
grid-row: 5 / span 4;
}
.collage {
grid-column: 6 / span 5;
grid-row: 5 / span 4;
}
.consumption {
grid-column: span 4;
grid-row: span 2;
}
.gym {
grid-column: span 3;
grid-row: span 2;
}
.favorites {
grid-column: span 3;
grid-row: span 2;
}
.bg-random {
background-color: var(--bg_primary);
background-image: url("/img/miku/miku2.gif");
background-size: 10px 10px;
}
</style>

View File

@@ -0,0 +1,30 @@
<script setup>
import Header from "@/components/text/Header.vue";
import Paragraph from "@/components/text/Paragraph.vue";
</script>
<template>
<div class="flex-1 border-box flex flex-col p-1 text-left items-start justify-start">
<Header>Yo</Header>
<!-- <Header>Intro</Header> -->
<!-- <Paragraph> -->
<!-- Hi, I'm Adam, thank you for visiting my website. -->
<!-- </Paragraph> -->
<!-- <Header>Getting around</Header> -->
<!-- <Paragraph> -->
<!-- Pages available can be traversed through links below. I am hoping to -->
<!-- add some shrines, code-walkthoughs, live chat and page transitions -->
<!-- at a later date. -->
<!-- </Paragraph> -->
<!-- <Header>Contact</Header> -->
<!-- <Paragraph> -->
<!-- Please email me <a href="mailto:adam.a.french@outlook.com">here</a>, -->
<!-- or contact me though any of the social medias linked. -->
<!-- </Paragraph> -->
<!-- <Header>A Quote</Header> -->
<!-- <Paragraph> -->
<!-- One crossed wire, one wayward pinch of potassium chlorate, one -->
<!-- errant twitch, and KA-BLOOIE! -->
<!-- </Paragraph> -->
</div>
</template>

View File

@@ -0,0 +1,130 @@
<script setup lang="ts">
import { rand } from "@vueuse/core";
import { ref, onMounted, onUnmounted, nextTick } from "vue";
interface Item {
x: number;
y: number;
dx: number;
dy: number;
content: string;
}
const container = ref<HTMLDivElement | null>(null);
const itemEls = ref<HTMLDivElement[]>([]);
const phrases = [
"Welcome to my website",
"I'm looking to do a big revamp",
"A4 sheets of paper are what I'm used to",
"I'd love to know your recommendations",
"for very short books",
"Message me by any means necessary",
"I like anime, all kinds of music and sci fic",
];
// Non-reactive animation state to avoid triggering Vue re-renders every frame
const animState = phrases.map((text, i) => ({
x: i * 20,
y: i * 20,
dx: rand(0, 60) / 100,
dy: 1.0,
content: text,
cachedW: 0,
cachedH: 0,
}));
// Reactive items only for initial render
const items = ref<Item[]>(
animState.map((s) => ({
x: s.x,
y: s.y,
dx: s.dx,
dy: s.dy,
content: s.content,
})),
);
let rafId = 0;
let cachedCW = 0;
let cachedCH = 0;
let lastFrameTime = 0;
const FRAME_INTERVAL = 1000 / 30;
function measureSizes() {
const c = container.value;
if (c) {
cachedCW = c.clientWidth;
cachedCH = c.clientHeight;
}
itemEls.value.forEach((el, i) => {
if (el && animState[i]) {
animState[i].cachedW = el.offsetWidth;
animState[i].cachedH = el.offsetHeight;
}
});
}
function animate(timestamp: number) {
if (!cachedCW || !cachedCH) {
rafId = requestAnimationFrame(animate);
return;
}
if (timestamp - lastFrameTime < FRAME_INTERVAL) {
rafId = requestAnimationFrame(animate);
return;
}
lastFrameTime = timestamp;
for (let i = 0; i < animState.length; i++) {
const s = animState[i];
const el = itemEls.value[i];
if (!el) continue;
s.x += s.dx;
s.y += s.dy;
if (s.x < 0 || s.x > cachedCW - s.cachedW) s.dx *= -1;
if (s.y < 0 || s.y > cachedCH - s.cachedH) s.dy *= -1;
el.style.transform = `translate(${s.x}px, ${s.y}px)`;
}
rafId = requestAnimationFrame(animate);
}
let resizeObserver: ResizeObserver;
onMounted(async () => {
await nextTick();
measureSizes();
rafId = requestAnimationFrame(animate);
resizeObserver = new ResizeObserver(measureSizes);
resizeObserver.observe(container.value!);
});
onUnmounted(() => {
cancelAnimationFrame(rafId);
resizeObserver?.disconnect();
});
</script>
<template>
<div
ref="container"
class="w-full h-full min-h-125 relative overflow-hidden"
>
<div
v-for="(item, i) in items"
:key="i"
ref="itemEls"
class="absolute w-fit h-fit"
>
<h1>
{{ item.content }}
</h1>
</div>
</div>
</template>

View File

@@ -0,0 +1,30 @@
<script setup>
import RouterTable from "@/components/util/RouterTable.vue";
import LinkTable from "@/components/util/LinkTable.vue";
const site_links = [
{ name: "CV", link: "/cv" },
{ name: "Bookmarks", link: "/bookmarks" },
{ name: "Notes", link: "/notes/Index.md" },
{ name: "Admin", link: "/admin" },
// { name: "Shrines", link: "/shrines" },
];
const social_links = [
{ name: "Gitea", link: "/gitea/explore/repos" },
{ name: "Steam", link: "https://steamcommunity.com/id/SteveThePug" },
{ name: "Github", link: "https://github.com/SteveThePug" },
{ name: "Spotify", link: "https://open.spotify.com/user/stevethepug" },
];
</script>
<template>
<div class="flex flex-col justify-between">
<div class="flex flex-col gap-1">
<RouterTable :linkArr="site_links" />
</div>
<div class="flex flex-col gap-1">
<LinkTable :items="social_links" />
</div>
</div>
</template>

View File

@@ -0,0 +1,81 @@
<script setup>
import Header from "@/components/text/Header.vue";
import { ref, computed, onMounted, onUnmounted } from "vue";
import { useSongsStore } from "@/stores/songs";
const songsStore = useSongsStore();
const idx = ref(0);
const song = computed(() => songsStore.songs[idx.value]);
let nextId = null;
let refreshId = null;
function nextSong() {
clearTimeout(nextId);
nextId = setTimeout(nextSong, 5000);
idx.value = (idx.value + 1) % songsStore.songsCount;
}
onMounted(() => {
songsStore.fetchSongs();
nextId = setTimeout(nextSong, 5000);
refreshId = setInterval(songsStore.fetchSongs, 120000);
});
onUnmounted(() => {
clearTimeout(nextId);
clearInterval(refreshId);
});
</script>
<template>
<div class="listening-wrapper">
<Transition name="fade">
<div
@click="nextSong"
:key="song.track.name"
class="flex flex-col items-center"
>
<Header>Listening To</Header>
<img :src="song.track.album.images[0].url" />
<p class="text-center">
<strong>Song:</strong> {{ song.track.name }}
</p>
<p class="text-center">
<strong>Artist:</strong> {{ song.track.artists[0].name }}
</p>
</div>
</Transition>
</div>
</template>
<style scoped>
.listening-wrapper {
position: relative;
width: 100%;
height: 100%;
}
img {
width: 70%;
}
p {
width: 100%;
margin: 0 auto;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-leave-active {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,13 @@
<script setup>
import Slideshow from "@/components/util/Slideshow.vue";
const images = [
{ url: "/img/miku/miku1.gif" },
{ url: "/img/miku/miku2.gif" },
// { url: "/img/miku/miku2.png" },
];
</script>
<template>
<Slideshow class="p-5" :images="images" :interval="10000" />
</template>

View File

@@ -0,0 +1,50 @@
<script setup>
import { ref } from "vue";
import Touchscreen from "@/components/util/Touchscreen.vue";
import Link from "@/components/text/Link.vue";
import { shuffleArray } from "@/js/utils.js";
let srcs = [
"/img/stamps/portal.gif",
"/img/stamps/miku.gif",
"/img/stamps/utau.gif",
"/img/stamps/teto.webp",
"/img/stamps/3ds.jpg",
"/img/stamps/fry.png",
"/img/stamps/ai.png",
"/img/stamps/rei.png",
"/img/stamps/tetris.gif",
"/img/stamps/tf2.gif",
"/img/stamps/demo.gif",
];
shuffleArray(srcs);
</script>
<template>
<Touchscreen>
<div class="flex flex-wrap tst">
<Link bare href="https://www.adam-french.co.uk">
<img src="https://www.adam-french.co.uk/img/stamps/mine.gif" />
</Link>
<Link bare href="https://jacobbarron.xyz">
<img
src="https://jacobbarron.xyz/Banneh.gif"
alt="jacobbarron.xyz"
/>
</Link>
<img v-for="src in srcs" :src="src" />
</div>
</Touchscreen>
</template>
<style scoped>
img {
width: 89px;
height: 59px;
}
.tst {
min-width: calc(89px * 4);
}
</style>

View File

@@ -0,0 +1,29 @@
<script setup>
import VideoTable from "@/components/util/VideoTable.vue";
import Link from "@/components/text/Link.vue";
const videoSources = [
{ name: "demoman", link: "/img/demoman/1760582395316219.webm" },
{ name: "demoman", link: "/img/demoman/1761052136609718.webm" },
{ name: "demoman", link: "/img/demoman/1761088452011210.mp4" },
{ name: "demoman", link: "/img/demoman/1761570214170465.webm" },
{ name: "demoman", link: "/img/demoman/1761828457509465.webm" },
];
</script>
<template>
<main class="items-center flex flex-col">
<div
class="a4page-portrait bdr-1 flex flex-row relative overflow-scroll items-center"
>
<p>
<Link href="https://wiki.teamfortress.com/wiki/Demoman"
>The goat</Link
>
</p>
<div>
<VideoTable :sourceArr="videoSources" />
</div>
</div>
</main>
</template>

View File

@@ -0,0 +1,13 @@
<script setup>
import Wip from "@/components/util/Wip.vue";
</script>
<template>
<main class="items-center flex flex-col">
<div
class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll"
>
<Wip />
</div>
</main>
</template>

View File

@@ -0,0 +1,11 @@
<script setup>
import Wip from "@/components/util/Wip.vue";
</script>
<template>
<main class="items-center flex flex-col">
<div class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll">
<Wip />
</div>
</main>
</template>

View File

@@ -0,0 +1,13 @@
<script setup>
import Wip from "@/components/util/Wip.vue";
</script>
<template>
<main class="items-center flex flex-col">
<div
class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll"
>
<Wip />
</div>
</main>
</template>

View File

@@ -0,0 +1,72 @@
<template>
<main>
<table id="cover-nav" class="cover-nav no-print">
<thead>
<tr>
<th>Companies</th>
<th>Completed</th>
</tr>
</thead>
<tbody>
<tr>
<td><a href="#LloydsBank">Lloyds</a></td>
<td>YES</td>
</tr>
</tbody>
</table>
<div class="no-print m-1 w-full text-center"></div>
<div id="LloydsBank" class="a5page">
<div class="contact">
<h1>Adam French</h1>
<!-- <a href="index.html"><img width=25 height=50 src="img/rune.png"></a> -->
<div class="contact-details">
<p>+447563266931</p>
<p>adam.a.french@outlook.com</p>
</div>
</div>
<h2>BAE graduate digital intelligence software engineer</h2>
<p>
I am writing to express my interest in your software engineering
position. BAE Systems has hosted multiple stools at the
University of Leeds and have always exhibited their development
of leading-edge software and technology. This is where the
origin of my interest in BAE systems emerged and I'm hopeful
that this interest shall continue.
</p>
<p>
I'm confidient im a strong fit for this role. My technical
background includes extensive experience with frontend
frameworks such as React. My devotion however lies more in
backend development as has more potential to graple problems
related to optimisation and designing coherent interfaces.
</p>
<p>
<em> The C# Programming Yellow Book </em> was my first
introduction to C# during A-Level, Java was our vessel for
teaching object-orientated programming at university. I am
confident I have the relevant experience to grasp the languages
stated for the role I am applying for.
</p>
<p>
My academic background in Computer Science and Mathematics has
honed my ability to translate abstract concepts into structured,
logical solutions. Just as I have transformed theoretical
hypotheses into formal proofs, I aim to transform business
requirements into robust, maintainable software systems through
collaboration and rigorous reasoning.
</p>
<p>Thank you for reading - Adam F</p>
</div>
</main>
</template>
<style scoped>
@import "@/assets/css/cv_styles.css";
@media print {
@page {
size: A5 landscape;
margin: 0;
}
}
</style>