ye
All checks were successful
Build and Deploy Nuxt / build (push) Successful in 52s

This commit is contained in:
2026-04-29 01:32:09 +02:00
parent 76bb9fbb30
commit e6d66529e3
16 changed files with 767 additions and 83 deletions

0
.codex Normal file
View File

View File

@@ -53,6 +53,7 @@ app.use(checkAuth);
// ROUTES WITH AUTH
app.use('/campaign', require('./routes/campaign'));
app.use('/note', require('./routes/note'));
/*
app.use('/campaign', require('./routes/campaign'));
app.use('/maps', require('./routes/map'));

View File

@@ -0,0 +1,15 @@
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const NoteSchema = new Schema({
title: { type: String },
content: { type: String },
campaign: {
type: Schema.Types.ObjectId,
ref: 'Campaign',
required: true
},
date: { type: Date, default: Date.now }
});
module.exports = mongoose.model('Note', NoteSchema);

View File

@@ -0,0 +1,86 @@
const express = require('express');
const router = express.Router();
const Campaign = require("../models/Campaign");
const Note = require("../models/Note");
async function userOwnsCampaign(campaignId, userId) {
const campaign = await Campaign.findOne({ _id: campaignId, createdBy: userId }).lean();
return Boolean(campaign);
}
router.get('/list', async (req, res) => {
try {
const { campaign } = req.query;
if (!campaign) return res.json({ status: "error", msg: "errors.missing-data" });
const hasAccess = await userOwnsCampaign(campaign, req.user.id);
if (!hasAccess) return res.json({ status: "error", msg: "unauthorized" });
const notes = await Note.find({ campaign })
.select('_id title content date campaign')
.sort({ date: -1 })
.lean();
res.json({ status: "ok", notes });
} catch (err) {
console.error(err);
res.json({ status: "error", msg: "errors.internal" });
}
});
router.post('/create', async (req, res) => {
try {
const { title, content, campaign } = req.body;
const hasAccess = await userOwnsCampaign(campaign, req.user.id);
if (!hasAccess) return res.json({ status: "error", msg: "unauthorized" });
const newNote = new Note({
title,
content,
campaign
});
await newNote.save();
res.json({ status: "ok", note: newNote });
} catch (err) {
console.error(err);
res.json({ status: "error", msg: "errors.internal" });
}
});
router.post('/update', async (req, res) => {
try {
const { id, title, content } = req.body;
const note = await Note.findById(id);
if (!note) return res.json({ status: "error", msg: "errors.notfound" });
const hasAccess = await userOwnsCampaign(note.campaign, req.user.id);
if (!hasAccess) return res.json({ status: "error", msg: "unauthorized" });
if(title) note.title = title;
note.content = content;
note.date = Date.now();
await note.save();
res.json({ status: "ok", note });
} catch (err) {
console.error(err);
res.json({ status: "error", msg: "errors.internal" });
}
});
router.post('/delete', async (req, res) => {
try {
const { id } = req.body;
const note = await Note.findById(id);
if (!note) return res.json({ status: "error", msg: "errors.notfound" });
const hasAccess = await userOwnsCampaign(note.campaign, req.user.id);
if (!hasAccess) return res.json({ status: "error", msg: "unauthorized" });
await note.remove();
res.json({ status: "ok" });
} catch (err) {
console.error(err);
res.json({ status: "error", msg: "errors.internal" });
}
});
module.exports = router;

View File

@@ -19,6 +19,8 @@ $themes: (
toast-background: #202020,
note-border: #202324,
hover: #21262d,
selected: #4a4a4b,
border-color: #819796,
@@ -49,6 +51,8 @@ $themes: (
toast-background: #f0f0f0,
note-border: #e0e0e0,
border-color: #e0e0e0,
border: #f0f0f0,
hover: #e9e9e9,

View File

@@ -1,18 +1,302 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import Content from '../viewer/content/Content.vue';
import StatusBar from '../viewer/statusbar/StatusBar.vue';
import TopBar from '../viewer/TopBar.vue';
import { ShowContent } from '../../services/Content.js';
import { useCampaignService } from '~/services/Campaign.js';
import { emitter } from '~/services/Emitter';
import Server from '~/services/Server';
const { Campaign } = useCampaignService();
const notes = ref([]);
const loadingNotes = ref(false);
const notesError = ref('');
const sidebarCollapsed = ref(false);
const campaignId = computed(() => {
return Campaign.value?._id ?? Campaign.value?.id ?? null;
});
const campaignName = computed(() => {
return Campaign.value?.name ?? 'Campaign';
});
async function fetchCampaignNotes() {
if (!campaignId.value) {
notes.value = [];
notesError.value = '';
return;
}
loadingNotes.value = true;
notesError.value = '';
try {
const response = await Server().get('/note/list', {
params: {
campaign: campaignId.value
}
});
if (response.data.status !== 'ok') {
notes.value = [];
notesError.value = response.data.msg ?? 'Unable to load notes.';
return;
}
notes.value = response.data.notes.map((note) => {
return {
key: note._id,
title: note.title,
text: note.content ?? '',
date: note.date
};
});
} catch (error) {
notes.value = [];
notesError.value = 'Unable to load notes.';
} finally {
loadingNotes.value = false;
}
}
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value;
}
function openNote(note) {
emitter.emit('push-note', note);
}
function handleNoteCreated(note) {
if (!note) {
return;
}
const noteCampaignId = note.campaign?._id ?? note.campaign ?? null;
if (campaignId.value && noteCampaignId && noteCampaignId !== campaignId.value) {
return;
}
const createdNote = {
key: note._id,
title: note.title,
text: note.content ?? '',
date: note.date
};
notes.value = notes.value.filter((currentNote) => {
return currentNote.key !== createdNote.key;
});
notes.value.unshift(createdNote);
openNote(createdNote);
}
function formatNoteDate(date) {
if (!date) return '';
return new Date(date).toLocaleDateString();
}
onMounted(() => {
emitter.on('note-created', handleNoteCreated);
});
onUnmounted(() => {
emitter.off('note-created', handleNoteCreated);
});
watch(Campaign, () => {
if(Campaign.value) ShowContent.value = true;
fetchCampaignNotes();
}, { immediate: true });
</script>
<template>
<div v-show="ShowContent">
<div v-show="ShowContent" class="content-manager">
<TopBar></TopBar>
<Content></Content>
<div class="content-layout">
<aside class="notes-sidebar" :class="{ collapsed: sidebarCollapsed }">
<div class="sidebar-header">
<button
class="sidebar-toggle"
type="button"
@click="toggleSidebar"
:aria-expanded="(!sidebarCollapsed).toString()"
aria-controls="campaign-notes-list"
>
<img
class="sidebar-toggle-icon"
:src="sidebarCollapsed ? '/icons/iconoir/regular/nav-arrow-right.svg' : '/icons/iconoir/regular/nav-arrow-left.svg'"
alt=""
aria-hidden="true"
>
</button>
<div v-if="!sidebarCollapsed" class="sidebar-copy">
<span class="sidebar-eyebrow">Campaign Notes</span>
<strong class="sidebar-title">{{ campaignName }}</strong>
<span class="sidebar-meta">{{ notes.length }} note<span v-if="notes.length !== 1">s</span></span>
</div>
</div>
<div v-if="!sidebarCollapsed" id="campaign-notes-list" class="sidebar-list">
<div v-if="loadingNotes" class="sidebar-state">
Loading notes...
</div>
<div v-else-if="notesError" class="sidebar-state error">
{{ notesError }}
</div>
<div v-else-if="notes.length === 0" class="sidebar-state">
No notes in this campaign yet.
</div>
<template v-else>
<button
v-for="note in notes"
:key="note.key"
type="button"
class="note-link"
@click="openNote(note)"
>
<span class="note-link-title">{{ note.title }}</span>
</button>
</template>
</div>
</aside>
<Content></Content>
</div>
<StatusBar></StatusBar>
</div>
</template>
<style scoped>
.content-manager {
height: 100%;
display: flex;
flex-direction: column;
}
.content-layout {
min-height: 0;
flex: 1;
display: flex;
}
.notes-sidebar {
width: 280px;
min-width: 280px;
border-right: 1px solid var(--color-border);
background-color: var(--color-background-light);
display: flex;
flex-direction: column;
transition: width 0.2s ease, min-width 0.2s ease;
}
.notes-sidebar.collapsed {
width: 54px;
min-width: 54px;
}
.sidebar-header {
padding: 12px;
display: flex;
align-items: flex-start;
gap: 10px;
border-bottom: 1px solid var(--color-border);
}
.sidebar-toggle {
width: 30px;
height: 30px;
flex-shrink: 0;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-background-soft);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.sidebar-toggle-icon {
width: 18px;
height: 18px;
filter: invert(var(--color-icon-invert));
}
.sidebar-copy {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.sidebar-eyebrow,
.sidebar-meta {
font-size: 12px;
opacity: 0.7;
}
.sidebar-title {
line-height: 1.2;
word-break: break-word;
}
.sidebar-list {
padding: 10px;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.sidebar-state {
padding: 12px;
border-radius: 10px;
background: var(--color-background-soft);
font-size: 14px;
}
.sidebar-state.error {
color: #9e2a2b;
}
.note-link {
padding: 6px;
margin: 0;
box-shadow: none;
border: none;
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
cursor: pointer;
transition: transform 0.15s ease, background-color 0.15s ease;
}
.note-link:hover {
transform: translateX(2px);
background: var(--color-background-light);
}
.note-link-title {
font-weight: 600;
word-break: break-word;
}
.note-link-date {
font-size: 12px;
opacity: 0.7;
}
@media (max-width: 900px) {
.notes-sidebar {
width: 220px;
min-width: 220px;
}
}
</style>

View File

@@ -3,6 +3,9 @@
import { onMounted, ref } from 'vue';
import { AddSound } from '../../services/Sound';
import { useCampaignService } from '~/services/Campaign';
import { ClearWindow } from '~/services/Windows';
const { SetCampaign } = useCampaignService();
const props = defineProps(['data']);
const data = props.data;
@@ -21,8 +24,8 @@ onMounted(() => {
});
function ViewCampaign(){
// ConnectToCampaign(data);
// DisplayCampaign(data);
SetCampaign(data);
ClearWindow({type: "main_menu"});
}
</script>

View File

@@ -1,17 +1,41 @@
<script setup>
import TopSearchBar from './topbar/TopSearchBar.vue';
import { useCampaignService } from '~/services/Campaign';
import { CreateWindow } from '~/services/Windows';
const { Campaign } = useCampaignService();
const campaignName = computed(() => {
return Campaign.value?.name ?? 'Campaign';
});
function openCreateNoteWindow() {
if (!Campaign.value) {
return;
}
CreateWindow('create_note');
}
</script>
<template>
<div class="top-bar">
<div class="left">
<span class="top-bar-title"></span>
<span class="top-bar-title">
<img src="/img/logo.png" alt="Dragonroll Logo" class="logo">
<span>{{ campaignName }}</span>
</span>
</div>
<div class="center">
<TopSearchBar></TopSearchBar>
</div>
<div class="right"></div>
<div class="right">
<button class="note-button sound-click" type="button" @click="openCreateNoteWindow" :disabled="!Campaign">
<img class="note-button-icon" src="/icons/iconoir/regular/plus.svg" alt="" aria-hidden="true">
<span>New Note</span>
</button>
</div>
</div>
</template>
@@ -25,17 +49,51 @@ import TopSearchBar from './topbar/TopSearchBar.vue';
display: flex;
}
.logo {
height: 36px;
width: 36px;
position: absolute;
top: 0px;
left: 8px;
}
.left, .right {
flex: 1;
}
.right {
text-align: right;
display: flex;
justify-content: flex-end;
align-items: center;
padding-right: 10px;
}
.top-bar-title {
padding: 10px;
display: flex;
margin-left: 48px;
font-weight: bold;
}
.note-button {
height: 30px;
padding: 0 12px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-background-soft);
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.note-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.note-button-icon {
width: 16px;
height: 16px;
}
</style>

View File

@@ -1,16 +1,13 @@
<script setup>
import NoteContainer from './NoteContainer.vue';
const emitter = useEmitter();
function hideSearch(){
emitter.emit("hide-search-container");
}
</script>
<template>
<div class="content" v-on:click="hideSearch">
<NoteContainer></NoteContainer>
<div class="content">
<NoteContainer>
</NoteContainer>
<!-- PowerMod -->
</div>
</template>
@@ -18,6 +15,7 @@ function hideSearch(){
<style scoped>
.content {
flex-grow: 1;
min-width: 0; /* 👈 important */
display: flex;
justify-content: center;
align-items: center;

