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

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>