Big formatting spree
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m50s

This commit is contained in:
2026-04-29 09:06:41 +01:00
parent b41e67fe1a
commit 3844a32751
76 changed files with 3146 additions and 2788 deletions

View File

@@ -1,33 +1,33 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Adam French's personal website">
<title>AF</title>
<link rel="preconnect" href="https://i.scdn.co" crossorigin>
<link
rel="preconnect"
href="https://cdn.akamai.steamstatic.com"
crossorigin
>
<link rel="icon" type="/img/x-icon" href="/img/favicon.ico">
<link
rel="preload"
href="/fonts/big_noodle_titling.woff2"
as="font"
type="font/woff2"
crossorigin
>
<link
rel="preload"
href="/fonts/CreatoDisplay-Bold.woff2"
as="font"
type="font/woff2"
crossorigin
>
</head>
<body id="app">
<script type="module" src="/src/main.js"></script>
</body>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Adam French's personal website" />
<title>AF</title>
<link rel="preconnect" href="https://i.scdn.co" crossorigin />
<link
rel="preconnect"
href="https://cdn.akamai.steamstatic.com"
crossorigin
/>
<link rel="icon" type="/img/x-icon" href="/img/favicon.ico" />
<link
rel="preload"
href="/fonts/big_noodle_titling.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/fonts/CreatoDisplay-Bold.woff2"
as="font"
type="font/woff2"
crossorigin
/>
</head>
<body id="app">
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -3,5 +3,5 @@ import { RouterView } from "vue-router";
</script>
<template>
<RouterView />
<RouterView />
</template>

View File

@@ -5,128 +5,128 @@ const clock = ref("");
let timer;
function updateClock() {
const now = new Date();
clock.value = now.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const now = new Date();
clock.value = now.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
onMounted(() => {
updateClock();
timer = setInterval(updateClock, 1000);
updateClock();
timer = setInterval(updateClock, 1000);
});
onUnmounted(() => {
clearInterval(timer);
clearInterval(timer);
});
const user = "visitor";
</script>
<template>
<footer class="waybar">
<div class="modules-left">
<span class="workspace active"></span>
</div>
<footer class="waybar">
<div class="modules-left">
<span class="workspace active"></span>
</div>
<div class="modules-right">
<span class="module greeting">Hi, {{ user }}!</span>
<span class="module cpu hide-sm">CPU 3%</span>
<span class="module mem hide-sm">MEM 42%</span>
<span class="module disk hide-sm">DISK 67%</span>
<span class="module network hide-sm"> 12K 84K</span>
<span class="module battery hide-sm">BAT 98%</span>
<span class="module clock">{{ clock }}</span>
</div>
</footer>
<div class="modules-right">
<span class="module greeting">Hi, {{ user }}!</span>
<span class="module cpu hide-sm">CPU 3%</span>
<span class="module mem hide-sm">MEM 42%</span>
<span class="module disk hide-sm">DISK 67%</span>
<span class="module network hide-sm"> 12K 84K</span>
<span class="module battery hide-sm">BAT 98%</span>
<span class="module clock">{{ clock }}</span>
</div>
</footer>
</template>
<style scoped>
.waybar {
font-family: "URWGothic-Book", monospace;
background-color: var(--bg_primary);
color: var(--primary);
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
font-size: 14px;
min-height: 36px;
flex-shrink: 0;
font-family: "URWGothic-Book", monospace;
background-color: var(--bg_primary);
color: var(--primary);
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
font-size: 14px;
min-height: 36px;
flex-shrink: 0;
}
.modules-left {
display: flex;
gap: 2px;
display: flex;
gap: 2px;
}
.workspace {
background: var(--quaternary);
border: none;
border-bottom: 2px solid var(--secondary);
color: var(--secondary);
padding: 2px 10px;
font-family: inherit;
font-size: 14px;
background: var(--quaternary);
border: none;
border-bottom: 2px solid var(--secondary);
color: var(--secondary);
padding: 2px 10px;
font-family: inherit;
font-size: 14px;
}
.modules-right {
display: flex;
align-items: center;
display: flex;
align-items: center;
}
.module {
padding: 2px 12px;
border-left: 1px solid var(--tertiary);
padding: 2px 12px;
border-left: 1px solid var(--tertiary);
}
.module:first-child {
border-left: none;
border-left: none;
}
.greeting {
color: var(--secondary);
color: var(--secondary);
}
.clock {
color: var(--tertiary);
color: var(--tertiary);
}
.cpu,
.mem,
.disk {
color: var(--primary);
color: var(--primary);
}
.network {
color: var(--secondary);
color: var(--secondary);
}
.battery {
color: var(--primary);
color: var(--primary);
}
@media (max-width: 800px) {
.waybar {
font-size: 11px;
padding: 4px 4px;
}
.waybar {
font-size: 11px;
padding: 4px 4px;
}
.workspace {
padding: 2px 6px;
font-size: 11px;
}
.workspace {
padding: 2px 6px;
font-size: 11px;
}
.module {
padding: 2px 6px;
}
.module {
padding: 2px 6px;
}
.hide-sm {
display: none;
}
.hide-sm {
display: none;
}
}
</style>

View File

@@ -6,55 +6,55 @@ 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 segments = route.path.split("/").filter(Boolean);
if (segments.length == 1) {
return "/";
} else {
segments.pop();
return segments.length ? "/" + segments.join("/") : null;
}
});
const faces = [
"^_^",
"¯\\_(ツ)_/¯",
"(◕‿◕✿)",
"ಠ_ಠ",
"ʘ‿ʘ",
"^̮^",
">_>",
"¬_¬",
"˙ ͜ʟ˙",
"( ͡° ͜ʖ ͡°)",
"[̲̅$̲̅(̲̅5̲̅)̲̅$̲̅]",
"(ง'̀-'́)ง",
"\ (•◡•) /",
"( ͡ᵔ ͜ʖ ͡ᵔ )",
"ᕙ(⇀‸↼‶)ᕗ",
"⚆ _ ⚆",
"(。◕‿◕。)",
"(╯°□°)╯︵ ʞooqǝɔɐɟ",
"̿ ̿ ̿'̿'\̵͇̿̿\з=(•_•)=ε/̵͇̿̿/'̿'̿ ̿",
"(☞゚ヮ゚)☞ ☜(゚ヮ゚☜)",
"^_^",
"¯\\_(ツ)_/¯",
"(◕‿◕✿)",
"ಠ_ಠ",
"ʘ‿ʘ",
"^̮^",
">_>",
"¬_¬",
"˙ ͜ʟ˙",
"( ͡° ͜ʖ ͡°)",
"[̲̅$̲̅(̲̅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" 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>
<nav class="flex flex-row w-full h-fit border border-primary bg-bg_primary">
<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;
position: fixed;
top: 0;
left: 0;
}
</style>

View File

@@ -1,57 +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>
<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;
position: relative;
margin: 100px;
}
.flower {
position: absolute;
width: 150px;
position: absolute;
width: 150px;
}
.tl {
top: -80px;
left: -80px;
--start: 0deg;
top: -80px;
left: -80px;
--start: 0deg;
}
.tr {
top: -80px;
right: -80px;
--start: 90deg;
top: -80px;
right: -80px;
--start: 90deg;
}
.bl {
bottom: -80px;
left: -80px;
--start: 180deg;
bottom: -80px;
left: -80px;
--start: 180deg;
}
.br {
bottom: -80px;
right: -80px;
--start: 270deg;
bottom: -80px;
right: -80px;
--start: 270deg;
}
.rotate {
animation: spin 3s linear infinite;
animation: spin 3s linear infinite;
}
.antirotate {
animation: spin 3s linear infinite reverse;
animation: spin 3s linear infinite reverse;
}
@keyframes spin {
from {
transform: rotate(var(--start));
}
to {
transform: rotate(calc(var(--start) + 360deg));
}
from {
transform: rotate(var(--start));
}
to {
transform: rotate(calc(var(--start) + 360deg));
}
}
</style>

View File

