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 // ROUTES WITH AUTH
app.use('/campaign', require('./routes/campaign')); app.use('/campaign', require('./routes/campaign'));
app.use('/note', require('./routes/note'));
/* /*
app.use('/campaign', require('./routes/campaign')); app.use('/campaign', require('./routes/campaign'));
app.use('/maps', require('./routes/map')); 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, toast-background: #202020,
note-border: #202324,
hover: #21262d, hover: #21262d,
selected: #4a4a4b, selected: #4a4a4b,
border-color: #819796, border-color: #819796,
@@ -49,6 +51,8 @@ $themes: (
toast-background: #f0f0f0, toast-background: #f0f0f0,
note-border: #e0e0e0,
border-color: #e0e0e0, border-color: #e0e0e0,
border: #f0f0f0, border: #f0f0f0,
hover: #e9e9e9, hover: #e9e9e9,

View File

@@ -1,18 +1,302 @@
<script setup> <script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import Content from '../viewer/content/Content.vue'; import Content from '../viewer/content/Content.vue';
import StatusBar from '../viewer/statusbar/StatusBar.vue'; import StatusBar from '../viewer/statusbar/StatusBar.vue';
import TopBar from '../viewer/TopBar.vue'; import TopBar from '../viewer/TopBar.vue';
import { ShowContent } from '../../services/Content.js'; 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> </script>
<template> <template>
<div v-show="ShowContent"> <div v-show="ShowContent" class="content-manager">
<TopBar></TopBar> <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> <StatusBar></StatusBar>
</div> </div>
</template> </template>
<style scoped> <style scoped>
</style> .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 { onMounted, ref } from 'vue';
import { AddSound } from '../../services/Sound'; import { AddSound } from '../../services/Sound';
import { useCampaignService } from '~/services/Campaign';
import { ClearWindow } from '~/services/Windows';
const { SetCampaign } = useCampaignService();
const props = defineProps(['data']); const props = defineProps(['data']);
const data = props.data; const data = props.data;
@@ -21,8 +24,8 @@ onMounted(() => {
}); });
function ViewCampaign(){ function ViewCampaign(){
// ConnectToCampaign(data); SetCampaign(data);
// DisplayCampaign(data); ClearWindow({type: "main_menu"});
} }
</script> </script>

View File

@@ -1,17 +1,41 @@
<script setup> <script setup>
import TopSearchBar from './topbar/TopSearchBar.vue'; 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> </script>
<template> <template>
<div class="top-bar"> <div class="top-bar">
<div class="left"> <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>
<div class="center"> <div class="center">
<TopSearchBar></TopSearchBar> <TopSearchBar></TopSearchBar>
</div> </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> </div>
</template> </template>
@@ -25,17 +49,51 @@ import TopSearchBar from './topbar/TopSearchBar.vue';
display: flex; display: flex;
} }
.logo {
height: 36px;
width: 36px;
position: absolute;
top: 0px;
left: 8px;
}
.left, .right { .left, .right {
flex: 1; flex: 1;
} }
.right { .right {
text-align: right; display: flex;
justify-content: flex-end;
align-items: center;
padding-right: 10px;
} }
.top-bar-title { .top-bar-title {
padding: 10px; padding: 10px;
display: flex; display: flex;
margin-left: 48px;
font-weight: bold; font-weight: bold;
} }
</style>
.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> <script setup>
import NoteContainer from './NoteContainer.vue'; import NoteContainer from './NoteContainer.vue';
const emitter = useEmitter();
function hideSearch(){
emitter.emit("hide-search-container");
}
</script> </script>
<template> <template>
<div class="content" v-on:click="hideSearch"> <div class="content">
<NoteContainer></NoteContainer> <NoteContainer>
</NoteContainer>
<!-- PowerMod --> <!-- PowerMod -->
</div> </div>
</template> </template>
@@ -18,6 +15,7 @@ function hideSearch(){
<style scoped> <style scoped>
.content { .content {
flex-grow: 1; flex-grow: 1;
min-width: 0; /* 👈 important */
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;

View File

