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:
12
vue/src/App.vue
Normal file
12
vue/src/App.vue
Normal 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
299
vue/src/assets/styles.css
Normal 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;
|
||||
}
|
||||
3
vue/src/components/Footer.vue
Normal file
3
vue/src/components/Footer.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<footer></footer>
|
||||
</template>
|
||||
67
vue/src/components/Navbar.vue
Normal file
67
vue/src/components/Navbar.vue
Normal 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>
|
||||
57
vue/src/components/borders/UtenaFrame.vue
Normal file
57
vue/src/components/borders/UtenaFrame.vue
Normal 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>
|
||||
88
vue/src/components/elle/Elle.vue
Normal file
88
vue/src/components/elle/Elle.vue
Normal 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>
|
||||
11
vue/src/components/input/Button.vue
Normal file
11
vue/src/components/input/Button.vue
Normal 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>
|
||||
31
vue/src/components/input/ToggleButton.vue
Normal file
31
vue/src/components/input/ToggleButton.vue
Normal 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>
|
||||
7
vue/src/components/text/Header.vue
Normal file
7
vue/src/components/text/Header.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full border-b border-primary">
|
||||
<h1 class="pl-2 m-0">
|
||||
<slot />
|
||||
</h1>
|
||||
</div>
|
||||
</template>
|
||||
85
vue/src/components/text/Headline.vue
Normal file
85
vue/src/components/text/Headline.vue
Normal 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>
|
||||
39
vue/src/components/text/InlineLink.vue
Normal file
39
vue/src/components/text/InlineLink.vue
Normal 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>
|
||||
38
vue/src/components/text/Link.vue
Normal file
38
vue/src/components/text/Link.vue
Normal 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>
|
||||
5
vue/src/components/text/Paragraph.vue
Normal file
5
vue/src/components/text/Paragraph.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<p class="p-1">
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
37
vue/src/components/text/ToggleHeader.vue
Normal file
37
vue/src/components/text/ToggleHeader.vue
Normal 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>
|
||||
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>
|
||||
7
vue/src/graphql.js
Normal file
7
vue/src/graphql.js
Normal 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;
|
||||
}
|
||||
347
vue/src/js/mobile-automata.mjs
Normal file
347
vue/src/js/mobile-automata.mjs
Normal 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
17
vue/src/js/utils.js
Normal 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
12
vue/src/main.js
Normal 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
70
vue/src/router/index.js
Normal 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;
|
||||
36
vue/src/stores/activity.js
Normal file
36
vue/src/stores/activity.js
Normal 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
90
vue/src/stores/auth.js
Normal 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,
|
||||
};
|
||||
});
|
||||
36
vue/src/stores/favorites.js
Normal file
36
vue/src/stores/favorites.js
Normal 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,
|
||||
};
|
||||
});
|
||||
74
vue/src/stores/homeData.js
Normal file
74
vue/src/stores/homeData.js
Normal 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
103
vue/src/stores/messages.js
Normal 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
48
vue/src/stores/models.js
Normal 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
57
vue/src/stores/posts.js
Normal 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
59
vue/src/stores/songs.js
Normal 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
13
vue/src/views/404.vue
Normal 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
255
vue/src/views/Bookmarks.vue
Normal 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
359
vue/src/views/CV/CV.vue
Normal 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>2021–2025</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>2023–2024</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>2018–2023</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>
|
||||
17
vue/src/views/CV/Project.vue
Normal file
17
vue/src/views/CV/Project.vue
Normal 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
56
vue/src/views/Landing.vue
Normal 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
75
vue/src/views/Notes.vue
Normal 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
20
vue/src/views/Shrines.vue
Normal 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>
|
||||
28
vue/src/views/admin/Admin.vue
Normal file
28
vue/src/views/admin/Admin.vue
Normal 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>
|
||||
35
vue/src/views/admin/CreateActivity.vue
Normal file
35
vue/src/views/admin/CreateActivity.vue
Normal 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>
|
||||
35
vue/src/views/admin/CreateFavorite.vue
Normal file
35
vue/src/views/admin/CreateFavorite.vue
Normal 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>
|
||||
36
vue/src/views/admin/CreatePost.vue
Normal file
36
vue/src/views/admin/CreatePost.vue
Normal 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>
|
||||
51
vue/src/views/admin/CreateRowing.vue
Normal file
51
vue/src/views/admin/CreateRowing.vue
Normal 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>
|
||||
45
vue/src/views/admin/CreateUser.vue
Normal file
45
vue/src/views/admin/CreateUser.vue
Normal 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>
|
||||
34
vue/src/views/admin/Login.vue
Normal file
34
vue/src/views/admin/Login.vue
Normal 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>
|
||||
45
vue/src/views/admin/ManageUsers.vue
Normal file
45
vue/src/views/admin/ManageUsers.vue
Normal 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>
|
||||
42
vue/src/views/home/BadApple.vue
Normal file
42
vue/src/views/home/BadApple.vue
Normal 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>
|
||||
15
vue/src/views/home/Collage.vue
Normal file
15
vue/src/views/home/Collage.vue
Normal 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>
|
||||
18
vue/src/views/home/Consumption.vue
Normal file
18
vue/src/views/home/Consumption.vue
Normal 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>
|
||||
22
vue/src/views/home/Favorites.vue
Normal file
22
vue/src/views/home/Favorites.vue
Normal 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>
|
||||
82
vue/src/views/home/Feed.vue
Normal file
82
vue/src/views/home/Feed.vue
Normal 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>
|
||||
26
vue/src/views/home/Gym.vue
Normal file
26
vue/src/views/home/Gym.vue
Normal 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
318
vue/src/views/home/Gym2.vue
Normal 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
217
vue/src/views/home/Home.vue
Normal 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>
|
||||
30
vue/src/views/home/Intro.vue
Normal file
30
vue/src/views/home/Intro.vue
Normal 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>
|
||||
130
vue/src/views/home/Intro2.vue
Normal file
130
vue/src/views/home/Intro2.vue
Normal 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>
|
||||
30
vue/src/views/home/Links.vue
Normal file
30
vue/src/views/home/Links.vue
Normal 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>
|
||||
81
vue/src/views/home/Listening.vue
Normal file
81
vue/src/views/home/Listening.vue
Normal 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>
|
||||
13
vue/src/views/home/Miku.vue
Normal file
13
vue/src/views/home/Miku.vue
Normal 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>
|
||||
50
vue/src/views/home/Stamps.vue
Normal file
50
vue/src/views/home/Stamps.vue
Normal 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>
|
||||
29
vue/src/views/shrines/Demoman.vue
Normal file
29
vue/src/views/shrines/Demoman.vue
Normal 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>
|
||||
13
vue/src/views/shrines/Evangelion.vue
Normal file
13
vue/src/views/shrines/Evangelion.vue
Normal 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>
|
||||
11
vue/src/views/shrines/GTO.vue
Normal file
11
vue/src/views/shrines/GTO.vue
Normal 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>
|
||||
13
vue/src/views/shrines/Skipskipbenben.vue
Normal file
13
vue/src/views/shrines/Skipskipbenben.vue
Normal 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>
|
||||
72
vue/src/views/unused/CoverLetters.vue
Normal file
72
vue/src/views/unused/CoverLetters.vue
Normal 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>
|
||||
Reference in New Issue
Block a user