@@ -6,11 +6,11 @@ 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(),
}));
return Array.from({ length: n }, (_, i) => ({
width,
offset: step * i,
color: getRandomColor(),
}));
}
const offsets = ref(generateOffsets((150, 15, 10)));
let rafId;
@@ -18,71 +18,71 @@ 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);
const ctnr = container.value;
for (const item of offsets.value) {
const width = Math.max(ctnr.offsetWidth, item.width);
console.log(ctnr.offsetWidth);
console.log(ctnr.offsetWidth);
item.offset -= speed;
if (item.offset <= -width) {
item.color = getRandomColor();
item.offset = 0;
}
item.offset -= speed;
if (item.offset <= -width) {
item.color = getRandomColor();
item.offset = 0;
}
rafId = requestAnimationFrame(animate);
}
rafId = requestAnimationFrame(animate);
}
onMounted(() => {
rafId = requestAnimationFrame(animate);
rafId = requestAnimationFrame(animate);
});
onUnmounted(() => {
cancelAnimationFrame(rafId);
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 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;
position: relative;
overflow: hidden;
width: 100%;
will-change: transform;
}
.item {
opacity: 40%;
height: 100%;
position: absolute;
opacity: 40%;
height: 100%;
position: absolute;
}
.item1 {
left: 0px;
top: 0px;
left: 0px;
top: 0px;
}
.item2 {
top: 0px;
top: 0px;
}
</style>

View File

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

View File

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

View File

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

View File

@@ -12,74 +12,74 @@ 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);
}
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)`;
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);
measureWidth();
rafId = requestAnimationFrame(animate);
resizeObserver = new ResizeObserver(measureWidth);
resizeObserver.observe(container.value);
resizeObserver = new ResizeObserver(measureWidth);
resizeObserver.observe(container.value);
});
onUnmounted(() => {
cancelAnimationFrame(rafId);
resizeObserver?.disconnect();
cancelAnimationFrame(rafId);
resizeObserver?.disconnect();
});
</script>
<template>
<div class="root">
<div class="container" ref="container">
<div ref="item1">
<slot />
</div>
<div>
<slot />
</div>
</div>
<div class="root">
<div class="container" ref="container">
<div ref="item1">
<slot />
</div>
<div>
<slot />
</div>
</div>
</div>
</template>
<style scoped>
.root {
overflow: hidden;
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;
width: fit-content;
height: fit-content;
display: grid;
grid-auto-flow: column;
grid-auto-columns: max-content;
/* Each column fits its content */
overflow-x: visible;
will-change: transform;
gap: 10em;
}
</style>

View File

@@ -2,38 +2,44 @@
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 },
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;
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>
<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;
color: var(--primary);
font-weight: bold;
font-style: italic;
text-decoration: none;
transition: color 0.15s ease;
}
.inline-link:hover {
color: var(--tertiary);
color: var(--tertiary);
}
</style>

View File

@@ -2,37 +2,43 @@
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 },
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;
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>
<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;
color: var(--primary);
text-decoration: none;
transition: color 0.15s ease;
}
.link:hover {
color: var(--tertiary);
color: var(--tertiary);
}
</style>

View File

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

View File

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

View File

@@ -1,7 +1,12 @@
<template>
<div ref="container" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" class="overflow-y-auto">
<slot />
</div>
<div
ref="container"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
class="overflow-y-auto"
>
<slot />
</div>
</template>
<script setup>
@@ -20,89 +25,89 @@ let pauseTimeoutId = null;
let cachedScrollHeight = 0;
function measureScrollHeight() {
const el = container.value;
if (el) cachedScrollHeight = el.scrollHeight;
const el = container.value;
if (el) cachedScrollHeight = el.scrollHeight;
}
function stopLoop() {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
if (pauseTimeoutId !== null) {
clearTimeout(pauseTimeoutId);
pauseTimeoutId = null;
}
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
if (pauseTimeoutId !== null) {
clearTimeout(pauseTimeoutId);
pauseTimeoutId = null;
}
}
function startLoop() {
stopLoop();
rafId = requestAnimationFrame(tick);
stopLoop();
rafId = requestAnimationFrame(tick);
}
function onMouseEnter() {
hovered = true;
stopLoop();
hovered = true;
stopLoop();
}
function onMouseLeave() {
hovered = false;
const el = container.value;
if (el && cachedScrollHeight > 0) {
pos = el.scrollTop / cachedScrollHeight;
}
startLoop();
hovered = false;
const el = container.value;
if (el && cachedScrollHeight > 0) {
pos = el.scrollTop / cachedScrollHeight;
}
startLoop();
}
function schedulePause(callback) {
stopLoop();
pauseTimeoutId = setTimeout(callback, PAUSE);
stopLoop();
pauseTimeoutId = setTimeout(callback, PAUSE);
}
function tick() {
rafId = null;
const el = container.value;
if (hovered) return;
if (!el || cachedScrollHeight === 0) {
rafId = requestAnimationFrame(tick);
return;
}
const reachedBottom = pos >= 1;
const reachedTop = pos <= 0;
if (reachedBottom) {
pos = 0.999;
direction = -1;
schedulePause(startLoop);
return;
} else if (reachedTop && direction === -1) {
pos = 0.001;
direction = 1;
schedulePause(startLoop);
return;
}
pos += direction * SPEED;
el.scrollTop = pos * cachedScrollHeight;
rafId = null;
const el = container.value;
if (hovered) return;
if (!el || cachedScrollHeight === 0) {
rafId = requestAnimationFrame(tick);
return;
}
const reachedBottom = pos >= 1;
const reachedTop = pos <= 0;
if (reachedBottom) {
pos = 0.999;
direction = -1;
schedulePause(startLoop);
return;
} else if (reachedTop && direction === -1) {
pos = 0.001;
direction = 1;
schedulePause(startLoop);
return;
}
pos += direction * SPEED;
el.scrollTop = pos * cachedScrollHeight;
rafId = requestAnimationFrame(tick);
}
let resizeObserver;
onMounted(() => {
measureScrollHeight();
schedulePause(startLoop);
measureScrollHeight();
schedulePause(startLoop);
resizeObserver = new ResizeObserver(measureScrollHeight);
resizeObserver.observe(container.value);
resizeObserver = new ResizeObserver(measureScrollHeight);
resizeObserver.observe(container.value);
});
onBeforeUnmount(() => {
stopLoop();
resizeObserver?.disconnect();
stopLoop();
resizeObserver?.disconnect();
});
</script>

View File

@@ -19,210 +19,205 @@ const SCROLL_THRESHOLD = 100;
let resizeObserver = null;
function scrollToBottom() {
if (messagesContainer.value) {
messagesContainer.value.scrollTop =
messagesContainer.value.scrollHeight;
}
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
}
}
function scrollToBottomIfNear() {
if (isNearBottom.value) {
scrollToBottom();
}
if (isNearBottom.value) {
scrollToBottom();
}
}
function onScroll() {
if (!messagesContainer.value) return;
const { scrollHeight, scrollTop, clientHeight } = messagesContainer.value;
isNearBottom.value =
scrollHeight - scrollTop - clientHeight < SCROLL_THRESHOLD;
if (!messagesContainer.value) return;
const { scrollHeight, scrollTop, clientHeight } = messagesContainer.value;
isNearBottom.value =
scrollHeight - scrollTop - clientHeight < SCROLL_THRESHOLD;
}
function goToBottom() {
isNearBottom.value = true;
scrollToBottom();
isNearBottom.value = true;
scrollToBottom();
}
watch(
() => messages.value.length,
() => {
nextTick(scrollToBottomIfNear);
},
() => messages.value.length,
() => {
nextTick(scrollToBottomIfNear);
},
);
function sendMessage() {
const text = messageInput.value.trim();
if (!text) return;
isNearBottom.value = true;
messagesStore.sendMessage(text);
messageInput.value = "";
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 = "";
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);
return /\.(jpg|jpeg|png|gif|webp)$/i.test(url);
}
function isVideoUrl(url) {
return /\.(mp4|webm|ogg|mov)$/i.test(url);
return /\.(mp4|webm|ogg|mov)$/i.test(url);
}
function isSafeFileUrl(url) {
return typeof url === "string" && url.startsWith("/uploads/");
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;
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),
});
}
if (lastIndex < text.length) {
parts.push({ type: "text", value: text.slice(lastIndex) });
}
return parts;
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();
messagesStore.connect();
if (messagesContainer.value) {
messagesContainer.value.addEventListener("scroll", onScroll, {
passive: true,
});
}
if (messagesContainer.value) {
messagesContainer.value.addEventListener("scroll", onScroll, {
passive: true,
});
}
if (messagesInner.value) {
resizeObserver = new ResizeObserver(scrollToBottomIfNear);
resizeObserver.observe(messagesInner.value);
}
if (messagesInner.value) {
resizeObserver = new ResizeObserver(scrollToBottomIfNear);
resizeObserver.observe(messagesInner.value);
}
scrollToBottom();
scrollToBottom();
});
onUnmounted(() => {
messagesStore.disconnect();
messagesStore.disconnect();
if (messagesContainer.value) {
messagesContainer.value.removeEventListener("scroll", onScroll);
}
if (messagesContainer.value) {
messagesContainer.value.removeEventListener("scroll", onScroll);
}
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
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 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"
>
<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"
alt="Uploaded image"
loading="lazy"
class="w-full max-w-full 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"
aria-label="Chat message"
<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"
alt="Uploaded image"
loading="lazy"
class="w-full max-w-full rounded block"
/>
<input
ref="fileInput"
type="file"
class="hidden"
@change="onFileSelected"
<video
v-else-if="isVideoUrl(message.fileUrl)"
:src="message.fileUrl"
controls
preload="none"
class="w-full max-w-full max-h-48 rounded block"
/>
<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>
<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"
aria-label="Chat message"
/>
<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: none;
height: 100%;
}
.chat-root {
max-height: none;
height: 100%;
}
}
</style>

View File

@@ -9,37 +9,42 @@ 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 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 items-center overflow-y-auto overflow-x-hidden"
>
<h3>Last git activity</h3>
<img :src="feed.avatarUrl" alt="User avatar" class="avatar" loading="lazy" />
<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 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 items-center overflow-y-auto overflow-x-hidden"
>
<h3>Last git activity</h3>
<img
:src="feed.avatarUrl"
alt="User avatar"
class="avatar"
loading="lazy"
/>
<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>
<style scoped>
.avatar {
max-width: 200px;
width: 100%;
height: auto;
max-width: 200px;
width: 100%;
height: auto;
}
</style>

View File

@@ -4,70 +4,66 @@ 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: "",
},
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 items-center">
{{ 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>
<div v-if="title" class="h-fit w-full">
<ToggleHeader v-model="show" class="justify-between flex items-center">
{{ title }}
</ToggleHeader>
<template v-if="show">
<Link
v-if="variant === 'list'"
v-for="(item, i) in items"
:key="i"
:href="item.link"
>
<p class="bdr-2 bg-bg_tertiary">{{ item.name }}</p>
</Link>
<table class="w-full" v-else>
<tbody>
<tr v-for="item in items" :key="item.id">
<th>{{ item.type }}</th>
<td v-if="item.link">
<Link :href="item.link">{{ item.name }}</Link>
</td>
<td v-else>{{ item.name }}</td>
</tr>
</tbody>
</table>
</template>
</div>
<template v-else>
<template v-if="variant === 'list'">
<Link v-for="(item, i) in items" :key="i" :href="item.link">
<p class="bdr-2 bg-bg_tertiary">{{ item.name }}</p>
</Link>
</template>
<table class="w-full" v-else>
<tbody>
<tr v-for="item in items" :key="item.id">
<th>{{ item.type }}</th>
<td v-if="item.link">
<Link :href="item.link">{{ item.name }}</Link>
</td>
<td v-else>{{ item.name }}</td>
</tr>
</tbody>
</table>
</template>
</template>

View File

@@ -6,19 +6,19 @@ import DOMPurify from "dompurify";
const mdIt = MarkdownIt().use(katex);
const props = defineProps({
source: String,
source: String,
});
function renderMarkdown(source) {
return DOMPurify.sanitize(mdIt.render(source));
return DOMPurify.sanitize(mdIt.render(source));
}
</script>
<template>
<div
v-html="renderMarkdown(props.source)"
class="flex flex-col items-center"
></div>
<div
v-html="renderMarkdown(props.source)"
class="flex flex-col items-center"
></div>
</template>
<style>

View File

@@ -1,93 +1,92 @@
<script setup>
import Button from "@/components/input/Button.vue";
</script>
<template>
<audio/>
<div class="musicPlayerGrid">
<div class="album_cover">
<img src="/img/Untitled.png" alt=""></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>
<audio />
<div class="musicPlayerGrid">
<div class="album_cover">
<img src="/img/Untitled.png" alt="" />
</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;
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%;
width: 100%;
}
.album_cover {
grid-row: 1 / span 3;
background-color: grey;
box-sizing: border-box;
grid-row: 1 / span 3;
background-color: grey;
box-sizing: border-box;
}
.controls {
width: 100%;
grid-row: 4 / span 1;
box-sizing: border-box;
width: 100%;
grid-row: 4 / span 1;
box-sizing: border-box;
display: grid;
grid-template-rows: repeat(4, 1fr);
grid-gap: 5px;
display: grid;
grid-template-rows: repeat(4, 1fr);
grid-gap: 5px;
}
.sliders {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-gap: 5px;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-gap: 5px;
}
.timeline {
grid-column: 1;
background-color: white;
grid-column: 1;
background-color: white;
}
.volume {
grid-column: 2;
background-color: white;
grid-column: 2;
background-color: white;
}
.buttons {
background-color: black;
grid-row: 2 / -1;
background-color: black;
grid-row: 2 / -1;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-gap: 5px;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-gap: 5px;
}
.rewind {
grid-column: 1;
background-color: grey;
grid-column: 1;
background-color: grey;
}
.fastforward {
grid-column: 4;
background-color: grey;
grid-column: 4;
background-color: grey;
}
.playPause {
grid-column: 2/span 2;
background-color: grey;
grid-column: 2 / span 2;
background-color: grey;
}
</style>

View File

@@ -2,42 +2,42 @@
import { computed } from "vue";
const props = defineProps({
objArr: {
type: Array,
required: true,
},
objArr: {
type: Array,
required: true,
},
});
const resolvedColumns = computed(() => {
const keys = new Set();
const keys = new Set();
for (const obj of props.objArr) {
Object.keys(obj).forEach((key) => keys.add(key));
}
for (const obj of props.objArr) {
Object.keys(obj).forEach((key) => keys.add(key));
}
return Array.from(keys).map((key) => ({
key,
label: 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>
<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>
<tbody>
<tr v-for="(row, rowIndex) in objArr" :key="rowIndex">
<td v-for="col in resolvedColumns" :key="col.key">
{{ row[col.key] ?? "" }}
</td>
</tr>
</tbody>
</table>
</template>

View File

@@ -1,17 +1,17 @@
<template>
<div v-if="streamLive" class="overflow-auto">
<Header>Radio</Header>
<img src="/img/tmpen31z3pe.PNG" alt="Radio" width="176" height="177" />
<audio controls :src="streamUrl" ref="audio"></audio>
</div>
<div v-else class="overflow-auto">
<Header>Radio</Header>
<img src="/img/tmpen31z3pe.PNG" alt="Radio" width="176" height="177" />
<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 v-if="streamLive" class="overflow-auto">
<Header>Radio</Header>
<img src="/img/tmpen31z3pe.PNG" alt="Radio" width="176" height="177" />
<audio controls :src="streamUrl" ref="audio"></audio>
</div>
<div v-else class="overflow-auto">
<Header>Radio</Header>
<img src="/img/tmpen31z3pe.PNG" alt="Radio" width="176" height="177" />
<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>
@@ -25,32 +25,32 @@ 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;
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);
checkStream();
setInterval(checkStream, 120000);
});
</script>
<style scoped>
img {
width: 100%;
max-height: 150px;
object-fit: cover;
width: 100%;
max-height: 150px;
object-fit: cover;
}
</style>

View File

@@ -2,22 +2,22 @@
import { computed } from "vue";
const props = defineProps({
linkArr: {
type: Array,
required: true,
},
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>
<RouterLink
class="bdr-2 bg-bg_primary"
v-for="(row, rowIndex) in linkArr"
:key="rowIndex"
:to="row.link"
>
{{ row.name }}
</RouterLink>
</template>

View File

@@ -3,14 +3,14 @@ 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,
},
images: {
type: Array,
required: true,
},
interval: {
type: Number,
default: 10000,
},
});
const currentIndex = ref(0);
@@ -20,58 +20,58 @@ 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);
clearTimeout(nextId);
currentIndex.value = (currentIndex.value + 1) % props.images.length;
nextId = setTimeout(nextImage, props.interval);
}
onMounted(() => {
nextId = setTimeout(nextImage, props.interval);
nextId = setTimeout(nextImage, props.interval);
});
onUnmounted(() => {
clearTimeout(nextId);
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" fetchpriority="high" />
</div>
</Transition>
</div>
<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" fetchpriority="high" />
</div>
</Transition>
</div>
</template>
<style scoped>
.slideshow-wrapper {
display: grid;
width: 100%;
overflow: hidden;
display: grid;
width: 100%;
overflow: hidden;
}
.image-viewer {
grid-area: 1 / 1;
width: 100%;
overflow: hidden;
grid-area: 1 / 1;
width: 100%;
overflow: hidden;
}
img {
width: 100%;
object-fit: cover;
display: block;
width: 100%;
object-fit: cover;
display: block;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
opacity: 0;
}
</style>

View File

@@ -8,14 +8,14 @@ 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" });
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();
@@ -24,15 +24,15 @@ setInterval(updateDateTime, 60000);
</script>
<template>
<div class="flex flex-col">
<Header>{{ weekday }} {{ day }}, {{ month }}</Header>
<h1>{{ time }}</h1>
</div>
<div class="flex flex-col">
<Header>{{ weekday }} {{ day }}, {{ month }}</Header>
<h1>{{ time }}</h1>
</div>
</template>
<style scoped>
div {
text-align: center;
padding: 4px;
text-align: center;
padding: 4px;
}
</style>

View File

@@ -18,109 +18,109 @@ const seconds = ref(0);
const audio = new Audio("/sound/auughhh.mp3");
function tick() {
seconds.value++;
if (seconds.value === 60) {
minutes.value++;
seconds.value = 0;
}
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);
}
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);
finished.value = false;
paused.value = false;
timer.value = setInterval(tick, 1000);
}
function pauseTimer() {
if (finished.value) return;
if (finished.value) return;
if (paused.value) {
timer.value = setInterval(tick, 1000);
paused.value = false;
} else {
clearInterval(timer.value);
paused.value = true;
}
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;
finished.value = true;
paused.value = true;
clearInterval(timer.value);
minutes.value = 0;
seconds.value = 0;
}
function playFinishedSound() {
audio.play();
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"
aria-label="Minutes"
/>
<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"
aria-label="Seconds"
/>
<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 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"
aria-label="Minutes"
/>
<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"
aria-label="Seconds"
/>
<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;
}
.timer-root {
padding: 2px;
gap: 2px;
}
}
</style>

View File

@@ -1,15 +1,15 @@
<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>
<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>
@@ -23,45 +23,45 @@ 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;
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();
// Prevent text selection while dragging
e.preventDefault();
};
const handleMouseMove = (e) => {
if (!isDragging.value) return;
if (!isDragging.value) return;
e.preventDefault();
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;
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;
container.value.scrollLeft = scrollLeft.value - walkX;
container.value.scrollTop = scrollTop.value - walkY;
};
const handleMouseUp = () => {
isDragging.value = false;
isDragging.value = false;
};
const handleMouseLeave = () => {
isDragging.value = false;
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;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
</style>

View File

@@ -2,29 +2,29 @@
import { computed } from "vue";
const props = defineProps({
sourceArr: {
type: Array,
required: true,
},
sourceArr: {
type: Array,
required: true,
},
});
function sourceType(link) {
return "video/" + link.split(".").pop();
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>
<video
v-for="(source, rowIndex) in sourceArr"
:key="rowIndex"
class="bdr-1"
width="300"
height="400"
controls
preload="none"
>
<source :src="source.link" :type="sourceType(source.link)" />
</video>
</template>

View File

@@ -1,11 +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" alt="" loading="lazy" />
</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>
<div class="a4page-portrait bdr-1 flex flex-col relative overflow-scroll">
<RouterLink to="/" class="bdr-2">
<img src="/img/memes/epic.jpeg" alt="" loading="lazy" />
</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>

View File

@@ -1,7 +1,7 @@
<template>
<div ref="container" class="overflow-y-auto">
<slot />
</div>
<div ref="container" class="overflow-y-auto">
<slot />
</div>
</template>
<script setup>
@@ -13,14 +13,14 @@ const container = useTemplateRef("container");
let scroller = null;
onMounted(() => {
if (!container.value) return;
scroller = new AutoScroller(container.value);
scroller.start();
if (!container.value) return;
scroller = new AutoScroller(container.value);
scroller.start();
});
onBeforeUnmount(() => {
scroller?.destroy();
scroller?.free();
scroller = null;
scroller?.destroy();
scroller?.free();
scroller = null;
});
</script>

View File

@@ -2,6 +2,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);
if (res.data.errors && !res.data.data)
throw new Error(res.data.errors[0].message);
return res.data.data;
}

View File

@@ -3,41 +3,69 @@ import { RouterView } from "vue-router";
</script>
<template>
<div class="cv-layout">
<RouterView />
</div>
<div class="cv-layout">
<RouterView />
</div>
</template>
<style scoped>
.cv-layout {
min-height: 100vh;
background: white;
color: #111;
min-height: 100vh;
background: white;
color: #111;
}
</style>
<style>
.cv-layout h1, .cv-layout h2, .cv-layout h3, .cv-layout h4,
.cv-layout p, .cv-layout small, .cv-layout code, .cv-layout ul, .cv-layout li,
.cv-layout td, .cv-layout tr, .cv-layout table {
color: #111;
.cv-layout h1,
.cv-layout h2,
.cv-layout h3,
.cv-layout h4,
.cv-layout p,
.cv-layout small,
.cv-layout code,
.cv-layout ul,
.cv-layout li,
.cv-layout td,
.cv-layout tr,
.cv-layout table {
color: #111;
}
.cv-layout h1,
.cv-layout h2,
.cv-layout h3,
.cv-layout h4 {
margin: 0;
}
.cv-layout h1, .cv-layout h2, .cv-layout h3, .cv-layout h4 { margin: 0; }
.cv-layout a {
color: #111;
background-color: transparent !important;
letter-spacing: normal;
color: #111;
background-color: transparent !important;
letter-spacing: normal;
}
.cv-layout input, .cv-layout textarea {
color: #111;
background-color: white;
border: 1px solid #ccc;
padding: 0;
width: auto;
.cv-layout input,
.cv-layout textarea {
color: #111;
background-color: white;
border: 1px solid #ccc;
padding: 0;
width: auto;
}
.cv-layout input::placeholder,
.cv-layout textarea::placeholder {
color: #999;
opacity: 1;
}
.cv-layout table {
border: 0 solid transparent;
}
.cv-layout tr {
border-color: transparent;
}
.cv-layout th {
border: none;
padding: 0;
}
.cv-layout td {
padding: 0;
}
.cv-layout input::placeholder, .cv-layout textarea::placeholder { color: #999; opacity: 1; }
.cv-layout table { border: 0 solid transparent; }
.cv-layout tr { border-color: transparent; }
.cv-layout th { border: none; padding: 0; }
.cv-layout td { padding: 0; }
</style>

View File

@@ -5,28 +5,28 @@ import Footer from "@/components/Footer.vue";
</script>
<template>
<div class="default-layout halftone">
<Navbar class="no-print sticky top-0 z-50" />
<main class="default-content">
<RouterView v-slot="{ Component }">
<Transition name="slide" mode="out-in">
<component :is="Component" :key="$route.path" />
</Transition>
</RouterView>
</main>
<Footer class="no-print sticky bottom-0 z-50" />
</div>
<div class="default-layout halftone">
<Navbar class="no-print sticky top-0 z-50" />
<main class="default-content">
<RouterView v-slot="{ Component }">
<Transition name="slide" mode="out-in">
<component :is="Component" :key="$route.path" />
</Transition>
</RouterView>
</main>
<Footer class="no-print sticky bottom-0 z-50" />
</div>
</template>
<style scoped>
.default-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.default-content {
flex: 1;
overflow-y: auto;
flex: 1;
overflow-y: auto;
}
</style>

View File

@@ -7,96 +7,103 @@ import { useHomeDataStore } from "@/stores/homeData";
import { useAuthStore } from "@/stores/auth";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
component: DefaultLayout,
children: [
{
path: "/",
component: DefaultLayout,
children: [
{
path: "",
name: "landing",
component: Landing,
},
{
path: "stp",
name: "home",
component: () => import("@/views/home/Home.vue"),
},
{
path: "admin/login",
name: "admin-login",
component: () => import("@/views/admin/Login.vue"),
},
{
path: "admin",
name: "admin",
component: () => import("@/views/admin/Admin.vue"),
meta: { requiresAdmin: true },
},
{
path: "shrines",
name: "shrine links",
component: () => import("@/views/home/shrines/Shrines.vue"),
},
{
path: "shrines/gto",
name: "gto shrine",
component: () => import("@/views/home/shrines/GTO.vue"),
},
{
path: "shrines/skipskipbenben",
name: "skipskipbenben shrine",
component: () => import("@/views/home/shrines/Skipskipbenben.vue"),
},
{
path: "shrines/evangelion",
name: "evangelion shrine",
component: () => import("@/views/home/shrines/Evangelion.vue"),
},
{
path: "shrines/demoman",
name: "demoman shrine",
component: () => import("@/views/home/shrines/Demoman.vue"),
},
{
path: ":pathMatch(.*)*",
name: "404",
component: () => import("@/views/404/404.vue"),
},
],
path: "",
name: "landing",
component: Landing,
},
{
path: "/cv",
component: CVLayout,
children: [
{
path: "",
name: "cv",
component: () => import("@/views/CV/CV.vue"),
},
{
path: "jobs",
name: "job-applications",
component: () => import("@/views/CV/JobApplications.vue"),
meta: { requiresAdmin: true },
},
],
path: "stp",
name: "home",
component: () => import("@/views/home/Home.vue"),
},
],
{
path: "admin/login",
name: "admin-login",
component: () => import("@/views/admin/Login.vue"),
},
{
path: "admin",
name: "admin",
component: () => import("@/views/admin/Admin.vue"),
meta: { requiresAdmin: true },
},
{
path: "shrines",
name: "shrine links",
component: () => import("@/views/home/shrines/Shrines.vue"),
},
{
path: "shrines/gto",
name: "gto shrine",
component: () => import("@/views/home/shrines/GTO.vue"),
},
{
path: "shrines/skipskipbenben",
name: "skipskipbenben shrine",
component: () => import("@/views/home/shrines/Skipskipbenben.vue"),
},
{
path: "shrines/evangelion",
name: "evangelion shrine",
component: () => import("@/views/home/shrines/Evangelion.vue"),
},
{
path: "shrines/demoman",
name: "demoman shrine",
component: () => import("@/views/home/shrines/Demoman.vue"),
},
{
path: ":pathMatch(.*)*",
name: "404",
component: () => import("@/views/404/404.vue"),
},
],
},
{
path: "/cv",
component: CVLayout,
children: [
{
path: "",
name: "cv",
component: () => import("@/views/CV/CV.vue"),
},
{
path: "jobs",
name: "job-applications",
component: () => import("@/views/CV/JobApplications.vue"),
meta: { requiresAdmin: true },
},
],
},
],
});
router.beforeEach(async (to) => {
if (!to.meta.requiresAdmin) return;
const homeData = useHomeDataStore();
if (!homeData.loaded) {
await new Promise((resolve) => {
const stop = watch(() => homeData.loaded, (val) => {
if (val) { stop(); resolve(); }
});
});
}
if (!useAuthStore().user.admin) return { path: "/admin/login", query: { redirect: to.fullPath } };
if (!to.meta.requiresAdmin) return;
const homeData = useHomeDataStore();
if (!homeData.loaded) {
await new Promise((resolve) => {
const stop = watch(
() => homeData.loaded,
(val) => {
if (val) {
stop();
resolve();
}
},
);
});
}
if (!useAuthStore().user.admin)
return { path: "/admin/login", query: { redirect: to.fullPath } };
});
export default router;

View File

@@ -1,13 +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" loading="lazy" />
</RouterLink>
<h1>Click her, she will take you home</h1>
</div>
</main>
<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" loading="lazy" />
</RouterLink>
<h1>Click her, she will take you home</h1>
</div>
</main>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,17 @@
<script setup></script>
<template>
<div class="flex flex-col sm:flex-row">
<div class="sm:w-2/7 mt-auto mb-auto ml-0">
<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 class="flex flex-col sm:flex-row">
<div class="sm:w-2/7 mt-auto mb-auto ml-0">
<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>

View File

@@ -9,15 +9,15 @@ import ManageRadio from "./ManageRadio.vue";
</script>
<template>
<main class="justify-center flex flex-row w-full h-full">
<div class="bdr-1 flex flex-col">
<CreateUser class="bdr-2 bg-bg_primary" />
<CreatePost class="bdr-2 bg-bg_primary" />
<CreateFavorite class="bdr-2 bg-bg_primary" />
<CreateActivity class="bdr-2 bg-bg_primary" />
<CreateRowing class="bdr-2 bg-bg_primary" />
<ManageUsers class="bdr-2 bg-bg_primary" />
<ManageRadio class="bdr-2 bg-bg_primary" />
</div>
</main>
<main class="justify-center flex flex-row w-full h-full">
<div class="bdr-1 flex flex-col">
<CreateUser class="bdr-2 bg-bg_primary" />
<CreatePost class="bdr-2 bg-bg_primary" />
<CreateFavorite class="bdr-2 bg-bg_primary" />
<CreateActivity class="bdr-2 bg-bg_primary" />
<CreateRowing class="bdr-2 bg-bg_primary" />
<ManageUsers class="bdr-2 bg-bg_primary" />
<ManageRadio class="bdr-2 bg-bg_primary" />
</div>
</main>
</template>

View File

@@ -11,29 +11,35 @@ 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);
emit("done");
} catch (err) {
console.error(err);
}
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);
emit("done");
} 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>
<Button @click="emit('cancel')">Cancel</Button>
</div>
<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>
<Button @click="emit('cancel')">Cancel</Button>
</div>
</template>

View File

@@ -10,28 +10,40 @@ const name = ref("");
const link = ref("");
async function submit() {
try {
await gql(
`mutation CreateBookmark($input: CreateBookmarkInput!) { createBookmark(input: $input) { id } }`,
{ input: { category: category.value, name: name.value, link: link.value } },
);
category.value = "";
name.value = "";
link.value = "";
emit("done");
} catch (err) {
console.error(err);
}
try {
await gql(
`mutation CreateBookmark($input: CreateBookmarkInput!) { createBookmark(input: $input) { id } }`,
{
input: { category: category.value, name: name.value, link: link.value },
},
);
category.value = "";
name.value = "";
link.value = "";
emit("done");
} catch (err) {
console.error(err);
}
}
</script>
<template>
<div class="flex flex-col">
<h1>Create Bookmark</h1>
<input type="text" v-model="category" placeholder="Category" />
<input type="text" v-model="name" placeholder="Name" @keyup.enter="submit" />
<input type="text" v-model="link" placeholder="Link" @keyup.enter="submit" />
<Button @click="submit">Upload</Button>
<Button @click="emit('cancel')">Cancel</Button>
</div>
<div class="flex flex-col">
<h1>Create Bookmark</h1>
<input type="text" v-model="category" placeholder="Category" />
<input
type="text"
v-model="name"
placeholder="Name"
@keyup.enter="submit"
/>
<input
type="text"
v-model="link"
placeholder="Link"
@keyup.enter="submit"
/>
<Button @click="submit">Upload</Button>
<Button @click="emit('cancel')">Cancel</Button>
</div>
</template>

View File

@@ -11,29 +11,35 @@ 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);
emit("done");
} catch (err) {
console.error(err);
}
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);
emit("done");
} 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>
<Button @click="emit('cancel')">Cancel</Button>
</div>
<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>
<Button @click="emit('cancel')">Cancel</Button>
</div>
</template>

View File

@@ -9,31 +9,32 @@ 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);
emit("done");
} catch (err) {
console.error(err);
}
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);
emit("done");
} 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>
<Button @click="emit('cancel')">Cancel</Button>
</div>
<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>
<Button @click="emit('cancel')">Cancel</Button>
</div>
</template>

View File

@@ -9,47 +9,60 @@ const images = ref([]);
const results = ref([]);
function onFileChange(e) {
images.value = Array.from(e.target.files);
results.value = [];
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..." }));
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;
}
})
);
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 = [];
emit("done");
images.value = [];
emit("done");
}
</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>
<Button @click="emit('cancel')">Cancel</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 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>
<Button @click="emit('cancel')">Cancel</Button>
<div v-for="r in results" :key="r.name">
<span class="text-primary">{{ r.name }}: </span>
<span :class="r.ok ? 'text-secondary' : 'text-red-500'">{{
r.status
}}</span>
</div>
</div>
</template>

View File

@@ -11,35 +11,45 @@ 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.";
}
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>
<div v-if="auth.loggedIn && auth.user.admin" class="flex flex-col">
<h1>Create User</h1>
<p v-if="message" class="text-green-500">{{ message }}</p>
<p v-if="error" class="text-red-500">{{ error }}</p>
<input
type="text"
v-model="username"
placeholder="Username"
@keyup.enter="handleCreate"
/>
<input
type="password"
v-model="password"
placeholder="Password"
@keyup.enter="handleCreate"
/>
<Button @click="handleCreate">Create Account</Button>
</div>
<div v-else-if="auth.loggedIn" class="flex flex-col">
<p>You do not have permission to create users.</p>
</div>
<div v-else class="flex flex-col">
<p>You must be logged in as an admin to create users.</p>
</div>
</template>

View File

@@ -12,29 +12,39 @@ const username = ref("");
const password = ref("");
async function handleLogin() {
await auth.logIn(username.value, password.value);
if (auth.loggedIn && route.query.redirect) {
router.push(route.query.redirect);
}
await auth.logIn(username.value, password.value);
if (auth.loggedIn && route.query.redirect) {
router.push(route.query.redirect);
}
}
function handleLogout() {
auth.logOut();
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>
<div v-if="auth.loggedIn" class="flex flex-col">
<h1>Logged in</h1>
<p>{{ auth.user.id }}</p>
<p>{{ auth.user.username }}</p>
<p>{{ auth.user.admin }}</p>
<Button @click="handleLogout">Log Out</Button>
</div>
<div v-else class="flex flex-col">
<h1>Login</h1>
<input
type="text"
v-model="username"
placeholder="Username"
@keyup.enter="handleLogin"
/>
<input
type="password"
v-model="password"
placeholder="Password"
@keyup.enter="handleLogin"
/>
<Button @click="handleLogin">Log In</Button>
</div>
</template>

View File

@@ -9,94 +9,108 @@ const results = ref([]);
const loading = ref(false);
async function fetchSongs() {
try {
const res = await axios.get("/api/radio/songs");
songs.value = res.data.songs;
} catch (err) {
console.error(err);
}
try {
const res = await axios.get("/api/radio/songs");
songs.value = res.data.songs;
} catch (err) {
console.error(err);
}
}
function onFileChange(e) {
files.value = Array.from(e.target.files);
results.value = [];
files.value = Array.from(e.target.files);
results.value = [];
}
async function upload() {
if (!files.value.length) return;
loading.value = true;
results.value = files.value.map((f) => ({ name: f.name, status: "Uploading..." }));
if (!files.value.length) return;
loading.value = true;
results.value = files.value.map((f) => ({
name: f.name,
status: "Uploading...",
}));
await Promise.all(
files.value.map(async (file, i) => {
const formData = new FormData();
formData.append("file", file);
try {
await axios.post("/api/radio/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
results.value[i].status = "Uploaded";
results.value[i].ok = true;
} catch (err) {
results.value[i].status = err.response?.data?.error || "Upload failed";
results.value[i].ok = false;
}
})
);
await Promise.all(
files.value.map(async (file, i) => {
const formData = new FormData();
formData.append("file", file);
try {
await axios.post("/api/radio/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
results.value[i].status = "Uploaded";
results.value[i].ok = true;
} catch (err) {
results.value[i].status = err.response?.data?.error || "Upload failed";
results.value[i].ok = false;
}
}),
);
files.value = [];
loading.value = false;
await fetchSongs();
files.value = [];
loading.value = false;
await fetchSongs();
}
async function deleteSong(name) {
try {
await axios.delete(`/api/radio/songs/${encodeURIComponent(name)}`);
await fetchSongs();
} catch (err) {
console.error(err);
}
try {
await axios.delete(`/api/radio/songs/${encodeURIComponent(name)}`);
await fetchSongs();
} catch (err) {
console.error(err);
}
}
async function toggleSong(song) {
const action = song.disabled ? "enable" : "disable";
try {
await axios.patch(`/api/radio/songs/${encodeURIComponent(song.name)}/${action}`);
await fetchSongs();
} catch (err) {
console.error(err);
}
const action = song.disabled ? "enable" : "disable";
try {
await axios.patch(
`/api/radio/songs/${encodeURIComponent(song.name)}/${action}`,
);
await fetchSongs();
} catch (err) {
console.error(err);
}
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
}
onMounted(fetchSongs);
</script>
<template>
<div class="flex flex-col gap-2">
<h1>Manage Radio</h1>
<input type="file" accept=".mp3,.ogg,.flac,.wav,.m4a,.opus" multiple @change="onFileChange" />
<Button @click="upload" :disabled="loading">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
v-for="song in songs"
:key="song.name"
class="flex flex-row items-center gap-2"
:class="{ 'opacity-50': song.disabled }"
>
<span :class="{ 'line-through': song.disabled }">{{ song.name }}</span>
<span class="text-secondary text-sm">{{ formatSize(song.size) }}</span>
<span v-if="song.disabled" class="text-red-400 text-xs">disabled</span>
<Button @click="toggleSong(song)">{{ song.disabled ? "Enable" : "Disable" }}</Button>
<Button @click="deleteSong(song.name)">Delete</Button>
</div>
<div class="flex flex-col gap-2">
<h1>Manage Radio</h1>
<input
type="file"
accept=".mp3,.ogg,.flac,.wav,.m4a,.opus"
multiple
@change="onFileChange"
/>
<Button @click="upload" :disabled="loading">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
v-for="song in songs"
:key="song.name"
class="flex flex-row items-center gap-2"
:class="{ 'opacity-50': song.disabled }"
>
<span :class="{ 'line-through': song.disabled }">{{ song.name }}</span>
<span class="text-secondary text-sm">{{ formatSize(song.size) }}</span>
<span v-if="song.disabled" class="text-red-400 text-xs">disabled</span>
<Button @click="toggleSong(song)">{{
song.disabled ? "Enable" : "Disable"
}}</Button>
<Button @click="deleteSong(song.name)">Delete</Button>
</div>
</div>
</template>

View File

@@ -8,38 +8,39 @@ 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);
}
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);
}
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 class="flex flex-col">
<h1>Manage Users</h1>
<div
v-for="user in users"
:key="user.id"
class="flex flex-row items-center gap-2"
>
<span>{{ user.username }}</span>
<span v-if="user.admin">(admin)</span>
<Button v-if="user.id !== auth.user.id" @click="toggleAdmin(user)">
{{ user.admin ? "Demote" : "Promote" }}
</Button>
</div>
</div>
</template>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useTemplateRef, ref, onMounted, onUnmounted } from 'vue';
import { useTemplateRef, ref, onMounted, onUnmounted } from "vue";
const display = useTemplateRef('display')
const display = useTemplateRef("display");
const displayText = ref("");
const charHeight: number = 14;
@@ -10,33 +10,37 @@ let n: number;
let m: number;
function setup() {
display.value.style.fontSize = `${charHeight}px`;
display.value.style.lineHeight = `${charHeight}px`;
fillDisplay()
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
// 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 = ""
displayText.value = "";
}
onMounted(() => {
setup()
})
setup();
});
onUnmounted(() => {
close()
})
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>
<pre
class="overflow-scroll w-full h-full bg-black text-white m-0 p-0"
id="container"
ref="display"
>{{ displayText }}</pre
>
</template>

View File

@@ -5,7 +5,9 @@ import Header from "@/components/text/Header.vue";
import { useHomeDataStore } from "@/stores/homeData";
import { useAuthStore } from "@/stores/auth";
const CreateBookmark = defineAsyncComponent(() => import("@/views/admin/CreateBookmark.vue"));
const CreateBookmark = defineAsyncComponent(
() => import("@/views/admin/CreateBookmark.vue"),
);
const homeData = useHomeDataStore();
const authStore = useAuthStore();
@@ -13,48 +15,57 @@ const authStore = useAuthStore();
const showCreate = ref(false);
const groupedBookmarks = computed(() => {
const groups = {};
for (const b of homeData.bookmarks) {
if (!groups[b.category]) groups[b.category] = [];
groups[b.category].push(b);
}
return Object.entries(groups);
const groups = {};
for (const b of homeData.bookmarks) {
if (!groups[b.category]) groups[b.category] = [];
groups[b.category].push(b);
}
return Object.entries(groups);
});
</script>
<template>
<div class="bookmarks-wrapper">
<Header class="text-left">
<span class="flex items-center justify-between w-full">
{{ showCreate ? "Create Bookmark" : "Bookmarks" }}
<button v-if="authStore.user.admin" class="text-sm px-1" @click="showCreate = !showCreate">
{{ showCreate ? "x" : "+" }}
</button>
</span>
</Header>
<CreateBookmark v-if="showCreate" class="flex-1 min-h-0 p-1" @done="showCreate = false" @cancel="showCreate = false" />
<div v-if="!showCreate" class="bookmarks-scroll">
<LinkTable
v-for="group in groupedBookmarks"
:key="group[0]"
:title="group[0]"
:items="group[1]"
/>
</div>
<div class="bookmarks-wrapper">
<Header class="text-left">
<span class="flex items-center justify-between w-full">
{{ showCreate ? "Create Bookmark" : "Bookmarks" }}
<button
v-if="authStore.user.admin"
class="text-sm px-1"
@click="showCreate = !showCreate"
>
{{ showCreate ? "x" : "+" }}
</button>
</span>
</Header>
<CreateBookmark
v-if="showCreate"
class="flex-1 min-h-0 p-1"
@done="showCreate = false"
@cancel="showCreate = false"
/>
<div v-if="!showCreate" class="bookmarks-scroll">
<LinkTable
v-for="group in groupedBookmarks"
:key="group[0]"
:title="group[0]"
:items="group[1]"
/>
</div>
</div>
</template>
<style scoped>
.bookmarks-wrapper {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
.bookmarks-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
flex: 1;
min-height: 0;
overflow-y: auto;
}
</style>

View File

@@ -2,14 +2,14 @@
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: "床" },
{ 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" />
<Slideshow :images="images" />
</template>

View File

@@ -7,7 +7,9 @@ import { ref, defineAsyncComponent } from "vue";
import { useActivityStore } from "@/stores/activity";
import { useAuthStore } from "@/stores/auth";
const CreateActivity = defineAsyncComponent(() => import("@/views/admin/CreateActivity.vue"));
const CreateActivity = defineAsyncComponent(
() => import("@/views/admin/CreateActivity.vue"),
);
const activityStore = useActivityStore();
const authStore = useAuthStore();
@@ -15,18 +17,31 @@ const showCreate = ref(false);
</script>
<template>
<div class="flex flex-col items-center">
<Header>
<span class="flex items-center justify-between w-full">
{{ showCreate ? "Create Activity" : "Consumption" }}
<button v-if="authStore.user.admin" class="text-sm px-1" @click="showCreate = !showCreate">
{{ showCreate ? "x" : "+" }}
</button>
</span>
</Header>
<CreateActivity v-if="showCreate" class="flex-1 w-full p-1" @done="showCreate = false" @cancel="showCreate = false" />
<AutoScroll v-if="!showCreate" class="flex-1 w-full">
<LinkTable variant="table" class="w-full" :items="activityStore.activity" />
</AutoScroll>
</div>
<div class="flex flex-col items-center">
<Header>
<span class="flex items-center justify-between w-full">
{{ showCreate ? "Create Activity" : "Consumption" }}
<button
v-if="authStore.user.admin"
class="text-sm px-1"
@click="showCreate = !showCreate"
>
{{ showCreate ? "x" : "+" }}
</button>
</span>
</Header>
<CreateActivity
v-if="showCreate"
class="flex-1 w-full p-1"
@done="showCreate = false"
@cancel="showCreate = false"
/>
<AutoScroll v-if="!showCreate" class="flex-1 w-full">
<LinkTable
variant="table"
class="w-full"
:items="activityStore.activity"
/>
</AutoScroll>
</div>
</template>

View File

@@ -7,7 +7,9 @@ import { ref, defineAsyncComponent } from "vue";
import { useFavoritesStore } from "@/stores/favorites";
import { useAuthStore } from "@/stores/auth";
const CreateFavorite = defineAsyncComponent(() => import("@/views/admin/CreateFavorite.vue"));
const CreateFavorite = defineAsyncComponent(
() => import("@/views/admin/CreateFavorite.vue"),
);
const favoritesStore = useFavoritesStore();
const authStore = useAuthStore();
@@ -15,22 +17,31 @@ const showCreate = ref(false);
</script>
<template>
<div class="flex flex-col items-center">
<Header>
<span class="flex items-center justify-between w-full">
{{ showCreate ? "Create Favorite" : "favs" }}
<button v-if="authStore.user.admin" class="text-sm px-1" @click="showCreate = !showCreate">
{{ showCreate ? "x" : "+" }}
</button>
</span>
</Header>
<CreateFavorite v-if="showCreate" class="w-full flex-1 p-1" @done="showCreate = false" @cancel="showCreate = false" />
<AutoScroll v-if="!showCreate" class="w-full flex-1">
<LinkTable
variant="table"
class="w-full"
:items="favoritesStore.favorites"
/>
</AutoScroll>
</div>
<div class="flex flex-col items-center">
<Header>
<span class="flex items-center justify-between w-full">
{{ showCreate ? "Create Favorite" : "favs" }}
<button
v-if="authStore.user.admin"
class="text-sm px-1"
@click="showCreate = !showCreate"
>
{{ showCreate ? "x" : "+" }}
</button>
</span>
</Header>
<CreateFavorite
v-if="showCreate"
class="w-full flex-1 p-1"
@done="showCreate = false"
@cancel="showCreate = false"
/>
<AutoScroll v-if="!showCreate" class="w-full flex-1">
<LinkTable
variant="table"
class="w-full"
:items="favoritesStore.favorites"
/>
</AutoScroll>
</div>
</template>

View File

@@ -7,7 +7,9 @@ import { ref, computed, defineAsyncComponent } from "vue";
import { useAuthStore } from "@/stores/auth";
import { usePostsStore } from "@/stores/posts";
const CreatePost = defineAsyncComponent(() => import("@/views/admin/CreatePost.vue"));
const CreatePost = defineAsyncComponent(
() => import("@/views/admin/CreatePost.vue"),
);
const authStore = useAuthStore();
const postsStore = usePostsStore();
@@ -20,75 +22,70 @@ 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,
() => post.value.author.username == authStore.user.username,
);
function nextPost() {
if (idx.value < postsStore.postsCount - 1) {
idx.value++;
}
if (idx.value < postsStore.postsCount - 1) {
idx.value++;
}
}
function prevPost() {
if (idx.value > 0) {
idx.value--;
}
if (idx.value > 0) {
idx.value--;
}
}
function deletePost() {
postsStore.deletePost(post.value);
postsStore.deletePost(post.value);
}
</script>
<template>
<div class="flex flex-col flex-1 min-h-0">
<Header>
<span class="flex items-center justify-between w-full">
{{ showCreate ? "Create Post" : post.title }}
<button v-if="authStore.user.admin" class="text-sm px-1" @click="showCreate = !showCreate">
{{ showCreate ? "x" : "+" }}
</button>
</span>
</Header>
<CreatePost v-if="showCreate" class="flex-1 min-h-0 p-1" @done="showCreate = false" @cancel="showCreate = false" />
<div
v-if="!showCreate"
class="flex flex-col flex-1 min-h-0 p-1 overflow-auto text-left items-start justify-start"
<div class="flex flex-col flex-1 min-h-0">
<Header>
<span class="flex items-center justify-between w-full">
{{ showCreate ? "Create Post" : post.title }}
<button
v-if="authStore.user.admin"
class="text-sm px-1"
@click="showCreate = !showCreate"
>
<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>
{{ showCreate ? "x" : "+" }}
</button>
</span>
</Header>
<CreatePost
v-if="showCreate"
class="flex-1 min-h-0 p-1"
@done="showCreate = false"
@cancel="showCreate = false"
/>
<div
v-if="!showCreate"
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%;
width: 100%;
}
</style>

View File

@@ -2,25 +2,25 @@
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" },
{ 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 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%;
width: 100%;
}
</style>

View File

@@ -5,7 +5,9 @@ import { useHomeDataStore } from "@/stores/homeData";
import { useAuthStore } from "@/stores/auth";
import { storeToRefs } from "pinia";
const CreateRowing = defineAsyncComponent(() => import("@/views/admin/CreateRowing.vue"));
const CreateRowing = defineAsyncComponent(
() => import("@/views/admin/CreateRowing.vue"),
);
const store = useHomeDataStore();
const authStore = useAuthStore();
@@ -117,13 +119,22 @@ function formatValue(key, val) {
<Header>
<span class="flex items-center justify-between w-full">
{{ showCreate ? "Upload Rowing" : "Rowing" }}
<button v-if="authStore.user.admin" class="text-sm px-1" @click="showCreate = !showCreate">
<button
v-if="authStore.user.admin"
class="text-sm px-1"
@click="showCreate = !showCreate"
>
{{ showCreate ? "x" : "+" }}
</button>
</span>
</Header>
<CreateRowing v-if="showCreate" class="flex-1 p-1" @done="showCreate = false" @cancel="showCreate = false" />
<CreateRowing
v-if="showCreate"
class="flex-1 p-1"
@done="showCreate = false"
@cancel="showCreate = false"
/>
<div v-else-if="loading" class="flex-1 flex items-center justify-center">
<p>Loading...</p>
</div>

View File

@@ -25,223 +25,219 @@ import Bookmarks from "./Bookmarks.vue";
</script>
<template>
<main class="justify-center flex flex-row w-full h-full overflow-x-hidden">
<div class="outerWrap flex flex-row">
<div class="sidebar">
<Time class="time-sidebar sidebar-cell" />
<Timer class="timer-sidebar sidebar-cell" />
<Radio class="radio-sidebar sidebar-cell" />
<CommitHistory class="commits-sidebar flex-1 sidebar-cell" />
<main class="justify-center flex flex-row w-full h-full overflow-x-hidden">
<div class="outerWrap flex flex-row">
<div class="sidebar">
<Time class="time-sidebar sidebar-cell" />
<Timer class="timer-sidebar sidebar-cell" />
<Radio class="radio-sidebar sidebar-cell" />
<CommitHistory class="commits-sidebar flex-1 sidebar-cell" />
<!-- <Elle class="flex-1" /> -->
<!-- <MusicPlayer /> -->
<img
src="/img/memes/fire-woman.gif"
alt=""
width="178"
height="178"
class="border-tertiary border-2 sidebar-image box-border w-full bg-tertiary"
loading="lazy"
/>
</div>
<div
class="a4page-portrait homeGrid relative bdr-1 border-quaternary"
>
<!-- <Intro class="intro" /> -->
<Intro2 class="intro grid-cell" />
<!-- <BadApple class="intro" /> -->
<Listening class="listening grid-cell" />
<Stamps class="stamps grid-cell" />
<Feed class="feed grid-cell" />
<Links class="links grid-cell" />
<Collage class="collage grid-cell" />
<Consumption class="consumption grid-cell" />
<Favorites class="favorites grid-cell" />
<!-- <Gym class="gym" /> -->
<Gym2 class="gym grid-cell" />
</div>
<div class="sidebar">
<Steam class="steam-sidebar sidebar-cell" />
<Bookmarks class="bookmarks-sidebar sidebar-cell" />
<Chat
class="chat-sidebar flex-1 min-h-0 chat-home sidebar-cell"
/>
<Miku
class="sidebar-image miku-image box-border border-tertiary border-2 bg-bg_primary"
/>
</div>
</div>
</main>
<!-- <Elle class="flex-1" /> -->
<!-- <MusicPlayer /> -->
<img
src="/img/memes/fire-woman.gif"
alt=""
width="178"
height="178"
class="border-tertiary border-2 sidebar-image box-border w-full bg-tertiary"
loading="lazy"
/>
</div>
<div class="a4page-portrait homeGrid relative bdr-1 border-quaternary">
<!-- <Intro class="intro" /> -->
<Intro2 class="intro grid-cell" />
<!-- <BadApple class="intro" /> -->
<Listening class="listening grid-cell" />
<Stamps class="stamps grid-cell" />
<Feed class="feed grid-cell" />
<Links class="links grid-cell" />
<Collage class="collage grid-cell" />
<Consumption class="consumption grid-cell" />
<Favorites class="favorites grid-cell" />
<!-- <Gym class="gym" /> -->
<Gym2 class="gym grid-cell" />
</div>
<div class="sidebar">
<Steam class="steam-sidebar sidebar-cell" />
<Bookmarks class="bookmarks-sidebar sidebar-cell" />
<Chat class="chat-sidebar flex-1 min-h-0 chat-home sidebar-cell" />
<Miku
class="sidebar-image miku-image box-border border-tertiary border-2 bg-bg_primary"
/>
</div>
</div>
</main>
</template>
<style scoped>
.grid-cell {
background-color: var(--bg_primary);
border-width: 2px;
border-style: solid;
border-color: var(--quaternary);
background-color: var(--bg_primary);
border-width: 2px;
border-style: solid;
border-color: var(--quaternary);
}
.intro {
grid-area: intro;
grid-area: intro;
}
.listening {
grid-area: listening;
grid-area: listening;
}
.stamps {
grid-area: stamps;
grid-area: stamps;
}
.feed {
grid-area: feed;
grid-area: feed;
}
.links {
grid-area: links;
grid-area: links;
}
.collage {
grid-area: collage;
grid-area: collage;
}
.consumption {
grid-area: consumption;
grid-area: consumption;
}
.gym {
grid-area: gym;
grid-area: gym;
}
.favorites {
grid-area: favorites;
grid-area: favorites;
}
.sidebar-cell {
background-color: var(--bg_primary);
border-width: 2px;
border-style: solid;
border-color: var(--quaternary);
background-color: var(--bg_primary);
border-width: 2px;
border-style: solid;
border-color: var(--quaternary);
}
.sidebar {
margin: 0;
flex: 1;
display: flex;
flex-direction: column;
width: 15rem;
min-height: 0;
gap: 5px;
margin: 0;
flex: 1;
display: flex;
flex-direction: column;
width: 15rem;
min-height: 0;
gap: 5px;
}
.outerWrap {
height: 310mm;
gap: 10px;
height: 310mm;
gap: 10px;
}
.homeGrid {
display: grid;
gap: 5px;
grid-template-columns: repeat(10, 1fr);
grid-template-rows: repeat(10, 1fr);
grid-template-areas:
"intro intro intro intro intro intro listening listening listening listening"
"intro intro intro intro intro intro listening listening listening listening"
"intro intro intro intro intro intro listening listening listening listening"
"intro intro intro intro intro intro stamps stamps stamps stamps"
"feed feed feed links links collage collage collage collage collage"
"feed feed feed links links collage collage collage collage collage"
"feed feed feed links links collage collage collage collage collage"
"feed feed feed links links collage collage collage collage collage"
"consumption consumption consumption consumption gym gym gym favorites favorites favorites"
"consumption consumption consumption consumption gym gym gym favorites favorites favorites";
display: grid;
gap: 5px;
grid-template-columns: repeat(10, 1fr);
grid-template-rows: repeat(10, 1fr);
grid-template-areas:
"intro intro intro intro intro intro listening listening listening listening"
"intro intro intro intro intro intro listening listening listening listening"
"intro intro intro intro intro intro listening listening listening listening"
"intro intro intro intro intro intro stamps stamps stamps stamps"
"feed feed feed links links collage collage collage collage collage"
"feed feed feed links links collage collage collage collage collage"
"feed feed feed links links collage collage collage collage collage"
"feed feed feed links links collage collage collage collage collage"
"consumption consumption consumption consumption gym gym gym favorites favorites favorites"
"consumption consumption consumption consumption gym gym gym favorites favorites favorites";
}
.chat-home {
max-height: 800px;
max-height: 800px;
}
.miku-image {
height: 15rem;
height: 15rem;
}
@media (max-width: 1360px) {
.outerWrap {
flex-direction: column;
align-items: center;
height: auto;
}
.outerWrap {
flex-direction: column;
align-items: center;
height: auto;
}
.homeGrid {
order: -1;
width: 95vw;
height: 297mm;
margin-inline: 0;
box-sizing: border-box;
}
.homeGrid {
order: -1;
width: 95vw;
height: 297mm;
margin-inline: 0;
box-sizing: border-box;
}
.sidebar {
width: 95vw;
flex-direction: column;
align-items: center;
flex: unset;
justify-content: space-around;
}
.sidebar {
width: 95vw;
flex-direction: column;
align-items: center;
flex: unset;
justify-content: space-around;
}
.commits-sidebar,
.steam-sidebar,
.bookmarks-sidebar {
width: 100%;
max-height: 300px;
}
.commits-sidebar,
.steam-sidebar,
.bookmarks-sidebar {
width: 100%;
max-height: 300px;
}
.chat-sidebar {
width: 100%;
min-height: 400px;
max-height: 800px;
height: 25vh;
}
.chat-sidebar {
width: 100%;
min-height: 400px;
max-height: 800px;
height: 25vh;
}
.time-sidebar,
.sidebar-image,
.radio-sidebar,
.timer-sidebar {
display: none;
}
.time-sidebar,
.sidebar-image,
.radio-sidebar,
.timer-sidebar {
display: none;
}
/* .sidebar-image { */
/* max-height: 150px; */
/* max-width: 150px; */
/* object-fit: contain; */
/* } */
/* .sidebar-image { */
/* max-height: 150px; */
/* max-width: 150px; */
/* object-fit: contain; */
/* } */
}
@media (max-width: 700px) {
.homeGrid {
border-image: none;
border-width: 0;
grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: repeat(11, 1fr);
height: 150vh;
grid-template-areas:
"intro intro intro"
"intro intro intro"
"listening stamps stamps"
"listening feed feed"
"links feed feed"
"links feed feed"
"links feed feed"
"favorites feed feed"
"favorites consumption consumption"
"favorites consumption consumption"
"favorites consumption consumption";
}
.homeGrid {
border-image: none;
border-width: 0;
grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: repeat(11, 1fr);
height: 150vh;
grid-template-areas:
"intro intro intro"
"intro intro intro"
"listening stamps stamps"
"listening feed feed"
"links feed feed"
"links feed feed"
"links feed feed"
"favorites feed feed"
"favorites consumption consumption"
"favorites consumption consumption"
"favorites consumption consumption";
}
.collage {
display: none;
}
.collage {
display: none;
}
.gym {
display: none;
}
.gym {
display: none;
}
}
.bg-random {
background-color: var(--bg_primary);
background-image: url("/img/miku/miku2.gif");
background-size: 10px 10px;
background-color: var(--bg_primary);
background-image: url("/img/miku/miku2.gif");
background-size: 10px 10px;
}
</style>

View File

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

View File

@@ -3,46 +3,46 @@ import { rand } from "@vueuse/core";
import { ref, onMounted, onUnmounted, nextTick } from "vue";
interface Item {
x: number;
y: number;
dx: number;
dy: number;
content: string;
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",
"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: 0,
y: i * 20,
dx: rand(0, 60) / 100,
dy: 1.0,
content: text,
cachedW: 0,
cachedH: 0,
x: 0,
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,
})),
animState.map((s) => ({
x: s.x,
y: s.y,
dx: s.dx,
dy: s.dy,
content: s.content,
})),
);
let rafId = 0;
@@ -52,79 +52,76 @@ let lastFrameTime = 0;
const FRAME_INTERVAL = 1000 / 30;
function measureSizes() {
const c = container.value;
if (c) {
cachedCW = c.clientWidth;
cachedCH = c.clientHeight;
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;
}
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)`;
}
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);
await nextTick();
measureSizes();
rafId = requestAnimationFrame(animate);
resizeObserver = new ResizeObserver(measureSizes);
resizeObserver.observe(container.value!);
resizeObserver = new ResizeObserver(measureSizes);
resizeObserver.observe(container.value!);
});
onUnmounted(() => {
cancelAnimationFrame(rafId);
resizeObserver?.disconnect();
cancelAnimationFrame(rafId);
resizeObserver?.disconnect();
});
</script>
<template>
<div ref="container" class="w-full h-full relative overflow-hidden">
<div
ref="container"
class="w-full h-full relative overflow-hidden"
v-for="(item, i) in items"
:key="i"
ref="itemEls"
class="absolute w-fit h-fit"
>
<div
v-for="(item, i) in items"
:key="i"
ref="itemEls"
class="absolute w-fit h-fit"
>
<h1>
{{ item.content }}
</h1>
</div>
<h1>
{{ item.content }}
</h1>
</div>
</div>
</template>