@@ -1,56 +1,73 @@
<script setup> <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'; // import { GetNote, GetContent } from '@/services/Content';
const props = defineProps(['text', 'title', 'noteKey']); 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(); const editingMode = ref(false);
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);
});
function closeNote(){ function closeNote(){
emitter.emit('delete-note', props.noteKey); emitter.emit('delete-note', props.noteKey);
} }
function gotoNote(){ const compiledMarkdown = computed(() => {
// emitter.emit('goto-note', props.noteKey); 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() { function toggleCallout() {
@@ -78,6 +95,10 @@ function toggleCallout() {
} }
function setupCallout() { function setupCallout() {
if (!noteContent.value) {
return;
}
const collapsible = noteContent.value.getElementsByClassName( const collapsible = noteContent.value.getElementsByClassName(
`callout is-collapsible`, `callout is-collapsible`,
); );
@@ -93,21 +114,21 @@ function setupCallout() {
} }
} }
} }
*/
</script> </script>
<template> <template>
<div class="note"> <div class="note" @keydown="handleKeydown" tabindex="0">
<div class="note-stunt" v-on:click="gotoNote"> <div class="note-stunt">
<div class="close-button" v-on:click="closeNote"> <div class="close-button" v-on:click="closeNote">
<img class="icon" src="/icons/Pixelarticons/white/close.svg" alt="My Happy SVG"/> <img class="icon" src="/icons/Pixelarticons/white/close.svg" alt="My Happy SVG"/>
</div> </div>
<span>{{ title }}</span> <span>{{ title }}</span>
</div> </div>
<div class="note-content-container"> <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>
</div> </div>
</template> </template>
@@ -124,6 +145,22 @@ function setupCallout() {
user-select: none; 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 { .close-button {
height: 20px; height: 20px;
width: 20px; width: 20px;
@@ -131,6 +168,7 @@ function setupCallout() {
display: flex; display: flex;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
filter: invert(var(--color-icon-invert));
} }
.note { .note {
@@ -138,34 +176,32 @@ function setupCallout() {
max-width: 700px; max-width: 700px;
overflow-y: auto; overflow-y: auto;
border-color: var(--note-border-color); border-color: var(--color-note-border);
border-width: 0px; border-width: 0px;
border-right-width: 1px; border-right-width: 1px;
border-style: solid; border-style: solid;
display: flex; display: flex;
background-color: var(--background-color); background-color: var(--color-background);
position: sticky; position: sticky;
top: 0px; top: 0px;
} }
/* Contingut de cada nota */ /* Contingut de cada nota */
.note-content { .note-content {
padding-bottom: 60px; padding-bottom: 400px;
overflow-y: auto; overflow-y: auto;
max-width: 600px;
padding: 20px;
} }
.note-content-container { .note-content-container {
margin: 20px; width: 100%;
}
.note-content :deep(img) {
max-width: 100%;
height: auto;
display: block; /* optional: avoids inline spacing issues */
} }
</style> </style>
<style>
.note-content > h1 {
text-align: center;
}
.note-content .katex-display {
max-width: 600px;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { onMounted, onUnmounted, ref } from 'vue';
import Note from './Note.vue'; import Note from './Note.vue';
import { emitter } from '~/services/Emitter'; import { emitter } from '~/services/Emitter';
@@ -9,6 +9,10 @@ const noteContainer = ref(null);
function calculateContainerWidth(){ function calculateContainerWidth(){
let dom = noteContainer.value; let dom = noteContainer.value;
if (!dom) {
return;
}
dom.style.width = noteData.value.length * 701 + "px"; dom.style.width = noteData.value.length * 701 + "px";
setTimeout(() => { setTimeout(() => {
@@ -22,19 +26,33 @@ function calculateContainerWidth(){
} }
function pushNote(note){ function pushNote(note){
noteData.value = noteData.value.filter((currentNote) => {
return currentNote.key !== note.key;
});
noteData.value.push(note); noteData.value.push(note);
calculateContainerWidth(); calculateContainerWidth();
} }
emitter.on("push-note", (note) => { function handlePushNote(note) {
pushNote(note); pushNote(note);
}) }
emitter.on("delete-note", (key) => { function handleDeleteNote(key) {
noteData.value = noteData.value.filter((note) => { noteData.value = noteData.value.filter((note) => {
return note.key !== key; return note.key !== key;
}); });
calculateContainerWidth(); 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> </script>
@@ -59,10 +77,9 @@ emitter.on("delete-note", (key) => {
display: flex; display: flex;
height: 100%; height: 100%;
margin: 0; margin: 0;
height: 100%;
background-color: var(--color-background); background-color: var(--color-background);
} }
</style> </style>
<style> <style>
</style> </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,9 +34,15 @@ const defWindows = {
component: () => import('~/components/windows/CreateCampaignWindow.vue'), component: () => import('~/components/windows/CreateCampaignWindow.vue'),
close: () => ClearWindow({type: 'create_campaign'}), close: () => ClearWindow({type: 'create_campaign'}),
movable: true movable: true
},
create_note: {
title: "Create note",
component: () => import('~/components/windows/CreateNoteWindow.vue'),
close: () => ClearWindow({type: 'create_note'}),
movable: true
} }
} }
export { export {
defWindows defWindows
} }

View File

@@ -9,6 +9,7 @@
"dependencies": { "dependencies": {
"@nuxtjs/i18n": "^10.3.0", "@nuxtjs/i18n": "^10.3.0",
"axios": "^1.15.2", "axios": "^1.15.2",
"marked": "^18.0.2",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"motion": "^12.38.0", "motion": "^12.38.0",
"nuxt": "^4.4.2", "nuxt": "^4.4.2",
@@ -8886,6 +8887,18 @@
"source-map-js": "^1.2.1" "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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View File

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