This commit is contained in:
@@ -260,8 +260,8 @@ onMounted(() => {
|
||||
canvas.value.style.height = `${h}px`
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
ctx.scale(dpr, dpr)
|
||||
particles = createParticles(w, h)
|
||||
bgParticles = createBgParticles(w, h)
|
||||
// particles = createParticles(w, h)
|
||||
// bgParticles = createBgParticles(w, h)
|
||||
})
|
||||
|
||||
if (ctx && canvas.value) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
const { get } = api();
|
||||
const { get } = useApi();
|
||||
const { t } = useI18n();
|
||||
|
||||
const status = ref<{
|
||||
@@ -27,31 +27,20 @@ const online = computed(() => status.value?.mongo === 'connected');
|
||||
<template>
|
||||
<div class="server-status-card">
|
||||
<div class="monitor-bar">
|
||||
<span class="monitor-label">SERVER STATUS</span>
|
||||
<span class="monitor-label">STATUS</span>
|
||||
<span v-if="loading" class="monitor-blink">▚</span>
|
||||
<span v-else class="monitor-dot" :class="online ? 'green' : 'red'"></span>
|
||||
</div>
|
||||
|
||||
<div class="monitor-screen">
|
||||
<div class="screen-grid">
|
||||
<div class="screen-line">
|
||||
<span>Everything OK!</span>
|
||||
</div>
|
||||
<div class="screen-line">
|
||||
<span class="screen-key">UPTIME</span>
|
||||
<span class="screen-val">{{ loading ? '.......' : status?.uptime || '--' }}</span>
|
||||
</div>
|
||||
<div class="screen-line">
|
||||
<span class="screen-key">MEM_RSS</span>
|
||||
<span class="screen-val">{{ loading ? '......' : status?.memory.rss || '--' }}</span>
|
||||
</div>
|
||||
<div class="screen-line">
|
||||
<span class="screen-key">HEAP</span>
|
||||
<span class="screen-val">{{ loading ? '......' : status?.memory.heapUsed || '--' }}</span>
|
||||
</div>
|
||||
<div class="screen-line">
|
||||
<span class="screen-key">MONGO</span>
|
||||
<span class="screen-val" :class="status?.mongo === 'connected' ? 'ok' : status?.mongo ? 'err' : ''">
|
||||
[{{ loading ? '.+.' : status?.mongo || '--' }}]
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,7 +66,6 @@ const online = computed(() => status.value?.mongo === 'connected');
|
||||
background: #0e0e0e;
|
||||
color: #d4d4d4;
|
||||
font-family: 'Hurmit', monospace;
|
||||
font-size: 0.5rem;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
border-bottom: 2px solid #3a3a3a;
|
||||
@@ -128,7 +116,6 @@ const online = computed(() => status.value?.mongo === 'connected');
|
||||
|
||||
.screen-key {
|
||||
font-family: 'Hurmit', monospace;
|
||||
font-size: 0.55rem;
|
||||
color: var(--color-text);
|
||||
opacity: 0.4;
|
||||
letter-spacing: 1px;
|
||||
@@ -137,7 +124,6 @@ const online = computed(() => status.value?.mongo === 'connected');
|
||||
|
||||
.screen-val {
|
||||
font-family: 'Hurmit', monospace;
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-link);
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
|
||||
237
frontend/app/components/widgets/Place.vue
Normal file
237
frontend/app/components/widgets/Place.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<script setup>
|
||||
const GRID_COLS = 25
|
||||
const GRID_ROWS = 80
|
||||
|
||||
// Palette of swatches
|
||||
const palette = [
|
||||
"#172038",
|
||||
"#253a5e",
|
||||
"#3c5e8b",
|
||||
"#4f8fba",
|
||||
"#73bed3",
|
||||
"#a4dddb",
|
||||
"#193024",
|
||||
"#245938",
|
||||
"#2b8435",
|
||||
"#62ac4c",
|
||||
"#a2dc6e",
|
||||
"#c5e49b",
|
||||
"#19332d",
|
||||
"#25562e",
|
||||
"#468232",
|
||||
"#75a743",
|
||||
"#a8ca58",
|
||||
"#d0da91",
|
||||
"#5f6d43",
|
||||
"#97933a",
|
||||
"#a9b74c",
|
||||
"#cfd467",
|
||||
"#d5dc97",
|
||||
"#d6dea6",
|
||||
"#382a28",
|
||||
"#43322f",
|
||||
"#564238",
|
||||
"#715a42",
|
||||
"#867150",
|
||||
"#b1a282",
|
||||
"#4d2b32",
|
||||
"#7a4841",
|
||||
"#ad7757",
|
||||
"#c09473",
|
||||
"#d7b594",
|
||||
"#e7d5b3",
|
||||
"#341c27",
|
||||
"#602c2c",
|
||||
"#884b2b",
|
||||
"#be772b",
|
||||
"#de9e41",
|
||||
"#e8c170",
|
||||
"#241527",
|
||||
"#411d31",
|
||||
"#752438",
|
||||
"#a53030",
|
||||
"#cf573c",
|
||||
"#da863e",
|
||||
"#1e1d39",
|
||||
"#402751",
|
||||
"#7a367b",
|
||||
"#a23e8c",
|
||||
"#c65197",
|
||||
"#df84a5",
|
||||
"#090a14",
|
||||
"#10141f",
|
||||
"#151d28",
|
||||
"#202e37",
|
||||
"#394a50",
|
||||
"#577277",
|
||||
"#819796",
|
||||
"#a8b5b2",
|
||||
"#c7cfcc",
|
||||
"#ebede9"
|
||||
]
|
||||
|
||||
const colorMap = ref({})
|
||||
const selectedColor = ref(palette[0])
|
||||
const selectedColorId = ref(0)
|
||||
|
||||
const { get, post } = useApi();
|
||||
const { initSocket, onGridCellPaint, removeGridCellPaintListener } = useSocket();
|
||||
|
||||
onMounted(async () => {
|
||||
initSocket()
|
||||
|
||||
try {
|
||||
const cells = await get(`/grid-cells?minx=${1}&miny=${1}&maxx=${GRID_ROWS}&maxy=${GRID_COLS}`);
|
||||
cells.forEach(cell => {
|
||||
let key = `${cell.x},${cell.y}`;
|
||||
if(cell.color > 0 && cell.color <= palette.length)
|
||||
colorMap.value[key] = palette[cell.color];
|
||||
else
|
||||
colorMap.value[key] = "#00000000"
|
||||
; });
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
onGridCellPaint((data) => {
|
||||
const key = `${data.x},${data.y}`;
|
||||
if (data.color > 0 && data.color <= palette.length) {
|
||||
colorMap.value[key] = palette[data.color];
|
||||
} else {
|
||||
delete colorMap.value[key];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
removeGridCellPaintListener();
|
||||
});
|
||||
|
||||
function onCellClick(r, c) {
|
||||
if (!selectedColor.value) return
|
||||
const key = `${r},${c}`
|
||||
|
||||
colorMap.value[key] = selectedColor.value
|
||||
|
||||
// Send color paint
|
||||
post('/grid-cells/paint', {
|
||||
x: r,
|
||||
y: c,
|
||||
color: selectedColorId.value
|
||||
})
|
||||
}
|
||||
|
||||
function onCellRightClick(r, c, e) {
|
||||
e.preventDefault()
|
||||
const key = `${r},${c}`
|
||||
delete colorMap.value[key]
|
||||
|
||||
// Send color delete
|
||||
post('/grid-cells/paint', {
|
||||
x: r,
|
||||
y: c,
|
||||
color: 0
|
||||
})
|
||||
}
|
||||
|
||||
function onSwatchClick(color) {
|
||||
selectedColor.value = color;
|
||||
selectedColorId.value = palette.indexOf(color);
|
||||
}
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${GRID_ROWS}, 1fr)`,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="place-container">
|
||||
<div class="palette-row" role="toolbar" aria-label="Color palette">
|
||||
<button
|
||||
v-for="(color, i) in palette"
|
||||
:key="i"
|
||||
type="button"
|
||||
class="swatch"
|
||||
:class="{ active: selectedColor === color }"
|
||||
:style="{ backgroundColor: color }"
|
||||
:aria-label="`Select color ${color}`"
|
||||
@click="onSwatchClick(color)"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid-wrapper" :style="gridStyle">
|
||||
<div
|
||||
v-for="r in GRID_ROWS"
|
||||
:key="`row-${r}`"
|
||||
class="grid-row"
|
||||
>
|
||||
<div
|
||||
v-for="c in GRID_COLS"
|
||||
:key="`${r}-${c}`"
|
||||
class="cell"
|
||||
:class="{ filled: colorMap[`${r},${c}`] }"
|
||||
:style="colorMap[`${r},${c}`] ? { backgroundColor: colorMap[`${r},${c}`] } : {}"
|
||||
@click="onCellClick(r, c)"
|
||||
@contextmenu="onCellRightClick(r, c, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.place-container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.palette-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 12px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.05s, transform 0.05s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border: 2px solid var(--color-link, #a4dddb);
|
||||
box-shadow: 0 0 0 1px var(--color-link, #a4dddb);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-wrapper {
|
||||
background-color: var(--color-background-line);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.cell {
|
||||
aspect-ratio: 1 / 1;
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.05s steps(1);
|
||||
|
||||
&:hover {
|
||||
outline: 1px solid #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.filled {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
63
frontend/app/composables/useSocket.ts
Normal file
63
frontend/app/composables/useSocket.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { io } from 'socket.io-client'
|
||||
|
||||
export default function useSocket() {
|
||||
const config = useRuntimeConfig()
|
||||
const socketUrl = `${config.public.apiBaseUrl.replace('/api', '')}`
|
||||
|
||||
const socket = ref(null)
|
||||
const isConnected = ref(false)
|
||||
|
||||
function initSocket() {
|
||||
if (socket.value) return
|
||||
|
||||
const s = io(socketUrl, {
|
||||
transports: ['websocket', 'polling'],
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 10,
|
||||
reconnectionDelay: 1000,
|
||||
})
|
||||
|
||||
s.on('connect', () => {
|
||||
console.log('[socket.io] connected')
|
||||
isConnected.value = true
|
||||
})
|
||||
|
||||
s.on('disconnect', () => {
|
||||
console.log('[socket.io] disconnected')
|
||||
isConnected.value = false
|
||||
})
|
||||
|
||||
s.on('connect_error', (err) => {
|
||||
console.error('[socket.io] connection error:', err.message)
|
||||
isConnected.value = false
|
||||
})
|
||||
|
||||
socket.value = s
|
||||
}
|
||||
|
||||
function onGridCellPaint(callback) {
|
||||
if (!socket.value) initSocket()
|
||||
socket.value.on('grid-cell-paint', callback)
|
||||
}
|
||||
|
||||
function removeGridCellPaintListener() {
|
||||
if (socket.value) {
|
||||
socket.value.off('grid-cell-paint')
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (socket.value) {
|
||||
socket.value.disconnect()
|
||||
socket.value = null
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
initSocket,
|
||||
onGridCellPaint,
|
||||
removeGridCellPaintListener,
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import TableHeader from '~/components/parts/TableHeader.vue';
|
||||
import FixedLayout from '~/components/layouts/FixedLayout.vue';
|
||||
import { useSeo } from '~/composables/seo';
|
||||
|
||||
const { get, post } = api();
|
||||
const { get, post } = useApi();
|
||||
const { locale } = useI18n();
|
||||
|
||||
useSeo({
|
||||
@@ -79,4 +79,4 @@ p {
|
||||
.no-sprite .undertable-wrapper {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import FixedLayout from '~/components/layouts/FixedLayout.vue';
|
||||
import TableHeader from '~/components/parts/TableHeader.vue';
|
||||
import ServerStatus from '~/components/parts/ServerStatus.vue';
|
||||
import api from '~/composables/api'
|
||||
|
||||
import { useSeo } from '~/composables/seo'
|
||||
|
||||
const { get, post } = api();
|
||||
import Place from '~/components/widgets/Place.vue';
|
||||
|
||||
const { get, post } = useApi();
|
||||
const { locale } = useI18n();
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -180,7 +182,14 @@ const sectionTargets = {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="projects-section" id="scroll-projects" v-if="projects && projects.length > 0">
|
||||
<section class="normal-section" id="place">
|
||||
<Container>
|
||||
<h2 class="section-title">Place things!</h2>
|
||||
<Place></Place>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
<section class="normal-section projects-section" id="scroll-projects" v-if="projects && projects.length > 0">
|
||||
<Container>
|
||||
<h2 class="section-title">{{ t('pages.projects_heading') }}</h2>
|
||||
|
||||
@@ -217,101 +226,7 @@ const sectionTargets = {
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
<!--
|
||||
<section class="stats-section" id="scroll-stats">
|
||||
<Container>
|
||||
<h2 class="section-title">{{ t('pages.stats_heading') }}</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span class="stat-card-corner tl"></span>
|
||||
<span class="stat-card-corner tr"></span>
|
||||
<span class="stat-card-corner bl"></span>
|
||||
<span class="stat-card-corner br"></span>
|
||||
<span class="stat-card-frame-top"></span>
|
||||
<span class="stat-card-frame-bottom"></span>
|
||||
<div class="stat-card-header">CODE</div>
|
||||
<div class="stat-card-content">
|
||||
<svg class="stat-pixel-art" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 3h2v2H3V3zm4 0h2v2H7V3zm4 0h2v2h-2V3zM3 7h2v2H3V7zm6 0h2v2H9V7zm4 0h2v2h-2V7zM3 11h2v2H3v-2zm4 0h2v2H7v-2zm4 0h2v2h-2v-2z"/></svg>
|
||||
<span class="stat-number">50K+</span>
|
||||
<span class="stat-label">{{ t('pages.stat_lines_of_code') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-card-corner tl"></span>
|
||||
<span class="stat-card-corner tr"></span>
|
||||
<span class="stat-card-corner bl"></span>
|
||||
<span class="stat-card-corner br"></span>
|
||||
<span class="stat-card-frame-top"></span>
|
||||
<span class="stat-card-frame-bottom"></span>
|
||||
<div class="stat-card-header">WORK</div>
|
||||
<div class="stat-card-content">
|
||||
<svg class="stat-pixel-art" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 3h8v2H6v2h6v2H8v2h4v2H4V3z"/></svg>
|
||||
<span class="stat-number">12+</span>
|
||||
<span class="stat-label">{{ t('pages.stat_projects') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-card-corner tl"></span>
|
||||
<span class="stat-card-corner tr"></span>
|
||||
<span class="stat-card-corner bl"></span>
|
||||
<span class="stat-card-corner br"></span>
|
||||
<span class="stat-card-frame-top"></span>
|
||||
<span class="stat-card-frame-bottom"></span>
|
||||
<div class="stat-card-header">CAFFE</div>
|
||||
<div class="stat-card-content">
|
||||
<svg class="stat-pixel-art" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 5h8v2H4V5zm-2 2h2v1H2V7zm10 0h2v1h-2V7zM3 9h1v3h1v-1h3v1h1V9h1v3h1v-1h2v1h1v-4H3z"/></svg>
|
||||
<span class="stat-number">∞</span>
|
||||
<span class="stat-label">{{ t('pages.stat_coffee') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-card-corner tl"></span>
|
||||
<span class="stat-card-corner tr"></span>
|
||||
<span class="stat-card-corner bl"></span>
|
||||
<span class="stat-card-corner br"></span>
|
||||
<span class="stat-card-frame-top"></span>
|
||||
<span class="stat-card-frame-bottom"></span>
|
||||
<div class="stat-card-header">PLAY</div>
|
||||
<div class="stat-card-content">
|
||||
<svg class="stat-pixel-art" viewBox="0 0 16 16" aria-hidden="true"><path d="M2 4h3v2H3v1h2v2H8v-2h2V6h-1V4h3v2h-2v3H5V6H4V4zm7 2h2v2h-2V6z"/></svg>
|
||||
<span class="stat-number">28</span>
|
||||
<span class="stat-label">{{ t('pages.stat_board_games') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-card-corner tl"></span>
|
||||
<span class="stat-card-corner tr"></span>
|
||||
<span class="stat-card-corner bl"></span>
|
||||
<span class="stat-card-corner br"></span>
|
||||
<span class="stat-card-frame-top"></span>
|
||||
<span class="stat-card-frame-bottom"></span>
|
||||
<div class="stat-card-header">TIME</div>
|
||||
<div class="stat-card-content">
|
||||
<svg class="stat-pixel-art" viewBox="0 0 16 16" aria-hidden="true"><path d="M5 2h6v2H5V2zm-2 4h10v1H3V6zm1 2h8v1H4V8zm2 2h4v1H6v-1zM6 2h4v12H6V2z"/></svg>
|
||||
<span class="stat-number">8+</span>
|
||||
<span class="stat-label">{{ t('pages.stat_years_programming') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-card-corner tl"></span>
|
||||
<span class="stat-card-corner tr"></span>
|
||||
<span class="stat-card-corner bl"></span>
|
||||
<span class="stat-card-corner br"></span>
|
||||
<span class="stat-card-frame-top"></span>
|
||||
<span class="stat-card-frame-bottom"></span>
|
||||
<div class="stat-card-header">DEPLOY</div>
|
||||
<div class="stat-card-content">
|
||||
<svg class="stat-pixel-art" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 3h8v2H9v2h2v2h-2v2h-2v2h6v2H2v-2h2v-2H2V7h2V5H2V3z"/></svg>
|
||||
<span class="stat-number">150+</span>
|
||||
<span class="stat-label">{{ t('pages.stat_deployments') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
-->
|
||||
|
||||
<section class="blog-section" id="scroll-blog">
|
||||
<section class="normal-section" id="scroll-blog">
|
||||
<Container>
|
||||
<h2 class="section-title">{{ t('pages.blog_heading') }}</h2>
|
||||
<ul class="tui-list">
|
||||
@@ -328,7 +243,7 @@ const sectionTargets = {
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
<section class="art-section" id="scroll-art">
|
||||
<section class="normal-section" id="scroll-art">
|
||||
<Container>
|
||||
<h2 class="section-title">{{ t('pages.art_heading') }}</h2>
|
||||
<div class="grid">
|
||||
@@ -351,7 +266,7 @@ const sectionTargets = {
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
<section class="contact-section" id="scroll-contact">
|
||||
<section class="normal-section" id="scroll-contact">
|
||||
<Container>
|
||||
<h2 class="section-title">{{ t('pages.contact_heading') }}</h2>
|
||||
<ContentRenderer v-if="contactMarkdown" :value="contactMarkdown"></ContentRenderer>
|
||||
@@ -422,12 +337,8 @@ const sectionTargets = {
|
||||
}
|
||||
}
|
||||
|
||||
.projects-section,
|
||||
.stats-section,
|
||||
.blog-section,
|
||||
.contact-section,
|
||||
.art-section {
|
||||
margin-top: 16px;
|
||||
.normal-section {
|
||||
margin-top: 24px;
|
||||
|
||||
.tui-frame:last-child {
|
||||
margin-bottom: 0;
|
||||
@@ -713,11 +624,6 @@ const sectionTargets = {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Blog section */
|
||||
.blog-section {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.tui-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -779,16 +685,6 @@ const sectionTargets = {
|
||||
}
|
||||
}
|
||||
|
||||
/* Contact section */
|
||||
.contact-section {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
/* Art section */
|
||||
.art-section {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(250px, 1fr));
|
||||
|
||||
1
frontend/package-lock.json
generated
1
frontend/package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"nuxt": "^4.3.1",
|
||||
"sass": "^1.98.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"vue": "^3.5.30",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"nuxt": "^4.3.1",
|
||||
"sass": "^1.98.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"vue": "^3.5.30",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user