View File

@@ -4,31 +4,31 @@ import LinkTable from "@/components/util/LinkTable.vue";
import Header from "@/components/text/Header.vue";
const site_links = [
{ name: "CV", link: "/cv" },
{ name: "Bookmarks", link: "/bookmarks" },
{ name: "Shrines", link: "/shrines" },
{ name: "Admin", link: "/admin" },
{ name: "CV", link: "/cv" },
{ name: "Bookmarks", link: "/bookmarks" },
{ name: "Shrines", link: "/shrines" },
{ name: "Admin", link: "/admin" },
];
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" },
{ name: "Notes", link: "/notes/" },
{ 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" },
{ name: "Notes", link: "/notes/" },
];
</script>
<template>
<div class="flex flex-col overflow-auto">
<Header>Links</Header>
<div class="flex flex-col justify-between flex-1">
<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>
<div class="flex flex-col overflow-auto">
<Header>Links</Header>
<div class="flex flex-col justify-between flex-1">
<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>
</div>
</template>

View File

@@ -11,75 +11,76 @@ let nextId = null;
let refreshId = null;
function nextSong() {
clearTimeout(nextId);
nextId = setTimeout(nextSong, 5000);
idx.value = (idx.value + 1) % songsStore.songsCount;
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);
songsStore.fetchSongs();
nextId = setTimeout(nextSong, 5000);
refreshId = setInterval(songsStore.fetchSongs, 120000);
});
onUnmounted(() => {
clearTimeout(nextId);
clearInterval(refreshId);
clearTimeout(nextId);
clearInterval(refreshId);
});
</script>
<template>
<div class="listening-wrapper">
<div class="listening-wrapper">
<Transition name="fade">
<div
@click="nextSong"
:key="song.track.name"
class="flex flex-col items-center pt-2"
>
<Header>Listening To</Header>
<img :src="song.track.album.images[0].url" :alt="song.track.album.name + ' album art'" />
<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>
<div
@click="nextSong"
:key="song.track.name"
class="flex flex-col items-center pt-2"
>
<Header>Listening To</Header>
<img
:src="song.track.album.images[0].url"
:alt="song.track.album.name + ' album art'"
/>
<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>
</div>
</template>
<style scoped>
.listening-wrapper {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
overflow-y: auto;
position: relative;
width: 100%;
height: 100%;
min-width: 0;
overflow-y: auto;
}
img {
width: 70%;
max-width: 100%;
height: auto;
width: 70%;
max-width: 100%;
height: auto;
}
p {
width: 100%;
margin: 0 auto;
width: 100%;
margin: 0 auto;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
transition: opacity 0.5s ease;
}
.fade-leave-active {
position: absolute;
top: 0;
left: 0;
right: 0;
position: absolute;
top: 0;
left: 0;
right: 0;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
opacity: 0;
}
</style>

