Extract Vue frontend into separate container and add stp_wasm crate
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m58s
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:
78
vue/src/components/util/AutoScroll.vue
Normal file
78
vue/src/components/util/AutoScroll.vue
Normal 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>
|
||||
200
vue/src/components/util/Chat.vue
Normal file
200
vue/src/components/util/Chat.vue
Normal 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>
|
||||
37
vue/src/components/util/CommitHistory.vue
Normal file
37
vue/src/components/util/CommitHistory.vue
Normal 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>
|
||||
73
vue/src/components/util/LinkTable.vue
Normal file
73
vue/src/components/util/LinkTable.vue
Normal 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>
|
||||
22
vue/src/components/util/Markdown.vue
Normal file
22
vue/src/components/util/Markdown.vue
Normal 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>
|
||||
93
vue/src/components/util/MusicPlayer.vue
Normal file
93
vue/src/components/util/MusicPlayer.vue
Normal 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>
|
||||
43
vue/src/components/util/ObjectTable.vue
Normal file
43
vue/src/components/util/ObjectTable.vue
Normal 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>
|
||||
56
vue/src/components/util/Radio.vue
Normal file
56
vue/src/components/util/Radio.vue
Normal 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>
|
||||
23
vue/src/components/util/RouterTable.vue
Normal file
23
vue/src/components/util/RouterTable.vue
Normal 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>
|
||||
77
vue/src/components/util/Slideshow.vue
Normal file
77
vue/src/components/util/Slideshow.vue
Normal 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>
|
||||
38
vue/src/components/util/Time.vue
Normal file
38
vue/src/components/util/Time.vue
Normal 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>
|
||||
124
vue/src/components/util/Timer.vue
Normal file
124
vue/src/components/util/Timer.vue
Normal 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>
|
||||
67
vue/src/components/util/Touchscreen.vue
Normal file
67
vue/src/components/util/Touchscreen.vue
Normal 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>
|
||||
30
vue/src/components/util/VideoTable.vue
Normal file
30
vue/src/components/util/VideoTable.vue
Normal 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>
|
||||
11
vue/src/components/util/Wip.vue
Normal file
11
vue/src/components/util/Wip.vue
Normal 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>
|
||||
Reference in New Issue
Block a user