View File

@@ -1,56 +1,73 @@
<script setup>
import { onMounted, ref } from 'vue';
import { marked } from 'marked';
import { onMounted, onUnmounted, ref } from 'vue';
import ToastManager from '~/components/managers/ToastManager.vue';
import { emitter } from '~/services/Emitter';
import Server from '~/services/Server';
import { DisplayToast } from '~/services/Toaster';
// import { GetNote, GetContent } from '@/services/Content';
const props = defineProps(['text', 'title', 'noteKey']);
const noteContent = ref(null); // Markdown text
const noteContent = ref(null);
const sourceText = ref(''); // Original markdown source, used for editing
const displayText = ref(''); // Compiled HTML from markdown
const emitter = useEmitter();
function gotoNote(){
// emitter.emit('goto-note', props.noteKey);
}
function closeNote(){
// emitter.emit('delete-note', props.noteKey);
}
/*
onMounted(() => {
let content = GetContent();
let elements = noteContent.value.getElementsByTagName('a');
for(let i = 0, len = elements.length; i < len; i++) {
let link = elements[i].pathname.split('/').slice(1).join('');
link = decodeURIComponent(link);
if(content[link] !== undefined){
elements[i].onclick = function (event) {
event.preventDefault();
GetNote(link, (result) => {
emitter.emit("push-note", {key: link, text: "<h1>" + result.title + "</h1>" + result.html, title: result.title});
});
return false;
}
} else {
elements[i].classList.add("error-link");
elements[i].onclick = function (event) {
event.preventDefault();
return false;
}
}
}
setTimeout(() => setupCallout(), 0);
});
const editingMode = ref(false);
function closeNote(){
emitter.emit('delete-note', props.noteKey);
}
function gotoNote(){
// emitter.emit('goto-note', props.noteKey);
const compiledMarkdown = computed(() => {
return marked.parse(sourceText.value);
});
watch(sourceText, (newText) => {
displayText.value = compiledMarkdown.value;
setTimeout(() => setupCallout(), 0);
});
onMounted(() => {
sourceText.value = props.text;
// window.addEventListener('keydown', handleKeydown);
setTimeout(() => setupCallout(), 0);
});
onUnmounted(() => {
// window.removeEventListener('keydown', handleKeydown);
});
function handleKeydown(e) {
// Check for Ctrl + E (or Cmd + E on Mac)
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'e') {
e.preventDefault(); // prevent browser default behavior
editingMode.value = !editingMode.value;
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault(); // prevent browser default behavior
// Save the note (you can emit an event or call a method here)
SaveNote();
return;
}
}
function SaveNote(){
Server().post('/note/update', {
id: props.noteKey,
content: sourceText.value
}).then((response) => {
if(response.data.status !== 'ok'){
// Handle error (e.g., show a notification)
return;
}
DisplayToast('green', "Note saved successfully.", 500);
}).catch((error) => {
// Handle error (e.g., show a notification)
});
}
function toggleCallout() {
@@ -78,6 +95,10 @@ function toggleCallout() {
}
function setupCallout() {
if (!noteContent.value) {
return;
}
const collapsible = noteContent.value.getElementsByClassName(
`callout is-collapsible`,
);
@@ -93,21 +114,21 @@ function setupCallout() {
}
}
}
*/
</script>
<template>
<div class="note">
<div class="note-stunt" v-on:click="gotoNote">
<div class="note" @keydown="handleKeydown" tabindex="0">
<div class="note-stunt">
<div class="close-button" v-on:click="closeNote">
<img class="icon" src="/icons/Pixelarticons/white/close.svg" alt="My Happy SVG"/>
</div>
<span>{{ title }}</span>
</div>
<div class="note-content-container">
<div class="note-content" ref="noteContent" v-html="text"></div>
<textarea v-model="sourceText" class="full-editor" v-if="editingMode"></textarea>
<div class="note-content" ref="noteContent" v-html="displayText" v-else></div>
</div>
</div>
</template>
@@ -124,6 +145,22 @@ function setupCallout() {
user-select: none;
}
.full-editor {
width: 100%;
height: 100%;
box-sizing: border-box;
border: none;
outline: none;
resize: none;
padding: 20px;
font-size: 16px;
font-family: monospace; /* optional, gives document/editor feel */
padding-bottom: 400px; /* Small bottom margin */
}
.close-button {
height: 20px;
width: 20px;
@@ -131,6 +168,7 @@ function setupCallout() {
display: flex;
justify-content: center;
cursor: pointer;
filter: invert(var(--color-icon-invert));
}
.note {
@@ -138,34 +176,32 @@ function setupCallout() {
max-width: 700px;
overflow-y: auto;
border-color: var(--note-border-color);
border-color: var(--color-note-border);
border-width: 0px;
border-right-width: 1px;
border-style: solid;
display: flex;
background-color: var(--background-color);
background-color: var(--color-background);
position: sticky;
top: 0px;
}
/* Contingut de cada nota */
.note-content {
padding-bottom: 60px;
padding-bottom: 400px;
overflow-y: auto;
max-width: 600px;
padding: 20px;
}
.note-content-container {
margin: 20px;
}
</style>
<style>
.note-content > h1 {
text-align: center;
width: 100%;
}
.note-content .katex-display {
max-width: 600px;
.note-content :deep(img) {
max-width: 100%;
height: auto;
display: block; /* optional: avoids inline spacing issues */
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup>
import { ref, onMounted } from 'vue';
import { onMounted, onUnmounted, ref } from 'vue';
import Note from './Note.vue';
import { emitter } from '~/services/Emitter';
@@ -9,6 +9,10 @@ const noteContainer = ref(null);
function calculateContainerWidth(){
let dom = noteContainer.value;
if (!dom) {
return;
}
dom.style.width = noteData.value.length * 701 + "px";
setTimeout(() => {
@@ -22,19 +26,33 @@ function calculateContainerWidth(){
}
function pushNote(note){
noteData.value = noteData.value.filter((currentNote) => {
return currentNote.key !== note.key;
});
noteData.value.push(note);
calculateContainerWidth();
}
emitter.on("push-note", (note) => {
function handlePushNote(note) {
pushNote(note);
})
}
emitter.on("delete-note", (key) => {
function handleDeleteNote(key) {
noteData.value = noteData.value.filter((note) => {
return note.key !== key;
});
calculateContainerWidth();
}
// Moure aixo
onMounted(() => {
emitter.on("push-note", handlePushNote);
emitter.on("delete-note", handleDeleteNote);
});
onUnmounted(() => {
emitter.off("push-note", handlePushNote);
emitter.off("delete-note", handleDeleteNote);
});
</script>
@@ -59,7 +77,6 @@ emitter.on("delete-note", (key) => {
display: flex;
height: 100%;
margin: 0;
height: 100%;
background-color: var(--color-background);
}
</style>

View File

@@ -0,0 +1,150 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { SetupHandle, SetSize, ResetPosition, Top, ClearWindow } from '@/services/Windows';
import WindowHandle from './partials/WindowHandle.vue';
import Server from '~/services/Server';
import { emitter } from '~/services/Emitter';
import { useCampaignService } from '~/services/Campaign';
const handle = ref(null);
const wrapper = ref(null);
const props = defineProps(['data']);
const data = props.data;
const { Campaign } = useCampaignService();
const id = data.id;
const title = ref('');
const content = ref('');
const isSaving = ref(false);
const error = ref('');
const campaignId = computed(() => {
return Campaign.value?._id ?? Campaign.value?.id ?? null;
});
onMounted(() => {
Top(wrapper);
SetupHandle(id, handle);
SetSize(id, { width: 640, height: 520 });
ResetPosition(id, "center");
});
async function createNote() {
if (!campaignId.value || isSaving.value) {
return;
}
isSaving.value = true;
error.value = '';
try {
const response = await Server().post('/note/create', {
title: title.value.trim() || 'Untitled Note',
content: content.value,
campaign: campaignId.value
});
if (response.data.status !== 'ok') {
error.value = response.data.msg ?? 'Unable to create note.';
return;
}
emitter.emit('note-created', response.data.note);
ClearWindow({ id });
} catch (err) {
error.value = 'Unable to create note.';
} finally {
isSaving.value = false;
}
}
</script>
<template>
<div class="window-wrapper" :id="'window-wrapper-' + id" ref="wrapper">
<WindowHandle :window="id" ref="handle"></WindowHandle>
<div class="body">
<form @submit.prevent="createNote">
<div class="form-field">
<label>Title</label>
<input v-model="title" type="text" name="noteTitle" placeholder="Enter a note title" autocomplete="off">
</div>
<div class="form-field stacked">
<label>Content</label>
<textarea v-model="content" name="noteContent" placeholder="Write your note here"></textarea>
</div>
<div v-if="error" class="form-error">
{{ error }}
</div>
<div class="form-actions">
<button class="btn-primary sound-click" :disabled="isSaving">
{{ isSaving ? 'Creating...' : 'Create Note' }}
</button>
</div>
</form>
</div>
</div>
</template>
<style scoped>
.window-wrapper {
display: flex;
align-items: center;
flex-direction: column;
}
.body {
width: 100%;
}
form {
margin: 10px;
display: flex;
flex-direction: column;
gap: 12px;
}
.form-field {
display: flex;
align-items: center;
gap: 12px;
}
.form-field > * {
flex: 1;
}
.stacked {
align-items: stretch;
flex-direction: column;
gap: 6px;
}
label {
text-align: left;
}
textarea {
min-height: 300px;
resize: vertical;
}
.form-error {
color: #9e2a2b;
font-size: 14px;
}
.form-actions {
display: flex;
justify-content: center;
}
.form-actions button {
width: 100%;
}
</style>

View File

@@ -0,0 +1,12 @@
export const useCampaignService = () => {
const Campaign = useState('campaign', () => null)
const SetCampaign = (data) => {
Campaign.value = data;
}
return {
Campaign,
SetCampaign
}
}

View File

@@ -34,6 +34,12 @@ const defWindows = {
component: () => import('~/components/windows/CreateCampaignWindow.vue'),
close: () => ClearWindow({type: 'create_campaign'}),
movable: true
},
create_note: {
title: "Create note",
component: () => import('~/components/windows/CreateNoteWindow.vue'),
close: () => ClearWindow({type: 'create_note'}),
movable: true
}
}

View File

@@ -9,6 +9,7 @@
"dependencies": {
"@nuxtjs/i18n": "^10.3.0",
"axios": "^1.15.2",
"marked": "^18.0.2",
"mitt": "^3.0.1",
"motion": "^12.38.0",
"nuxt": "^4.4.2",
@@ -8886,6 +8887,18 @@
"source-map-js": "^1.2.1"
}
},
"node_modules/marked": {
"version": "18.0.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.2.tgz",
"integrity": "sha512-NsmlUYBS/Zg57rgDWMYdnre6OTj4e+qq/JS2ot3KrYLSoHLw+sDu0Nm1ZGpRgYAq6c+b1ekaY5NzVchMCQnzcg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View File

@@ -12,6 +12,7 @@
"dependencies": {
"@nuxtjs/i18n": "^10.3.0",
"axios": "^1.15.2",
"marked": "^18.0.2",
"mitt": "^3.0.1",
"motion": "^12.38.0",
"nuxt": "^4.4.2",