aboutsummaryrefslogtreecommitdiffstats
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-rw-r--r--components/Control/Button.vue82
-rw-r--r--components/Control/Checkbox.vue42
-rw-r--r--components/Control/ItemStackForm.vue50
-rw-r--r--components/Control/ItemStackModal.vue172
-rw-r--r--components/Control/ItemStackPicker.vue100
-rw-r--r--components/Control/Modal.vue68
-rw-r--r--components/Control/TrueFalseSwitch.vue54
-rw-r--r--components/Editor/Category/CategoryChildrenOptionsPanel.vue53
-rw-r--r--components/Editor/Category/CategoryOptionsPanel.vue54
-rw-r--r--components/Editor/EditorOptionsPanel.vue18
-rw-r--r--components/Editor/EditorPane.vue216
-rw-r--r--components/Editor/EditorSidebar.vue28
-rw-r--r--components/Editor/EditorSidebarCategory.vue94
-rw-r--r--components/Editor/EditorSidebarQuest.vue68
-rw-r--r--components/Editor/Quest/Modal/DeleteQuestModal.vue42
-rw-r--r--components/Editor/Quest/Modal/DuplicateQuestModal.vue69
-rw-r--r--components/Editor/Quest/Modal/RenameQuestModal.vue69
-rw-r--r--components/Editor/Quest/QuestOptionsPanel.vue145
-rw-r--r--components/Editor/Quest/QuestTasksOptionsPanel.vue98
-rw-r--r--components/Editor/Quest/Task/Modal/AddTaskModal.vue90
-rw-r--r--components/Editor/Quest/Task/Modal/ChangeTaskModal.vue77
-rw-r--r--components/Editor/Quest/Task/TaskConfiguration.vue209
-rw-r--r--components/Editor/Quest/Task/TaskConfigurationRow.vue195
-rw-r--r--components/Header/SiteHeader.vue43
24 files changed, 2136 insertions, 0 deletions
diff --git a/components/Control/Button.vue b/components/Control/Button.vue
new file mode 100644
index 0000000..efd91c2
--- /dev/null
+++ b/components/Control/Button.vue
@@ -0,0 +1,82 @@
+<script setup lang="ts">
+const props = defineProps({
+ type: {
+ type: String,
+ required: false,
+ default: 'text',
+ },
+ label: String,
+ icon: Array<String>,
+ disabled: Boolean,
+});
+
+const emit = defineEmits(['click']);
+
+const onClick = (event: MouseEvent) => {
+ if (!props.disabled) {
+ emit('click', event);
+ }
+};
+</script>
+
+<template>
+ <a id="button" :class="{text: type === 'text', solid: type === 'solid', disabled: disabled}" @click.stop="onClick">
+ <font-awesome-icon :icon="icon" />
+ {{ label }}
+ </a>
+</template>
+
+<style scoped>
+#button {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ user-select: none;
+}
+
+.text {
+ background-color: transparent;
+ color: var(--color-text-primary);
+ transition: color 0.3s;
+ font-weight: 700;
+ cursor: pointer;
+
+ &.disabled {
+ color: var(--color-text-mute);
+ cursor: not-allowed;
+ }
+
+ &:hover {
+ color: var(--color-primary-dark);
+ }
+
+ &.disabled:hover {
+ color: var(--color-text-mute);
+ }
+}
+
+.solid {
+ background-color: var(--color-primary);
+ transition: background-color 0.3s;
+ color: var(--color-text);
+ padding: 0.25rem 0.5rem;
+ border-radius: 4px;
+ font-weight: 700;
+ cursor: pointer;
+
+ &.disabled {
+ background-color: var(--color-border);
+ color: var(--color-text-mute);
+ cursor: not-allowed;
+ }
+
+ &:hover {
+ background-color: var(--color-primary-dark);
+ }
+
+ &.disabled:hover {
+ background-color: var(--color-border);
+ }
+}
+
+</style> \ No newline at end of file
diff --git a/components/Control/Checkbox.vue b/components/Control/Checkbox.vue
new file mode 100644
index 0000000..e0325e7
--- /dev/null
+++ b/components/Control/Checkbox.vue
@@ -0,0 +1,42 @@
+<script setup lang="ts">
+const model = defineModel();
+
+defineProps({
+ id: String,
+ label: String,
+ description: String,
+});
+
+</script>
+
+<template>
+ <div class="checkbox">
+ <label id="wrapper" :for="id">
+ <input :id="id" type="checkbox" v-model="model" />
+ <span id="label">{{ label }}</span>
+ <span id="description">{{ description }}</span>
+ </label>
+ </div>
+</template>
+
+<style scoped>
+#label {
+ display: block;
+ font-weight: bold;
+}
+
+#description {
+ display: block;
+ font-size: 0.8em;
+}
+
+input {
+ float: left;
+ margin: 5px 0 0 -20px;
+}
+
+.checkbox {
+ padding: 0 0 0 20px;
+}
+
+</style> \ No newline at end of file
diff --git a/components/Control/ItemStackForm.vue b/components/Control/ItemStackForm.vue
new file mode 100644
index 0000000..250e8c9
--- /dev/null
+++ b/components/Control/ItemStackForm.vue
@@ -0,0 +1,50 @@
+<script setup lang="ts">
+import { computed } from 'vue';
+import materials from '@/lib/materials';
+
+const model = defineModel<any>();
+
+if (typeof model.value !== 'object' || model.value === null) {
+ model.value = {};
+}
+
+const itemName = computed({
+ get() {
+ return model.value.name;
+ },
+ set(newValue: string) {
+ model.value.name = newValue;
+ },
+});
+
+const itemType = computed({
+ get() {
+ return model.value.type || model.value.material || model.value.item;
+ },
+ set(newValue: string) {
+ if (model.value.material) {
+ model.value.material = newValue;
+ } else if (model.value.item) {
+ model.value.item = newValue;
+ } else {
+ model.value.type = newValue;
+ }
+ },
+});
+</script>
+
+<template>
+ <div class="option-group">
+ <label for="itemstack-name">Name</label>
+ <input id="itemstack-name" name="itemstack-name" v-model="itemName" placeholder="Enter a display name" />
+ </div>
+
+ <div class="option-group">
+ <label for="itemstack-name">Type</label>
+ <multiselect v-model="itemType"
+ :options="materials" :searchable="true" placeholder="Choose a material" />
+ </div>
+</template>
+
+<style scoped>
+</style> \ No newline at end of file
diff --git a/components/Control/ItemStackModal.vue b/components/Control/ItemStackModal.vue
new file mode 100644
index 0000000..642c5f9
--- /dev/null
+++ b/components/Control/ItemStackModal.vue
@@ -0,0 +1,172 @@
+<script setup lang="ts">
+import Modal from '@/components/Control/Modal.vue';
+import Button from '@/components/Control/Button.vue';
+import { computed, ref } from 'vue';
+import materials from '@/lib/materials';
+import ItemStackForm from './ItemStackForm.vue';
+
+const model = defineModel();
+
+const emit = defineEmits(['confirm']);
+
+const props = defineProps({
+ value: String,
+});
+
+//TODO unshitify
+const value = ref<any>(props.value);
+
+const isQuestItem = computed(() => {
+ return value.value?.['quest-item'] !== undefined;
+});
+const isItemStack = computed(() => {
+ return (
+ typeof value.value === 'object'
+ && (
+ value.value?.item !== undefined
+ || value.value?.type !== undefined
+ || value.value?.material !== undefined
+ ))
+});
+const isMaterial = computed(() => {
+ return typeof value.value === 'string' && materials.includes(value.value)
+});
+
+const selectedType = ref(
+ isQuestItem.value
+ ? 'questitem'
+ : isItemStack.value
+ ? 'itemstack'
+ : isMaterial.value
+ ? 'material'
+ : ''
+);
+
+const noTypeSelected = computed(() => selectedType.value === '');
+const noValue = computed(() => !isQuestItem.value && !isItemStack.value && !isMaterial.value);
+
+const setSelectedType = (type: string) => {
+ if (type === 'questitem') {
+ value.value = {};
+ } else if (type === 'itemstack') {
+ value.value = {};
+ } else if (type === 'material') {
+ value.value = '';
+ }
+ selectedType.value = type;
+};
+
+const confirm = () => {
+ emit('confirm', value.value);
+};
+</script>
+
+<template>
+ <Modal v-model="model">
+ <template v-slot:header>
+ <h2>Edit ItemStack</h2>
+ </template>
+
+ <template v-slot:body>
+ <div id="type">
+ <span class="option" @click="setSelectedType('questitem')" :class="{selected: selectedType === 'questitem'}">
+ <span>
+ <font-awesome-icon :icon="['fas', 'fa-tag']" />
+ Quest Item
+ </span>
+ <p v-if="noTypeSelected">Re-use a quest item.</p>
+ </span>
+ <span class="option" @click="setSelectedType('itemstack')" :class="{selected: selectedType === 'itemstack'}">
+ <span>
+ <font-awesome-icon :icon="['fas', 'fa-cube']" />
+ ItemStack
+ </span>
+ <p v-if="noTypeSelected">Define a new item stack.</p>
+ </span>
+ <span class="option" @click="setSelectedType('material')" :class="{selected: selectedType === 'material'}">
+ <span>
+ <font-awesome-icon :icon="['fas', 'fa-apple-whole']" />
+ Material
+ </span>
+ <p v-if="noTypeSelected">Define a specific item type.</p>
+ </span>
+ </div>
+
+ <div id="material" class="option-group" v-if="selectedType === 'material'">
+ <label for="material">Material</label>
+ <multiselect v-model="value"
+ :options="materials" :searchable="true" placeholder="Enter material name" />
+ </div>
+
+ <div id="itemstack" class="option-group" v-if="selectedType === 'itemstack'">
+ <ItemStackForm v-model="value" />
+ </div>
+
+
+ <div id="confirm" class="control-group">
+ <Button
+ :icon="['fas', 'fa-times']"
+ :label="'Cancel'"
+ @click="model = false"
+ ></Button>
+ <Button
+ type="solid"
+ :icon="['fas', 'fa-check']"
+ :label="'Confirm'"
+ @click="confirm"
+ ></Button>
+ <!-- :disabled="noTypeSelected || noValue" -->
+ </div>
+ </template>
+ </Modal>
+</template>
+
+<style scoped>
+#confirm {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 1rem;
+}
+
+#type {
+ display: flex;
+ justify-content: space-around;
+ gap: 0.25rem;
+ user-select: none;
+ margin-bottom: 1rem;
+
+ .option {
+ border: 1px solid var(--color-border);
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ flex-basis: 0;
+ flex-grow: 1;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem;
+ background-color: var(--color-background-soft);
+ transition: background-color 0.3s;
+
+ span {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-weight: 700;
+ }
+
+ p {
+ text-align: center;
+ font-size: 0.8rem;
+ }
+
+ &:hover {
+ background-color: var(--color-hover);
+ }
+
+ &.selected {
+ background-color: var(--color-primary-mute);
+ }
+ }
+}
+</style> \ No newline at end of file
diff --git a/components/Control/ItemStackPicker.vue b/components/Control/ItemStackPicker.vue
new file mode 100644
index 0000000..d16090e
--- /dev/null
+++ b/components/Control/ItemStackPicker.vue
@@ -0,0 +1,100 @@
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import ItemStackModal from './ItemStackModal.vue';
+import materials from '@/lib/materials';
+
+const props = defineProps<{
+ value: any;
+}>();
+const emit = defineEmits(['update']);
+
+const value = ref(props.value);
+
+const showItemStackModal = ref(false);
+
+//TODO unshitify
+const empty = computed(() => {
+ return value.value === undefined
+ || value.value === null
+ || value.value === ''
+ || (Array.isArray(value.value) && value.value.length === 0)
+ || (typeof value.value === 'object' && Object.keys(value.value).length === 0);
+});
+const isQuestItem = computed(() => {
+ return value.value?.['quest-item'] !== undefined;
+});
+const isItemStack = computed(() => {
+ if (typeof value.value !== 'object' || value.value === null) {
+ return false;
+ }
+
+ const key = 'item' in value.value
+ ? 'item'
+ : 'type' in value.value
+ ? 'type'
+ : 'material'
+
+ return (!!value.value[key]);
+});
+const isMaterial = computed(() => {
+ return typeof value.value === 'string' && materials.includes(value.value)
+});
+
+const update = (newValue: any) => {
+ value.value = newValue;
+ showItemStackModal.value = false;
+ emit('update', value.value);
+};
+</script>
+
+<template>
+ <div class="itemstack" @click="showItemStackModal = true">
+ <span v-if="empty" class="empty">ItemStack...</span>
+ <span v-if="isQuestItem" class="item"><font-awesome-icon :icon="['fas', 'fa-tag']" /> Quest Item</span>
+ <span v-if="isItemStack" class="item"><font-awesome-icon :icon="['fas', 'fa-cube']" /> ItemStack of '{{ value.type || value.item || value.material }}'</span>
+ <span v-if="isMaterial" class="item"><font-awesome-icon :icon="['fas', 'fa-apple-whole']" /> {{ value }}</span>
+ <span v-if="!empty && !isQuestItem && !isItemStack && !isMaterial" class="invalid"><font-awesome-icon :icon="['fas', 'fa-triangle-exclamation']" /> Invalid ItemStack</span>
+ </div>
+
+ <ItemStackModal
+ v-model="showItemStackModal"
+ :value="value"
+ @confirm="update"
+ />
+</template>
+
+<style scoped>
+.itemstack {
+ display: flex;
+ width: 100%;
+ height: 100%;
+ cursor: pointer;
+ align-items: center;
+ padding: 0.5rem;
+ user-select: none;
+ transition: background-color 0.3s;
+ background-color: var(--color-background-soft);
+
+ span {
+ font-family: monospace;
+ font-size: 0.8rem;
+ }
+
+ .empty {
+ color: var(--color-text-mute);
+ }
+
+ .item {
+ color: var(--color-primary);
+ }
+
+ .invalid {
+ color: var(--color-false);
+ }
+
+ &:hover {
+ background-color: var(--color-hover);
+ }
+}
+
+</style> \ No newline at end of file
diff --git a/components/Control/Modal.vue b/components/Control/Modal.vue
new file mode 100644
index 0000000..46d5da5
--- /dev/null
+++ b/components/Control/Modal.vue
@@ -0,0 +1,68 @@
+<script setup lang="ts">
+const model = defineModel();
+
+</script>
+
+<template>
+ <div id="modal" class="modal" :class="{ 'is-active': model }">
+ <div class="modal-background" @click="model = false"></div>
+ <div class="modal-content">
+ <div class="header" v-if="$slots.header">
+ <slot name="header" />
+ </div>
+ <slot name="body" />
+ <slot />
+ </div>
+ </div>
+</template>
+
+<style scoped>
+#modal {
+ align-items: center;
+ justify-content: center;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 1000;
+ background-color: rgba(0, 0, 0, 0.5);
+ transition: opacity 0.3s;
+ display: none;
+ overflow: visible;
+}
+
+.modal-content {
+ background-color: var(--color-background);
+ border: 1px solid var(--color-border);
+ padding: 1rem;
+ width: 100%;
+ max-width: 600px;
+ max-height: 80%;
+ overflow-y: visible;
+ border-radius: 4px;
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
+}
+
+.modal-background {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.5);
+ z-index: -1;
+}
+
+.is-active {
+ opacity: 1 !important;
+ pointer-events: all !important;
+ display: flex !important;
+}
+
+.header {
+ border-bottom: 1px solid var(--color-border);
+ margin-bottom: 1rem;
+}
+
+</style> \ No newline at end of file
diff --git a/components/Control/TrueFalseSwitch.vue b/components/Control/TrueFalseSwitch.vue
new file mode 100644
index 0000000..a0a3392
--- /dev/null
+++ b/components/Control/TrueFalseSwitch.vue
@@ -0,0 +1,54 @@
+<script setup lang="ts">
+import { ref } from 'vue';
+
+const props = defineProps<{
+ value: boolean;
+}>();
+const emit = defineEmits(['update']);
+
+const value = ref(props.value);
+
+const invert = () => {
+ value.value = !value.value;
+ emit('update', value.value);
+};
+</script>
+
+<template>
+ <div class="switch" @click="invert">
+ <span v-if="value" class="true"><font-awesome-icon :icon="['fas', 'fa-check']" /> True</span>
+ <span v-else class="false"><font-awesome-icon :icon="['fas', 'fa-xmark']" /> False</span>
+ </div>
+</template>
+
+<style scoped>
+.switch {
+ display: flex;
+ width: 100%;
+ height: 100%;
+ cursor: pointer;
+ align-items: center;
+ padding: 0.5rem;
+ user-select: none;
+ transition: background-color 0.3s;
+ background-color: var(--color-background-soft);
+
+ span {
+ font-family: monospace;
+ font-size: 0.8rem;
+ }
+
+ .true {
+ color: var(--color-text-primary);
+ }
+
+ .false {
+ color: var(--color-false);
+ }
+
+ &:hover {
+ background-color: var(--color-hover);
+ }
+}
+
+</style> \ No newline at end of file
diff --git a/components/Editor/Category/CategoryChildrenOptionsPanel.vue b/components/Editor/Category/CategoryChildrenOptionsPanel.vue
new file mode 100644
index 0000000..6e96f64
--- /dev/null
+++ b/components/Editor/Category/CategoryChildrenOptionsPanel.vue
@@ -0,0 +1,53 @@
+<script setup lang="ts">
+import { useSessionStore, type EditorCategory } from '@/stores/session';
+import { computed } from 'vue';
+import EditorOptionsPanel from '../EditorOptionsPanel.vue';
+
+const props = defineProps<{
+ categoryId: string;
+}>();
+
+const sessionStore = useSessionStore();
+
+const category = computed(() => {
+ return sessionStore.getCategoryById(props.categoryId) as EditorCategory;
+});
+</script>
+
+<template>
+ <EditorOptionsPanel v-if="category">
+ <div id="options">
+ <h2>Quests in this category</h2>
+ <p>Drag to reorder.</p>
+ <div class="option-group">
+ </div>
+ </div>
+ </EditorOptionsPanel>
+</template>
+
+<style>
+#options {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.option-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.description {
+ font-size: 0.8em;
+}
+
+label {
+ font-weight: 700;
+}
+
+h2 {
+ border-bottom: 1px solid var(--color-border);
+}
+</style>
+
diff --git a/components/Editor/Category/CategoryOptionsPanel.vue b/components/Editor/Category/CategoryOptionsPanel.vue
new file mode 100644
index 0000000..f7d548c
--- /dev/null
+++ b/components/Editor/Category/CategoryOptionsPanel.vue
@@ -0,0 +1,54 @@
+<script setup lang="ts">
+import { useSessionStore, type EditorCategory } from '@/stores/session';
+import { computed } from 'vue';
+import EditorOptionsPanel from '../EditorOptionsPanel.vue';
+import Checkbox from '@/components/Control/Checkbox.vue';
+
+const props = defineProps<{
+ categoryId: string;
+}>();
+
+const sessionStore = useSessionStore();
+
+const category = computed(() => {
+ return sessionStore.getCategoryById(props.categoryId) as EditorCategory;
+});
+</script>
+
+<template>
+ <EditorOptionsPanel v-if="category">
+ <div id="options">
+ <div class="option-group">
+ <Checkbox id="category-permissionrequired" label="Require permission for category"
+ description="Players must have permission to open and start quests in this category." v-model="category.permissionRequired" />
+ </div>
+ </div>
+ </EditorOptionsPanel>
+</template>
+
+<style>
+#options {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.option-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.description {
+ font-size: 0.8em;
+}
+
+label {
+ font-weight: 700;
+}
+
+h2 {
+ border-bottom: 1px solid var(--color-border);
+}
+</style>
+
diff --git a/components/Editor/EditorOptionsPanel.vue b/components/Editor/EditorOptionsPanel.vue
new file mode 100644
index 0000000..1415d84
--- /dev/null
+++ b/components/Editor/EditorOptionsPanel.vue
@@ -0,0 +1,18 @@
+<script setup lang="ts">
+</script>
+
+<template>
+ <div id="options-panel">
+ <slot />
+ </div>
+</template>
+
+<style scoped>
+#options-panel {
+ width: 100%;
+ background-color: var(--color-background);
+ border: 1px solid var(--color-border);
+ padding: 1rem;
+ height: 100%;
+}
+</style> \ No newline at end of file
diff --git a/components/Editor/EditorPane.vue b/components/Editor/EditorPane.vue
new file mode 100644
index 0000000..bf9532a
--- /dev/null
+++ b/components/Editor/EditorPane.vue
@@ -0,0 +1,216 @@
+<script setup lang="ts">
+import { useSessionStore } from '@/stores/session';
+import { computed, ref } from 'vue';
+import { stripColorCodes } from '@/lib/util';
+import QuestOptionsPanel from '@/components/Editor/Quest/QuestOptionsPanel.vue';
+import QuestTasksOptionsPanel from '@/components/Editor/Quest/QuestTasksOptionsPanel.vue';
+import CategoryOptionsPanel from '@/components/Editor/Category/CategoryOptionsPanel.vue';
+import CategoryChildrenOptionsPanel from '@/components/Editor/Category/CategoryChildrenOptionsPanel.vue';
+import Button from '@/components/Control/Button.vue';
+import DeleteQuestModal from '@/components/Editor/Quest/Modal/DeleteQuestModal.vue';
+import RenameQuestModal from '@/components/Editor/Quest/Modal/RenameQuestModal.vue';
+import DuplicateQuestModal from '@/components/Editor/Quest/Modal/DuplicateQuestModal.vue';
+
+const sessionStore = useSessionStore();
+
+const selectedType = computed(() => sessionStore.editor.selected.type);
+const selectedId = computed(() => sessionStore.editor.selected.id);
+
+const selectedName = computed(() => {
+ if (selectedType.value === 'Quest' && selectedId.value) {
+ return sessionStore.getQuestById(selectedId.value)?.display.name;
+ } else if (selectedType.value === 'Category' && selectedId.value) {
+ return sessionStore.getCategoryById(selectedId.value)?.display.name;
+ } else {
+ return '';
+ }
+});
+
+const categoryFromSelectedQuest = computed(() => {
+ if (!selectedId.value || selectedType.value !== 'Quest') return null;
+
+ const quest = sessionStore.getQuestById(selectedId.value);
+ if (quest) {
+ return sessionStore.getCategoryById(quest.options.category) || null;
+ } else {
+ return null;
+ }
+});
+
+const showDeleteModal = ref(false);
+const showRenameModal = ref(false);
+const showDuplicateModal = ref(false);
+
+const renameQuest = (oldId: string, newId: string) => {
+ sessionStore.changeQuestId(oldId, newId);
+ sessionStore.editor.selected.id = newId;
+ showRenameModal.value = false;
+};
+
+const deleteQuest = (questId: string) => {
+ sessionStore.deleteQuest(questId);
+ sessionStore.setEditorSelected(null, null);
+ showDeleteModal.value = false;
+};
+
+const duplicateQuest = (oldId: string, newId: string) => {
+ sessionStore.duplicateQuest(oldId, newId);
+ sessionStore.editor.selected.id = newId;
+ showDuplicateModal.value = false;
+};
+</script>
+
+<template>
+ <div id="pane-container" v-if="!selectedId || !selectedType">
+ <h1 class="none-selected">Select a quest or category from the sidebar to continue</h1>
+ </div>
+ <div id="pane-container" v-if="selectedId && selectedType">
+ <div id="header">
+ <span id="path">
+ <template v-if="selectedType === 'Quest'">
+ <template v-if="categoryFromSelectedQuest">
+ <font-awesome-icon class="icon" :icon="['fas', 'fa-folder']"/>
+ {{ stripColorCodes(categoryFromSelectedQuest?.display.name) }}
+ <font-awesome-icon class="chevron" :icon="['fas', 'fa-chevron-right']"/>
+ </template>
+ <font-awesome-icon class="icon" :icon="['far', 'fa-compass']"/>
+ <span class="title">{{ stripColorCodes(selectedName!) }} </span>
+ <code>({{ selectedId }})</code>
+ </template>
+ <template v-if="selectedType === 'Category'">
+ <font-awesome-icon class="icon" :icon="['fas', 'fa-folder']"/>
+ <span class="title">{{ stripColorCodes(selectedName!) }} </span>
+ <code>({{ selectedId }})</code>
+ </template>
+ </span>
+ <span id="controls" class="control-group">
+ <Button
+ v-if="selectedType === 'Quest'"
+ :icon="['fas', 'fa-code']"
+ :label="'YAML'"
+ ></Button>
+ <Button
+ v-if="selectedType === 'Quest'"
+ :icon="['fas', 'fa-copy']"
+ :label="'Duplicate'"
+ @click="showDuplicateModal = true"
+ ></Button>
+ <Button
+ :icon="['fas', 'fa-pen']"
+ :label="'Rename'"
+ @click="showRenameModal = true"
+ ></Button>
+ <Button
+ :icon="['fas', 'fa-trash']"
+ :label="'Delete'"
+ @click="showDeleteModal = true"
+ ></Button>
+ <Button
+ type="solid"
+ :disabled="true"
+ :icon="['fas', 'fa-save']"
+ :label="'Save'"
+ ></Button>
+ </span>
+ </div>
+
+ <div id="options-container">
+ <QuestOptionsPanel v-if="selectedType === 'Quest'" :questId="selectedId" />
+ <QuestTasksOptionsPanel v-if="selectedType === 'Quest'" :questId="selectedId" />
+
+ <CategoryOptionsPanel v-if="selectedType === 'Category'" :categoryId="selectedId" />
+ <CategoryChildrenOptionsPanel v-if="selectedType === 'Category'" :categoryId="selectedId" />
+ </div>
+ </div>
+
+ <DeleteQuestModal
+ v-if="selectedType === 'Quest' && selectedId"
+ v-model="showDeleteModal"
+ :key="`delete-quest-${selectedId}`"
+ :questId="selectedId"
+ @delete="() => selectedId && deleteQuest(selectedId)"
+ />
+ <RenameQuestModal
+ v-if="selectedType === 'Quest' && selectedId"
+ v-model="showRenameModal"
+ :key="`rename-quest-${selectedId}`"
+ :questId="selectedId"
+ @update="newId => selectedId && renameQuest(selectedId, newId)"
+ />
+ <DuplicateQuestModal
+ v-if="selectedType === 'Quest' && selectedId"
+ v-model="showDuplicateModal"
+ :key="`duplicate-quest-${selectedId}`"
+ :questId="selectedId"
+ @duplicate="newId => selectedId && duplicateQuest(selectedId, newId)"
+ />
+</template>
+
+<style scoped>
+#header {
+ padding: 1rem 1rem 0.5rem 1rem;
+ background-color: var(--color-background);
+ border-bottom: 1px solid var(--color-border);
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
+ width: 100%;
+ height: 55px;
+ display: flex;
+ align-items: left;
+ justify-content: space-between;
+ gap: 1rem;
+
+ #path {
+ font-size: 1.2rem;
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+
+ .icon {
+ font-size: 0.8rem;
+ }
+
+ .chevron {
+ font-size: 0.8rem;
+ color: var(--color-text-mute);
+ }
+
+ .title {
+ font-weight: 700;
+ }
+
+ code {
+ font-size: 0.8rem;
+ color: var(--color-text-mute);
+ }
+ }
+}
+
+.none-selected {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem;
+ font-size: 1.2rem;
+ color: var(--color-text-mute);
+}
+
+#pane-container {
+ width: 100%;
+ flex-grow: 1;
+ height: calc(100vh - 73px);
+ max-height: calc(100vh - 73px);
+}
+
+#options-container {
+ width: 100%;
+ display: flex;
+ gap: 1rem;
+ padding: 1rem;
+ overflow: scroll;
+ max-height: calc(100% - 55px);
+}
+
+header {
+ border-bottom: 1px solid var(--color-border);
+}
+</style> \ No newline at end of file
diff --git a/components/Editor/EditorSidebar.vue b/components/Editor/EditorSidebar.vue
new file mode 100644
index 0000000..c9539fa
--- /dev/null
+++ b/components/Editor/EditorSidebar.vue
@@ -0,0 +1,28 @@
+<script setup lang="ts">
+import { useSessionStore } from '@/stores/session';
+import { storeToRefs } from 'pinia';
+import EditorSidebarCategory from '@/components/Editor/EditorSidebarCategory.vue';
+import EditorSidebarQuest from '@/components/Editor/EditorSidebarQuest.vue';
+
+const sessionStore = useSessionStore();
+
+const { session } = storeToRefs(sessionStore);
+</script>
+
+<template>
+ <div id="sidebar-container">
+ <EditorSidebarCategory v-for="category in session.categories" :key="category.id" :category="category" />
+ <EditorSidebarQuest v-for="quest in session.quests.filter((q) => (!session.categories.some((c) => c.id === q.options.category)))" :key="quest.id" :quest="quest" />
+ </div>
+</template>
+
+<style scoped>
+#sidebar-container {
+ width: 20rem;
+ border-right: 1px solid var(--color-border);
+ height: calc(100vh - 73px);
+ max-height: calc(100vh - 73px);
+ background-color: var(--color-background);
+ user-select: none;
+}
+</style> \ No newline at end of file
diff --git a/components/Editor/EditorSidebarCategory.vue b/components/Editor/EditorSidebarCategory.vue
new file mode 100644
index 0000000..7153b92
--- /dev/null
+++ b/components/Editor/EditorSidebarCategory.vue
@@ -0,0 +1,94 @@
+<script setup lang="ts">
+import { useSessionStore, type EditorCategory } from '@/stores/session';
+import { computed, ref, toRefs } from 'vue';
+import { stripColorCodes } from '@/lib/util';
+import EditorSidebarQuest from '@/components/Editor/EditorSidebarQuest.vue';
+
+const props = defineProps<{
+ category: EditorCategory;
+}>();
+
+const { category } = toRefs(props);
+
+const expanded = ref(true);
+
+const sessionStore = useSessionStore();
+
+const questsInCategory = computed(() => {
+ return sessionStore.getQuestsInCategory(category.value.id);
+});
+
+const expandCategory = () => {
+ expanded.value = !expanded.value;
+};
+
+const setSelectedCategory = () => {
+ sessionStore.setEditorSelected('Category', category.value.id);
+};
+
+const selected = computed(() => {
+ return sessionStore.editor.selected.type === 'Category' && sessionStore.editor.selected.id === category.value.id;
+});
+</script>
+
+<template>
+ <div id="category-container" :class="{selected: selected}">
+ <span id="category-title" @click="setSelectedCategory">
+ <font-awesome-icon @click.stop="expandCategory" class="category-icon" :icon="expanded ? ['fas', 'fa-caret-down'] : ['fas', 'fa-caret-up']"/>
+ <span id="category-name">
+ <span id="category-display-name">{{ stripColorCodes(category.display.name) }}</span>
+ <code id="category-display-id">{{ category.id }}</code>
+ </span>
+ </span>
+ </div>
+ <div v-if="expanded" id="quests">
+ <EditorSidebarQuest class="quest" v-for="quest in questsInCategory" :key="quest.id" :quest="quest" />
+ </div>
+</template>
+
+<style scoped>
+#category-container {
+ cursor: pointer;
+ padding: 0.5rem 1rem;
+ transition: background-color 0.3s;
+ border-bottom: 1px solid var(--color-border-soft);
+
+ #category-title {
+ display: flex;
+ align-items: center;
+ margin: 0;
+ gap: 1rem;
+ font-size: 1.1rem;
+
+ #category-name {
+ display: flex;
+ flex-direction: column;
+ align-items: left;
+
+ #category-display-id {
+ font-size: 0.8rem;
+ color: var(--color-text-mute);
+ }
+ }
+ }
+}
+
+.selected {
+ background-color: var(--color-primary-mute);
+}
+
+#quests {
+ background-color: var(--color-background-mute);
+ border-bottom: 1px solid var(--color-border-soft);
+}
+
+.quest {
+ margin: 0 0 0 1.4rem;
+ background-color: var(--color-background);
+ border-left: 1px solid var(--color-border);
+}
+
+#category-container:hover {
+ background-color: var(--color-hover);
+}
+</style> \ No newline at end of file
diff --git a/components/Editor/EditorSidebarQuest.vue b/components/Editor/EditorSidebarQuest.vue
new file mode 100644
index 0000000..baf06f1
--- /dev/null
+++ b/components/Editor/EditorSidebarQuest.vue
@@ -0,0 +1,68 @@
+<script setup lang="ts">
+import { useSessionStore, type EditorQuest } from '@/stores/session';
+import { computed, toRefs } from 'vue';
+import { stripColorCodes } from '@/lib/util';
+
+const props = defineProps<{
+ quest: EditorQuest;
+}>();
+
+const { quest } = toRefs(props);
+
+const sessionStore = useSessionStore();
+
+const setSelectedQuest = () => {
+ sessionStore.setEditorSelected('Quest', quest.value.id);
+};
+
+const selected = computed(() => {
+ return sessionStore.editor.selected.type === 'Quest' && sessionStore.editor.selected.id === quest.value.id;
+});
+</script>
+
+<template>
+ <div id="quest-container" @click.stop="setSelectedQuest" :class="{selected: selected}">
+ <span id="quest-title">
+ <font-awesome-icon class="quest-icon" :icon="['far', 'fa-compass']"/>
+ <span id="quest-name">
+ <span id="quest-display-name">{{ stripColorCodes(quest.display.name) }}</span>
+ <code id="quest-display-id">{{ quest.id }}</code>
+ </span>
+ </span>
+ </div>
+</template>
+
+<style scoped>
+#quest-container {
+ cursor: pointer;
+ padding: 0.3rem 1rem;
+ transition: background-color 0.3s;
+
+ #quest-title {
+ display: flex;
+ align-items: center;
+ margin: 0;
+ gap: 0.5rem;
+ font-size: 0.8rem;
+
+ #quest-name {
+ display: flex;
+ flex-direction: column;
+ align-items: left;
+
+ #quest-display-id {
+ font-size: 0.6rem;
+ color: var(--color-text-mute);
+ }
+ }
+ }
+}
+
+.selected {
+ background-color: var(--color-primary-mute) !important;
+}
+
+#quest-container:hover {
+ background-color: var(--color-hover);
+}
+</style> \ No newline at end of file
diff --git a/components/Editor/Quest/Modal/DeleteQuestModal.vue b/components/Editor/Quest/Modal/DeleteQuestModal.vue
new file mode 100644
index 0000000..d0b0c5a
--- /dev/null
+++ b/components/Editor/Quest/Modal/DeleteQuestModal.vue
@@ -0,0 +1,42 @@
+<script setup lang="ts">
+import Modal from '@/components/Control/Modal.vue';
+import Button from '@/components/Control/Button.vue';
+
+const model = defineModel();
+
+const emit = defineEmits(['delete']);
+
+defineProps({
+ questId: String,
+});
+</script>
+
+<template>
+ <Modal v-model="model">
+ <template v-slot:header>
+ <h2>Really delete quest '{{ questId }}'?</h2>
+ </template>
+ <p>Are you sure you want to delete this quest? The quests editor does not have undo functionality (yet)! </p>
+ <div id="confirm" class="control-group">
+ <Button
+ :icon="['fas', 'fa-times']"
+ :label="'Cancel'"
+ @click="model = false"
+ ></Button>
+ <Button
+ type="solid"
+ :icon="['fas', 'fa-trash']"
+ :label="'Delete'"
+ @click="emit('delete')"
+ ></Button>
+ </div>
+ </Modal>
+</template>
+
+<style scoped>
+#confirm {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 1rem;
+}
+</style> \ No newline at end of file
diff --git a/components/Editor/Quest/Modal/DuplicateQuestModal.vue b/components/Editor/Quest/Modal/DuplicateQuestModal.vue
new file mode 100644
index 0000000..bcd3782
--- /dev/null
+++ b/components/Editor/Quest/Modal/DuplicateQuestModal.vue
@@ -0,0 +1,69 @@
+<script setup lang="ts">
+import Modal from '@/components/Control/Modal.vue';
+import Button from '@/components/Control/Button.vue';
+import { computed, ref } from 'vue';
+import { useSessionStore } from '@/stores/session';
+
+const model = defineModel();
+
+const emit = defineEmits(['duplicate']);
+
+const props = defineProps({
+ questId: String,
+});
+
+const session = useSessionStore();
+
+const newQuestId = ref(props.questId);
+
+const isDuplicate = computed(() => {
+ return session.getQuestById(newQuestId.value!) !== undefined;
+});
+
+</script>
+
+<template>
+ <Modal v-model="model">
+ <template v-slot:header>
+ <h2>Duplicate '{{ questId }}'</h2>
+ </template>
+
+ <template v-slot:body>
+ <div id="body">
+ <div class="option-group">
+ <label for="new-type">ID of new quest</label>
+ <input id="new-type" name="new-type" type="text" v-model="newQuestId" />
+ </div>
+ <p v-if="isDuplicate" class="error-text">Name is not unique.</p>
+ <p>A Quest ID must be unique, alphanumeric, and not contain any spaces.</p>
+ <div id="confirm" class="control-group">
+ <Button
+ :icon="['fas', 'fa-times']"
+ :label="'Cancel'"
+ @click="model = false"
+ ></Button>
+ <Button
+ type="solid"
+ :icon="['fas', 'fa-check']"
+ :label="'Duplicate'"
+ :disabled="isDuplicate"
+ @click="emit('duplicate', newQuestId)"
+ ></Button>
+ </div>
+ </div>
+ </template>
+ </Modal>
+</template>
+
+<style scoped>
+#confirm {
+ display: flex;
+ justify-content: flex-end;
+}
+
+#body {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+</style> \ No newline at end of file
diff --git a/components/Editor/Quest/Modal/RenameQuestModal.vue b/components/Editor/Quest/Modal/RenameQuestModal.vue
new file mode 100644
index 0000000..2ad1481
--- /dev/null
+++ b/components/Editor/Quest/Modal/RenameQuestModal.vue
@@ -0,0 +1,69 @@
+<script setup lang="ts">
+import Modal from '@/components/Control/Modal.vue';
+import Button from '@/components/Control/Button.vue';
+import { computed, ref } from 'vue';
+import { useSessionStore } from '@/stores/session';
+
+const model = defineModel();
+
+const emit = defineEmits(['update']);
+
+const props = defineProps({
+ questId: String,
+});
+
+const session = useSessionStore();
+
+const newQuestId = ref(props.questId);
+
+const isDuplicate = computed(() => {
+ return session.getQuestById(newQuestId.value!) !== undefined;
+});
+
+</script>
+
+<template>
+ <Modal v-model="model">
+ <template v-slot:header>
+ <h2>Rename quest '{{ questId }}'</h2>
+ </template>
+
+ <template v-slot:body>
+ <div id="body">
+ <div class="option-group">
+ <label for="new-type">New quest ID</label>
+ <input id="new-type" name="new-type" type="text" v-model="newQuestId" />
+ </div>
+ <p v-if="isDuplicate" class="error-text">Name is not unique.</p>
+ <p>A Quest ID must be unique, alphanumeric, and not contain any spaces.</p>
+ <div id="confirm" class="control-group">
+ <Button
+ :icon="['fas', 'fa-times']"
+ :label="'Cancel'"
+ @click="model = false"
+ ></Button>
+ <Button
+ type="solid"
+ :icon="['fas', 'fa-check']"
+ :label="'Rename'"
+ :disabled="isDuplicate"
+ @click="emit('update', newQuestId)"
+ ></Button>
+ </div>
+ </div>
+ </template>
+ </Modal>
+</template>
+
+<style scoped>
+#confirm {
+ display: flex;
+ justify-content: flex-end;
+}
+
+#body {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+</style> \ No newline at end of file
diff --git a/components/Editor/Quest/QuestOptionsPanel.vue b/components/Editor/Quest/QuestOptionsPanel.vue
new file mode 100644
index 0000000..a462126
--- /dev/null
+++ b/components/Editor/Quest/QuestOptionsPanel.vue
@@ -0,0 +1,145 @@
+<script setup lang="ts">
+import { useSessionStore, type EditorQuest } from '@/stores/session';
+import { computed, ref } from 'vue';
+import EditorOptionsPanel from '@/components/Editor/EditorOptionsPanel.vue';
+import Checkbox from '@/components/Control/Checkbox.vue';
+
+const props = defineProps<{
+ questId: string;
+}>();
+
+const sessionStore = useSessionStore();
+
+const quest = computed(() => {
+ return sessionStore.getQuestById(props.questId) as EditorQuest;
+});
+const knownCategories = computed(() => {
+ return sessionStore.session.categories.map((category) => category.id);
+});
+const knownQuests = computed(() => {
+ return sessionStore.session.quests.map((quest) => quest.id);
+});
+
+</script>
+
+<template>
+ <EditorOptionsPanel v-if="quest">
+ <div id="options">
+ <div class="option-group">
+ <label for="quest-category">Category</label>
+ <multiselect
+ id="quest-category"
+ v-model="quest.options.category"
+ :options="knownCategories"
+ :searchable="true"
+ placeholder="No category"></multiselect>
+ </div>
+
+ <div class="option-group">
+ <label for="quest-requirements">Requirements</label>
+ <multiselect
+ id="quest-requirements"
+ v-model="quest.options.requirements"
+ :options="knownQuests"
+ :searchable="true"
+ :taggable="true"
+ :multiple="true"
+ placeholder="Add requirement"></multiselect>
+ <p class="description">
+ This quest will only be available if the player has completed all of the quests listed above.
+ </p>
+ </div>
+
+ <h2>Quest options</h2>
+
+ <div class="option-group">
+ <Checkbox id="quest-permissionrequired" label="Require permission to start quest"
+ description="Players must have permission to start the quest." v-model="quest.options.permissionRequired" />
+ </div>
+
+ <div class="option-group">
+ <Checkbox id="quest-cancellable" label="Allow players to cancel quest"
+ description="Players can cancel the quest after they have started it." v-model="quest.options.cancellable" />
+ </div>
+
+ <div class="option-group">
+ <Checkbox id="quest-countstowardslimit" label="Count towards quest limit"
+ description="Quest will count towards the player's quest started limit."
+ v-model="quest.options.countsTowardsLimit" />
+ </div>
+
+ <div class="option-group">
+ <Checkbox id="quest-repeatable" label="Allow players to repeat quest"
+ description="Quest can be completed again after it has been completed once."
+ v-model="quest.options.repeatable" />
+ </div>
+
+ <div class="option-group">
+ <Checkbox id="quest-autostart" label="Automatically start quest"
+ description="Quest will start automatically when the player has unlocked it."
+ v-model="quest.options.autostart" />
+ </div>
+
+
+ <h2>Cooldown</h2>
+
+ <div class="option-group">
+ <Checkbox id="quest-cooldown" label="Enable cooldown"
+ description="Players will have to wait a certain amount of time before they can start the quest again."
+ v-model="quest.options.cooldown.enabled" />
+ </div>
+
+ <div class="option-group">
+ <label for="quest-cooldown-time">
+ Cooldown (in seconds)
+ </label>
+ <input id="quest-cooldown-time" type="number" v-model="quest.options.cooldown.time"
+ :disabled="!quest.options.cooldown.enabled" />
+ <p class="description">
+ Common values are: <code>3600</code> (1 hour), <code>86400</code> (24 hours), <code>604800</code> (7 days),
+ <code>2592000</code> (30 days)
+ </p>
+ </div>
+
+ <h2>Time limit</h2>
+
+ <div class="option-group">
+ <Checkbox id="quest-timelimit" label="Enable time limit"
+ description="Players will be required to complete the quest within a certain amount of time, otherwise it will be automatically cancelled."
+ v-model="quest.options.timeLimit.enabled" />
+ </div>
+
+ <div class="option-group">
+ <label for="quest-timelimit-time">
+ Time limit (in seconds)
+ </label>
+ <input id="quest-timelimit-time" type="number" v-model="quest.options.timeLimit.time"
+ :disabled="!quest.options.timeLimit.enabled" />
+ <p class="description">
+ Common values are: <code>3600</code> (1 hour), <code>86400</code> (24 hours), <code>604800</code> (7 days),
+ <code>2592000</code> (30 days)
+ </p>
+ </div>
+ </div>
+ </EditorOptionsPanel>
+</template>
+
+<style src="vue-multiselect/dist/vue-multiselect.css" />
+
+<style>
+#options {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.description {
+ font-size: 0.8em;
+}
+
+h2 {
+ border-bottom: 1px solid var(--color-border);
+}
+
+</style>
+
diff --git a/components/Editor/Quest/QuestTasksOptionsPanel.vue b/components/Editor/Quest/QuestTasksOptionsPanel.vue
new file mode 100644
index 0000000..a79e636
--- /dev/null
+++ b/components/Editor/Quest/QuestTasksOptionsPanel.vue
@@ -0,0 +1,98 @@
+<script setup lang="ts">
+import { useSessionStore, type EditorQuest } from '@/stores/session';
+import { computed, ref } from 'vue';
+import EditorOptionsPanel from '@/components/Editor/EditorOptionsPanel.vue';
+import TaskConfiguration from '@/components/Editor/Quest/Task/TaskConfiguration.vue';
+import Button from '@/components/Control/Button.vue';
+import AddTaskModal from './Task/Modal/AddTaskModal.vue';
+
+const props = defineProps<{
+ questId: string;
+}>();
+
+const sessionStore = useSessionStore();
+
+const quest = computed(() => {
+ return sessionStore.getQuestById(props.questId) as EditorQuest;
+});
+
+const showAddTaskModal = ref(false);
+
+const addTask = (newId: string, newType: string) => {
+ sessionStore.getQuestById(props.questId)!.tasks[newId] = {
+ id: newId,
+ config: {
+ type: newType,
+ },
+ };
+
+ showAddTaskModal.value = false;
+};
+</script>
+
+<template>
+ <EditorOptionsPanel v-if="quest">
+ <div id="options">
+ <h2>Tasks <code>({{ Object.keys(quest.tasks).length }})</code></h2>
+
+ <p v-if="Object.keys(quest.tasks).length === 0" class="error-text">This quest does not have any tasks.</p>
+ <TaskConfiguration v-for="(task, taskId) in quest.tasks" :key="taskId" :taskId="String(taskId)" :quest="quest" />
+
+ <div id="controls">
+ <Button
+ id="add-task"
+ :icon="['fas', 'fa-plus']"
+ type="solid"
+ label="Add task"
+ @click="showAddTaskModal = true"
+ />
+ </div>
+ </div>
+ </EditorOptionsPanel>
+
+ <AddTaskModal
+ v-if="quest"
+ v-model="showAddTaskModal"
+ :questId="questId"
+ @add="addTask"
+ />
+</template>
+
+
+<style scoped>
+#options {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+
+ #controls {
+ display: flex;
+ justify-content: flex-end;
+ gap: 1rem;
+ }
+}
+
+.option-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.description {
+ font-size: 0.8em;
+}
+
+label {
+ font-weight: 700;
+}
+
+h2 {
+ border-bottom: 1px solid var(--color-border);
+
+ code {
+ font-size: 0.8em;
+ color: var(--color-text-mute);
+ }
+}
+</style>
+
diff --git a/components/Editor/Quest/Task/Modal/AddTaskModal.vue b/components/Editor/Quest/Task/Modal/AddTaskModal.vue
new file mode 100644
index 0000000..57139bb
--- /dev/null
+++ b/components/Editor/Quest/Task/Modal/AddTaskModal.vue
@@ -0,0 +1,90 @@
+<script setup lang="ts">
+import Modal from '@/components/Control/Modal.vue';
+import Button from '@/components/Control/Button.vue';
+import { computed, ref } from 'vue';
+import { useSessionStore } from '@/stores/session';
+import { validateTaskId } from '@/lib/util';
+
+const model = defineModel();
+
+const emit = defineEmits(['add']);
+
+const session = useSessionStore();
+
+const props = defineProps({
+ questId: {
+ type: String,
+ required: true,
+ },
+});
+
+const knownTasks = computed(() => session.getQuestById(props.questId)!.tasks);
+const knownTaskTypes = computed(() => session.getKnownTaskTypes());
+
+const newId = ref('');
+const newType = ref('');
+const unknownTaskType = computed(() => !knownTaskTypes.value.includes(newType.value));
+const invalidTaskId = computed(() => !validateTaskId(newId.value));
+const duplicateTaskId = computed(() => knownTasks.value[newId.value] !== undefined);
+
+const newTypeDescription = computed(() => session.getTaskDefinitionByTaskType(newType.value)?.description);
+</script>
+
+<template>
+ <Modal v-model="model">
+ <template v-slot:header>
+ <h2>Add new task</h2>
+ </template>
+
+ <template v-slot:body>
+ <div id="body">
+ <div class="option-group">
+ <label for="new-type">Task ID</label>
+ <input id="new-id" name="new-id" type="text" v-model="newId" />
+ <p v-if="invalidTaskId" class="error-text">Invalid task ID.</p>
+ <p v-if="duplicateTaskId" class="error-text">Task ID already exists.</p>
+ </div>
+ <div class="option-group">
+ <label for="new-type">Task type</label>
+ <multiselect
+ id="new-type"
+ v-model="newType"
+ :options="knownTaskTypes"
+ :searchable="true"
+ placeholder="Select a new type"
+ ></multiselect>
+ <p v-if="unknownTaskType" class="error-text">Invalid task type.</p>
+ </div>
+ <p v-if="newTypeDescription">{{ newTypeDescription }}</p>
+ <p>A task ID must be unique, alphanumeric, and not contain any spaces.</p>
+ <div id="confirm" class="control-group">
+ <Button
+ :icon="['fas', 'fa-times']"
+ :label="'Cancel'"
+ @click="model = false"
+ ></Button>
+ <Button
+ type="solid"
+ :icon="['fas', 'fa-check']"
+ :label="'Confirm'"
+ :disabled="unknownTaskType || invalidTaskId || duplicateTaskId"
+ @click="emit('add', newId, newType)"
+ ></Button>
+ </div>
+ </div>
+ </template>
+ </Modal>
+</template>
+
+<style scoped>
+#confirm {
+ display: flex;
+ justify-content: flex-end;
+}
+
+#body {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+</style> \ No newline at end of file
diff --git a/components/Editor/Quest/Task/Modal/ChangeTaskModal.vue b/components/Editor/Quest/Task/Modal/ChangeTaskModal.vue
new file mode 100644
index 0000000..c6b5921
--- /dev/null
+++ b/components/Editor/Quest/Task/Modal/ChangeTaskModal.vue
@@ -0,0 +1,77 @@
+<script setup lang="ts">
+import Modal from '@/components/Control/Modal.vue';
+import Button from '@/components/Control/Button.vue';
+import { computed, ref } from 'vue';
+import { useSessionStore } from '@/stores/session';
+
+const model = defineModel();
+
+const emit = defineEmits(['update']);
+
+const session = useSessionStore();
+
+const props = defineProps({
+ taskId: String,
+ currentTaskType: String,
+});
+
+const knownTaskTypes = computed(() => session.getKnownTaskTypes());
+
+const newType = ref('');
+const unknownTaskType = computed(() => !knownTaskTypes.value.includes(newType.value));
+const noChange = computed(() => newType.value === props.currentTaskType);
+const newTypeDescription = computed(() => session.getTaskDefinitionByTaskType(newType.value)?.description);
+</script>
+
+<template>
+ <Modal v-model="model">
+ <template v-slot:header>
+ <h2>Change the task type of '{{ taskId }}'</h2>
+ </template>
+
+ <template v-slot:body>
+ <div id="body">
+ <div class="option-group">
+ <label for="new-type">New type</label>
+ <multiselect
+ id="new-type"
+ v-model="newType"
+ :options="knownTaskTypes"
+ :searchable="true"
+ placeholder="Select a new type"
+ ></multiselect>
+ </div>
+ <p v-if="unknownTaskType" class="error-text">Invalid task type.</p>
+ <p v-if="newTypeDescription">{{ newTypeDescription }}</p>
+ <p>Any configured options for this task will be overwritten.</p>
+ <div id="confirm" class="control-group">
+ <Button
+ :icon="['fas', 'fa-times']"
+ :label="'Cancel'"
+ @click="model = false"
+ ></Button>
+ <Button
+ type="solid"
+ :icon="['fas', 'fa-check']"
+ :label="'Change'"
+ :disabled="unknownTaskType || noChange"
+ @click="emit('update', newType)"
+ ></Button>
+ </div>
+ </div>
+ </template>
+ </Modal>
+</template>
+
+<style scoped>
+#confirm {
+ display: flex;
+ justify-content: flex-end;
+}
+
+#body {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+</style> \ No newline at end of file
diff --git a/components/Editor/Quest/Task/TaskConfiguration.vue b/components/Editor/Quest/Task/TaskConfiguration.vue
new file mode 100644
index 0000000..0646ad4
--- /dev/null
+++ b/components/Editor/Quest/Task/TaskConfiguration.vue
@@ -0,0 +1,209 @@
+<script setup lang="ts">
+import { useSessionStore, type EditorQuest } from '@/stores/session';
+import { computed, ref } from 'vue';
+import Button from '@/components/Control/Button.vue';
+import TaskConfigurationRow from '@/components/Editor/Quest/Task/TaskConfigurationRow.vue';
+import ChangeTaskModal from './Modal/ChangeTaskModal.vue';
+
+const props = defineProps<{
+ taskId: string;
+ quest: EditorQuest;
+}>();
+
+const sessionStore = useSessionStore();
+
+const taskType = computed(() => props.quest.tasks[props.taskId].config.type);
+const taskDefintion = computed(() => sessionStore.getTaskDefinitionByTaskType(taskType.value));
+
+const taskConfig = computed(() => {
+ return Object.keys(props.quest.tasks[props.taskId].config).filter((fieldName) => fieldName !== 'type').reduce((acc, fieldName) => {
+ acc[fieldName] = props.quest.tasks[props.taskId].config[fieldName];
+ return acc;
+ }, {} as { [key: string]: any });
+});
+
+const requiredFields = computed(() => {
+ return Object.keys(taskDefintion.value.configuration).filter((fieldName) => taskDefintion.value.configuration[fieldName].required);
+});
+
+const givenRequiredFields = computed(() => {
+ return requiredFields.value.filter((fieldName) => taskConfig.value[fieldName]);
+});
+
+const missingFields = computed(() => {
+ return requiredFields.value.filter((fieldName) => !props.quest.tasks[props.taskId].config[fieldName]);
+});
+
+const remainingGivenFields = computed(() => {
+ return Object.keys(taskConfig.value).filter((fieldName) => !requiredFields.value.includes(fieldName));
+});
+
+const configKeysOptions = computed(() => Object.keys(taskDefintion.value.configuration).filter((key) => !Object.keys(taskConfig.value).some((fieldName) => fieldName === key)));
+// const configKeysOptions = computed(() => {
+// const keys = Object.keys(taskDefintion.value.configuration).filter((key) => !Object.keys(taskConfig.value).some((fieldName) => fieldName === key));
+//
+// return keys.map((key) => {
+// return {
+// value: key,
+// description: taskDefintion.value.configuration[key].description,
+// note: taskDefintion.value.configuration[key].note,
+// };
+// });
+// });
+
+const onAddOption = (option: any) => {
+ sessionStore.getQuestById(props.quest.id)!.tasks[props.taskId].config[option] = taskDefintion.value.configuration[option].default || null;
+};
+
+const updateValue = (fieldName: string, value: any) => {
+ sessionStore.getQuestById(props.quest.id)!.tasks[props.taskId].config[fieldName] = value;
+};
+
+const deleteValue = (fieldName: string) => {
+ delete sessionStore.getQuestById(props.quest.id)!.tasks[props.taskId].config[fieldName];
+};
+
+const showChangeModal = ref(false);
+
+const updateTaskType = (newType: string) => {
+ sessionStore.getQuestById(props.quest.id)!.tasks[props.taskId].config = {
+ type: newType
+ };
+ showChangeModal.value = false;
+}
+
+const deleteTaskType = (taskId: string) => {
+ delete sessionStore.getQuestById(props.quest.id)!.tasks[taskId];
+}
+</script>
+
+<template>
+ <div id="task-configuration-table">
+ <div id="task-header">
+ <p id="task-id">
+ <span id="task-name">
+ {{ props.taskId }}
+ </span>
+ <code>
+ ({{ taskType }})
+ </code>
+ </p>
+ <div id="task-controls" class="control-group">
+ <Button
+ :icon="['fas', 'fa-pen']"
+ :label="'Change'"
+ @click="showChangeModal = true"
+ ></Button>
+ <Button
+ :icon="['fas', 'fa-trash']"
+ :label="'Delete'"
+ @click="deleteTaskType(props.taskId)"
+ ></Button>
+ </div>
+ </div>
+ <div id="task-configuration">
+ <div v-if="!taskDefintion" class="error">
+ <font-awesome-icon id="error-icon" :icon="['fas', 'fa-triangle-exclamation']"/>
+ <p id="error-message">
+ Unable to edit task <code>{{ props.taskId }}</code>.
+ </p>
+ <p id="error-description">
+ The quests web editor does not know how to configure task
+ type <code>{{ taskType }}</code> as it has no task definition.
+ </p>
+ </div>
+
+ <div v-if="taskDefintion">
+ <TaskConfigurationRow
+ v-for="fieldName in [...givenRequiredFields, ...missingFields, ...remainingGivenFields]"
+ :key="`${quest.id}-${props.taskId}-${taskType}-${fieldName}`"
+ :required="requiredFields.includes(fieldName)"
+ :configKey="fieldName"
+ :initialValue="taskConfig[fieldName]"
+ :taskType="taskType"
+ :type="(taskDefintion.configuration[fieldName].type as string)"
+ @update="newValue => updateValue(fieldName, newValue)"
+ @delete="() => deleteValue(fieldName)"
+ />
+ <div id="add-option">
+ <multiselect
+ class="multiselect"
+ :options="configKeysOptions"
+ :searchable="true"
+ @select="onAddOption"
+ placeholder="Add option...">
+ </multiselect>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <ChangeTaskModal
+ v-model="showChangeModal"
+ :taskId="props.taskId"
+ :currentTaskType="taskType"
+ :key="`change-task-${props.taskId}`"
+ @update="updateTaskType"
+ />
+</template>
+
+<style scoped>
+.error {
+ padding: 0.5rem 0.5rem 0.5rem calc(0.5rem + 20px);
+
+ #error-icon {
+ float: left;
+ margin: 5px 0 0 -20px;
+ }
+
+ #error-message {
+ font-weight: 700;
+ }
+
+}
+
+#task-configuration-table {
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--color-border);
+
+ #task-header {
+ display: flex;
+ justify-content: space-between;
+ border-bottom: 1px solid var(--color-border);
+ background-color: var(--color-background-soft);
+ padding: 0.5rem;
+
+ #task-id {
+ font-size: 1.2em;
+
+ #task-name {
+ font-weight: 700;
+ }
+
+ code {
+ font-size: 0.8em;
+ color: var(--color-text-mute);
+ }
+ }
+ }
+}
+
+#add-option {
+ width: 100%;
+ border-right: 1px solid var(--color-border);
+ border-top: 1px solid var(--color-border);
+}
+
+.multiselect::v-deep .multiselect__tags {
+ border: none !important;
+ border-radius: 0px !important;
+ background: transparent !important;
+}
+
+.multiselect::v-deep .multiselect__select {
+ background: transparent !important;
+}
+
+</style>
+
diff --git a/components/Editor/Quest/Task/TaskConfigurationRow.vue b/components/Editor/Quest/Task/TaskConfigurationRow.vue
new file mode 100644
index 0000000..f68ce97
--- /dev/null
+++ b/components/Editor/Quest/Task/TaskConfigurationRow.vue
@@ -0,0 +1,195 @@
+<script setup lang="ts">
+import { useSessionStore } from '@/stores/session';
+import { computed, ref, toRefs, watch } from 'vue';
+import TrueFalseSwitch from '@/components/Control/TrueFalseSwitch.vue';
+import ItemStackPicker from '@/components/Control/ItemStackPicker.vue';
+import materials from '@/lib/materials';
+
+const props = defineProps({
+ taskType: {
+ type: String,
+ required: true,
+ },
+ configKey: {
+ type: String,
+ required: true,
+ },
+ initialValue: null,
+ type: String,
+ required: Boolean,
+});
+const emit = defineEmits(['update', 'delete']);
+
+const sessionStore = useSessionStore();
+
+const definition = computed(() => {
+ const def = sessionStore.getTaskDefinitionByTaskType(props.taskType).configuration[props.configKey];
+ return { description: def.description, note: def.note };
+});
+
+const { description, note } = toRefs(definition.value);
+const showDescription = ref(false);
+const currentValue = ref(props.initialValue ||
+ (props.type === 'boolean'
+ ? false
+ : (props.type === 'material-list' || props.type === 'string-list'
+ ? []
+ : props.type === 'itemstack'
+ ? null
+ : ''
+ )));
+
+if (props.initialValue !== currentValue.value) {
+ emit('update', currentValue.value);
+}
+
+const error = computed(() => currentValue.value === undefined || currentValue.value === null || currentValue.value === '' || (Array.isArray(currentValue.value) && currentValue.value.length === 0));
+const updateValue = (value: any) => {
+ currentValue.value = value;
+};
+
+watch(currentValue, () => {
+ emit('update', currentValue.value);
+});
+
+const addValue = (searchQuery: any) => {
+ currentValue.value.push(searchQuery);
+};
+
+</script>
+
+<template>
+ <div id="task-configuration-row">
+ <div id="key">
+ <div id="delete" @click="emit('delete')" v-if="!props.required" class="delete">
+ <font-awesome-icon :icon="['fas', 'fa-xmark']" />
+ </div>
+ <p id="name" @click="showDescription = !showDescription">{{ props.configKey }}</p>
+ </div>
+ <div id="value">
+ <div id="value-container">
+ <!-- Data type 'string' -->
+ <input v-if="props.type === 'string'" v-model="currentValue" />
+
+ <!-- Data type 'number' -->
+ <input v-else-if="props.type === 'number'" type="number" v-model="currentValue" />
+
+ <!-- Data type 'boolean' -->
+ <TrueFalseSwitch v-else-if="props.type === 'boolean'" :value="!!currentValue" @update="updateValue" />
+
+ <!-- Data type 'material-list' -->
+ <multiselect v-else-if="props.type === 'material-list'" v-model="currentValue"
+ :options="materials" :multiple="true" :taggable="true" :searchable="true" placeholder="Enter material name" />
+
+ <!-- Data type 'string-list' -->
+ <multiselect v-else-if="props.type === 'string-list'" v-model="currentValue" :options="[]" @tag="addValue"
+ :multiple="true" :taggable="true" :searchable="true" placeholder="Enter string" />
+
+ <!-- Data type 'itemstack' -->
+ <ItemStackPicker v-else-if="props.type === 'itemstack'" :value="currentValue" @update="updateValue" />
+
+ <div v-if="showDescription || error" id="task-configuration-row-info">
+ <p v-if="error" class="error">A value is required.</p>
+ <p>{{ description }} <i>{{ note }}</i></p>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<style scoped>
+#task-configuration-row {
+ display: flex;
+ flex-direction: row;
+ transition: background-color 0.3s;
+ border-bottom: 1px solid var(--color-border);
+
+ #key {
+ width: 25%;
+ background-color: var(--color-background);
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ user-select: none;
+
+ #delete {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 100%;
+ cursor: pointer;
+ color: var(--color-text-mute);
+ border-right: 1px solid var(--color-border);
+ background-color: var(--color-background-soft);
+ transition: color 0.3s;
+
+ &:hover {
+ color: var(--color-false);
+ }
+ }
+
+ #name {
+ display: flex;
+ align-items: center;
+ font-size: 0.8rem;
+ padding: 0.5rem;
+ width: 100%;
+ height: 100%;
+ font-family: monospace;
+ cursor: pointer;
+ transition: background-color 0.3s;
+
+ &:hover {
+ background-color: var(--color-hover);
+ }
+ }
+ }
+
+ #value {
+ width: 75%;
+ background-color: var(--color-background);
+ border-left: 1px solid var(--color-border);
+
+ #value-container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ }
+ }
+}
+
+#task-configuration-row:hover {
+ background-color: var(--color-hover);
+}
+
+#task-configuration-row-info {
+ padding: 0.25rem 0.5rem;
+ font-size: 0.8em;
+ background-color: var(--color-background);
+ border-top: 1px solid var(--color-border);
+}
+
+input {
+ width: 100%;
+ padding: 0.5rem;
+ border-radius: 0;
+ border: none;
+ font-family: monospace;
+ font-size: 0.8rem;
+ height: 40px;
+}
+
+.error {
+ color: var(--color-false);
+}
+
+.multiselect::v-deep .multiselect__tags {
+ border: unset !important;
+ border-radius: 0px !important;
+}
+
+.multiselect::v-deep .multiselect__select {
+ background: unset !important;
+}
+</style> \ No newline at end of file
diff --git a/components/Header/SiteHeader.vue b/components/Header/SiteHeader.vue
new file mode 100644
index 0000000..f51e970
--- /dev/null
+++ b/components/Header/SiteHeader.vue
@@ -0,0 +1,43 @@
+<template>
+ <header>
+ <div id="nav">
+ <img src="@/assets/quests-logo.png" alt="Quests logo" />
+ <h1>Quests Web Editor</h1>
+ <code>Preview</code>
+ </div>
+ </header>
+</template>
+
+<style lang="scss" scoped>
+
+#nav {
+ padding: 1rem 1rem 0.5rem 1rem;
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ max-height: 72px;
+ gap: 1rem;
+
+ img {
+ max-width: 3rem;
+ height: auto;
+ }
+
+ h1 {
+ font-size: 1.5rem;
+ margin: -5px 0 0 0;
+ font-weight: 700;
+ }
+
+ code {
+ font-size: 0.8rem;
+ color: var(--color-text-mute);
+ }
+
+}
+
+header {
+ border-bottom: 1px solid var(--color-border);
+}
+
+</style> \ No newline at end of file