View File

@@ -2,12 +2,12 @@
import Slideshow from "@/components/util/Slideshow.vue";
const images = [
{ url: "/img/miku/miku1.gif" },
{ url: "/img/miku/miku2.gif" },
// { url: "/img/miku/miku2.png" },
{ 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" />
<Slideshow class="p-5" :images="images" :interval="10000" />
</template>

View File

@@ -6,20 +6,20 @@ 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",
"/img/stamps/demo.gif",
"/img/stamps/demo.gif",
"/img/stamps/demo.gif",
"/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",
"/img/stamps/demo.gif",
"/img/stamps/demo.gif",
"/img/stamps/demo.gif",
];
shuffleArray(srcs);
@@ -31,71 +31,76 @@ let dx = 0.2;
let dy = 0.12;
function bounce() {
const el = touchscreen.value?.$el;
if (!el) return;
const el = touchscreen.value?.$el;
if (!el) return;
const maxX = el.scrollWidth - el.clientWidth;
const maxY = el.scrollHeight - el.clientHeight;
const maxX = el.scrollWidth - el.clientWidth;
const maxY = el.scrollHeight - el.clientHeight;
if (maxX > 0) {
posX += dx;
if (posX <= 0) {
posX = 0;
dx = -dx;
} else if (posX >= maxX) {
posX = maxX;
dx = -dx;
}
el.scrollLeft = posX;
if (maxX > 0) {
posX += dx;
if (posX <= 0) {
posX = 0;
dx = -dx;
} else if (posX >= maxX) {
posX = maxX;
dx = -dx;
}
if (maxY > 0) {
posY += dy;
if (posY <= 0) {
posY = 0;
dy = -dy;
} else if (posY >= maxY) {
posY = maxY;
dy = -dy;
}
el.scrollTop = posY;
el.scrollLeft = posX;
}
if (maxY > 0) {
posY += dy;
if (posY <= 0) {
posY = 0;
dy = -dy;
} else if (posY >= maxY) {
posY = maxY;
dy = -dy;
}
el.scrollTop = posY;
}
animId = requestAnimationFrame(bounce);
animId = requestAnimationFrame(bounce);
}
onMounted(() => {
animId = requestAnimationFrame(bounce);
animId = requestAnimationFrame(bounce);
});
onUnmounted(() => {
if (animId) cancelAnimationFrame(animId);
if (animId) cancelAnimationFrame(animId);
});
</script>
<template>
<Touchscreen ref="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" alt="adam-french.co.uk" />
</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" :alt="src.split('/').pop().split('.')[0]" loading="lazy" />
</div>
</Touchscreen>
<Touchscreen ref="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"
alt="adam-french.co.uk"
/>
</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"
:alt="src.split('/').pop().split('.')[0]"
loading="lazy"
/>
</div>
</Touchscreen>
</template>
<style scoped>
img {
width: 89px;
height: 59px;
width: 89px;
height: 59px;
}
.tst {
min-width: calc(89px * 4);
min-width: calc(89px * 4);
}
</style>

View File

@@ -17,116 +17,116 @@ let nextId = null;
let refreshId = null;
function nextGame() {
clearTimeout(nextId);
nextId = setTimeout(nextGame, 5000);
if (steamStatus.value.recentGames.length) {
idx.value = (idx.value + 1) % steamStatus.value.recentGames.length;
}
clearTimeout(nextId);
nextId = setTimeout(nextGame, 5000);
if (steamStatus.value.recentGames.length) {
idx.value = (idx.value + 1) % steamStatus.value.recentGames.length;
}
}
onMounted(() => {
nextId = setTimeout(nextGame, 5000);
refreshId = setInterval(() => steamStore.fetchSteam(), 5 * 60 * 1000);
nextId = setTimeout(nextGame, 5000);
refreshId = setInterval(() => steamStore.fetchSteam(), 5 * 60 * 1000);
});
onUnmounted(() => {
clearTimeout(nextId);
clearInterval(refreshId);
clearTimeout(nextId);
clearInterval(refreshId);
});
function formatHours(minutes) {
const hrs = (minutes / 60).toFixed(1);
return `${hrs}h`;
const hrs = (minutes / 60).toFixed(1);
return `${hrs}h`;
}
</script>
<template>
<div class="steam-wrapper">
<Header class="text-left">
<span class="flex items-center gap-2">
Steam
<span
class="inline-block w-2 h-2 rounded-full"
:class="steamStatus.online ? 'bg-green-500' : 'bg-gray-400'"
:title="steamStatus.online ? 'Online' : 'Offline'"
/>
</span>
</Header>
<div class="steam-wrapper">
<Header class="text-left">
<span class="flex items-center gap-2">
Steam
<span
class="inline-block w-2 h-2 rounded-full"
:class="steamStatus.online ? 'bg-green-500' : 'bg-gray-400'"
:title="steamStatus.online ? 'Online' : 'Offline'"
/>
</span>
</Header>
<div v-if="!loaded" class="p-2 text-sm">Loading...</div>
<div v-if="!loaded" class="p-2 text-sm">Loading...</div>
<div v-else-if="game" class="game-container">
<Transition name="fade">
<div
@click="nextGame"
:key="game.appId"
class="flex flex-col items-center pt-2"
>
<img
:src="game.headerImageUrl"
:alt="game.name"
width="145"
height="68"
class="game-img"
/>
<p class="text-center">
<strong>{{ game.name }}</strong>
</p>
<p class="text-center text-tertiary text-xs">
{{ formatHours(game.playtime2Weeks) }} last 2 weeks
</p>
</div>
</Transition>
<div v-else-if="game" class="game-container">
<Transition name="fade">
<div
@click="nextGame"
:key="game.appId"
class="flex flex-col items-center pt-2"
>
<img
:src="game.headerImageUrl"
:alt="game.name"
width="145"
height="68"
class="game-img"
/>
<p class="text-center">
<strong>{{ game.name }}</strong>
</p>
<p class="text-center text-tertiary text-xs">
{{ formatHours(game.playtime2Weeks) }} last 2 weeks
</p>
</div>
<div v-else class="p-2 text-sm">No recent games.</div>
</Transition>
</div>
<div v-else class="p-2 text-sm">No recent games.</div>
</div>
</template>
<style scoped>
.steam-wrapper {
position: relative;
height: 54mm;
display: flex;
flex-direction: column;
position: relative;
height: 54mm;
display: flex;
flex-direction: column;
}
.game-container {
position: relative;
flex: 1;
min-height: 0;
overflow-y: scroll;
position: relative;
flex: 1;
min-height: 0;
overflow-y: scroll;
}
.game-img {
width: 90%;
max-width: 300px;
height: auto;
width: 90%;
max-width: 300px;
height: auto;
}
p {
width: 100%;
margin: 0 auto;
width: 100%;
margin: 0 auto;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
transition: opacity 0.5s ease;
}
.fade-leave-active {
position: absolute;
top: 0;
left: 0;
right: 0;
position: absolute;
top: 0;
left: 0;
right: 0;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
opacity: 0;
}
@media (max-width: 850px) {
.steam-wrapper {
height: auto;
min-height: 120px;
}
.steam-wrapper {
height: auto;
min-height: 120px;
}
}
</style>

View File

@@ -3,27 +3,25 @@ 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" },
{ 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>
<main class="items-center flex flex-col">
<div
class="a4page-portrait bdr-1 flex flex-row relative overflow-scroll items-center"
>
<p>
<Link href="https://wiki.teamfortress.com/wiki/Demoman">The goat</Link>
</p>
<div>
<VideoTable :sourceArr="videoSources" />
</div>
</div>
</main>
</template>

View File

@@ -3,11 +3,11 @@ 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>
<main class="items-center flex flex-col">
<div
class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll"
>
<Wip />
</div>
</main>
</template>

View File

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

View File

@@ -1,20 +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" },
{ 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" />
<div
class="a4page-portrait bdr-1 flex flex-col relative overflow-scroll gap-1"
>
<RouterTable :linkArr="shrine_links" />
</div>
</main>
<main class="items-center flex flex-col">
<div class="background" />
<div
class="a4page-portrait bdr-1 flex flex-col relative overflow-scroll gap-1"
>
<RouterTable :linkArr="shrine_links" />
</div>
</main>
</template>

View File

@@ -3,11 +3,11 @@ 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>
<main class="items-center flex flex-col">
<div
class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll"
>
<Wip />
</div>
</main>
</template>

View File

@@ -5,52 +5,51 @@ 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" },
{ 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="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>
<main class="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>
<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>
<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;
padding: 0.5rem 1rem;
border: 1px solid currentColor;
}
</style>

View File

@@ -1,72 +1,70 @@
<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>
<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 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>
</main>
</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;
}
@page {
size: A5 landscape;
margin: 0;
}
}
</style>

View File

@@ -16,60 +16,60 @@ 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 disposition = headers["content-disposition"];
if (!disposition) return null;
const match = disposition.match(/filename="?([^"]+)"?/);
return match ? match[1] : 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 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;
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;
}
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 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})`;
});
return `[${text}](/notes/${url})`;
});
}
onMounted(fetchFile);
</script>
<template>
<main class="items-center flex flex-col">
<div class="background" />
<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>
<main class="items-center flex flex-col">
<div class="background" />
<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>
<div v-else>Loading</div>
</main>
</template>

View File

@@ -9,25 +9,25 @@ import topLevelAwait from "vite-plugin-top-level-await";
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
...(process.env.NODE_ENV !== "production" ? [vueDevTools()] : []),
tailwindcss(),
wasm(),
topLevelAwait(),
],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
plugins: [
vue(),
...(process.env.NODE_ENV !== "production" ? [vueDevTools()] : []),
tailwindcss(),
wasm(),
topLevelAwait(),
],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
server: {
host: "0.0.0.0",
proxy: {
"/api": "http://localhost:8080",
"/gitea": "http://localhost:3000",
"/radio": "http://localhost:8000",
"/searxng": "http://localhost:8080",
},
},
server: {
host: "0.0.0.0",
proxy: {
"/api": "http://localhost:8080",
"/gitea": "http://localhost:3000",
"/radio": "http://localhost:8000",
"/searxng": "http://localhost:8080",
},
},
});