aboutsummaryrefslogtreecommitdiffstats
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/Control/Button.vue54
-rw-r--r--src/components/Control/Checkbox.vue42
-rw-r--r--src/components/Control/TrueFalseSwitch.vue54
-rw-r--r--src/components/Editor/Category/CategoryChildrenOptionsPanel.vue53
-rw-r--r--src/components/Editor/Category/CategoryOptionsPanel.vue54
-rw-r--r--src/components/Editor/EditorOptionsPanel.vue18
-rw-r--r--src/components/Editor/EditorPane.vue157
-rw-r--r--src/components/Editor/EditorSidebar.vue28
-rw-r--r--src/components/Editor/EditorSidebarCategory.vue94
-rw-r--r--src/components/Editor/EditorSidebarQuest.vue68
-rw-r--r--src/components/Editor/Quest/QuestOptionsPanel.vue154
-rw-r--r--src/components/Editor/Quest/QuestTasksOptionsPanel.vue75
-rw-r--r--src/components/Editor/Quest/Task/TaskConfiguration.vue191
-rw-r--r--src/components/Editor/Quest/Task/TaskConfigurationRow.vue156
-rw-r--r--src/components/Header/SiteHeader.vue43
15 files changed, 1241 insertions, 0 deletions
diff --git a/src/components/Control/Button.vue b/src/components/Control/Button.vue
new file mode 100644
index 0000000..044cca1
--- /dev/null
+++ b/src/components/Control/Button.vue
@@ -0,0 +1,54 @@
+<script setup lang="ts">
+defineProps({
+ type: {
+ type: String,
+ required: false,
+ default: 'text',
+ },
+ label: String,
+ icon: Array<String>,
+});
+</script>
+
+<template>
+ <a id="button" :class="{text: type === 'text', solid: type === 'solid'}" >
+ <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;
+}
+
+.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;
+}
+
+.text:hover {
+ color: var(--color-primary-dark);
+}
+
+.solid:hover {
+ background-color: var(--color-primary-dark);
+}
+
+</style> \ No newline at end of file
diff --git a/src/components/Control/Checkbox.vue b/src/components/Control/Checkbox.vue
new file mode 100644
index 0000000..e0325e7
--- /dev/null
+++ b/src/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/src/components/Control/TrueFalseSwitch.vue b/src/components/Control/TrueFalseSwitch.vue
new file mode 100644
index 0000000..a0a3392
--- /dev/null
+++ b/src/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/src/components/Editor/Category/CategoryChildrenOptionsPanel.vue b/src/components/Editor/Category/CategoryChildrenOptionsPanel.vue
new file mode 100644
index 0000000..6e96f64
--- /dev/null
+++ b/src/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/src/components/Editor/Category/CategoryOptionsPanel.vue b/src/components/Editor/Category/CategoryOptionsPanel.vue
new file mode 100644
index 0000000..f7d548c
--- /dev/null
+++ b/src/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/src/components/Editor/EditorOptionsPanel.vue b/src/components/Editor/EditorOptionsPanel.vue
new file mode 100644
index 0000000..1415d84
--- /dev/null
+++ b/src/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/src/components/Editor/EditorPane.vue b/src/components/Editor/EditorPane.vue
new file mode 100644
index 0000000..6245e56
--- /dev/null
+++ b/src/components/Editor/EditorPane.vue
@@ -0,0 +1,157 @@
+<script setup lang="ts">
+import { useSessionStore } from '@/stores/session';
+import { computed } from 'vue';
+import { stripColourCodes } 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';
+
+const sessionStore = useSessionStore();
+
+const selectedType = computed(() => sessionStore.editor.selected.type);
+const selectedId = computed(() => sessionStore.editor.selected.id);
+const selectedName = computed(() => {
+ if (selectedType.value === 'Quest') {
+ return sessionStore.getQuestById(selectedId.value)?.display.name;
+ } else if (selectedType.value === 'Category') {
+ return sessionStore.getCategoryById(selectedId.value)?.display.name;
+ } else {
+ return '';
+ }
+});
+
+const categoryFromSelectedQuest = computed(() => {
+ const quest = sessionStore.getQuestById(selectedId.value);
+ if (quest) {
+ return sessionStore.getCategoryById(quest.options.category) || null;
+ } else {
+ return null;
+ }
+});
+</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']"/>
+ {{ stripColourCodes(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">{{ stripColourCodes(selectedName!) }} </span>
+ <code>({{ selectedId }})</code>
+ </template>
+ <template v-if="selectedType === 'Category'">
+ <font-awesome-icon class="icon" :icon="['fas', 'fa-folder']"/>
+ <span class="title">{{ stripColourCodes(selectedName!) }} </span>
+ <code>({{ selectedId }})</code>
+ </template>
+ </span>
+ <span id="controls">
+ <Button
+ v-if="selectedType === 'Quest'"
+ :icon="['fas', 'fa-code']"
+ :label="'YAML'"
+ ></Button>
+ <Button
+ :icon="['fas', 'fa-pen']"
+ :label="'Rename'"
+ ></Button>
+ <Button
+ :icon="['fas', 'fa-trash']"
+ :label="'Delete'"
+ ></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>
+</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);
+ }
+ }
+
+ #controls {
+ display: flex;
+ gap: 1rem;
+ }
+}
+
+.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/src/components/Editor/EditorSidebar.vue b/src/components/Editor/EditorSidebar.vue
new file mode 100644
index 0000000..c9539fa
--- /dev/null
+++ b/src/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/src/components/Editor/EditorSidebarCategory.vue b/src/components/Editor/EditorSidebarCategory.vue
new file mode 100644
index 0000000..932b36b
--- /dev/null
+++ b/src/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 { stripColourCodes } 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">{{ stripColourCodes(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/src/components/Editor/EditorSidebarQuest.vue b/src/components/Editor/EditorSidebarQuest.vue
new file mode 100644
index 0000000..08f1625
--- /dev/null
+++ b/src/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 { stripColourCodes } 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">{{ stripColourCodes(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/src/components/Editor/Quest/QuestOptionsPanel.vue b/src/components/Editor/Quest/QuestOptionsPanel.vue
new file mode 100644
index 0000000..3495d60
--- /dev/null
+++ b/src/components/Editor/Quest/QuestOptionsPanel.vue
@@ -0,0 +1,154 @@
+<script setup lang="ts">
+import { useSessionStore, type EditorQuest } from '@/stores/session';
+import { computed } 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;
+}
+
+.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/src/components/Editor/Quest/QuestTasksOptionsPanel.vue b/src/components/Editor/Quest/QuestTasksOptionsPanel.vue
new file mode 100644
index 0000000..12b1263
--- /dev/null
+++ b/src/components/Editor/Quest/QuestTasksOptionsPanel.vue
@@ -0,0 +1,75 @@
+<script setup lang="ts">
+import { useSessionStore, type EditorQuest } from '@/stores/session';
+import { computed } 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';
+
+const props = defineProps<{
+ questId: string;
+}>();
+
+const sessionStore = useSessionStore();
+
+const quest = computed(() => {
+ return sessionStore.getQuestById(props.questId) as EditorQuest;
+});
+</script>
+
+<template>
+ <EditorOptionsPanel v-if="quest">
+ <div id="options">
+ <h2>Tasks <code>({{ Object.keys(quest.tasks).length }})</code></h2>
+
+ <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"
+ />
+ </div>
+ </div>
+ </EditorOptionsPanel>
+</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/src/components/Editor/Quest/Task/TaskConfiguration.vue b/src/components/Editor/Quest/Task/TaskConfiguration.vue
new file mode 100644
index 0000000..5c6613a
--- /dev/null
+++ b/src/components/Editor/Quest/Task/TaskConfiguration.vue
@@ -0,0 +1,191 @@
+<script setup lang="ts">
+import { useSessionStore, type EditorQuest } from '@/stores/session';
+import { computed } from 'vue';
+import Button from '@/components/Control/Button.vue';
+import TaskConfigurationRow from '@/components/Editor/Quest/Task/TaskConfigurationRow.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];
+};
+
+</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">
+ <Button
+ :icon="['fas', 'fa-pen']"
+ :label="'Change'"
+ ></Button>
+ <Button
+ :icon="['fas', 'fa-trash']"
+ :label="'Delete'"
+ ></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="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>
+</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);
+ }
+ }
+
+ #task-controls {
+ display: flex;
+ gap: 1rem;
+ }
+ }
+}
+
+#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/src/components/Editor/Quest/Task/TaskConfigurationRow.vue b/src/components/Editor/Quest/Task/TaskConfigurationRow.vue
new file mode 100644
index 0000000..fb872a8
--- /dev/null
+++ b/src/components/Editor/Quest/Task/TaskConfigurationRow.vue
@@ -0,0 +1,156 @@
+<script setup lang="ts">
+import { useSessionStore } from '@/stores/session';
+import { computed, ref, toRefs, watch } from 'vue';
+import TrueFalseSwitch from '@/components/Control/TrueFalseSwitch.vue';
+import materials from '@/data/materials.json';
+
+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);
+
+const error = computed(() => currentValue.value === undefined || currentValue.value === null || currentValue.value === '');
+const updateValue = (value: any) => {
+ currentValue.value = value;
+};
+
+watch(currentValue, () => {
+ emit('update', currentValue.value);
+});
+
+</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">
+ <input v-if="props.type === 'string'" v-model="currentValue" />
+ <input v-else-if="props.type === 'number'" type="number" v-model="currentValue" />
+ <TrueFalseSwitch v-else-if="props.type === 'boolean'" :value="!!currentValue" @update="updateValue" />
+ <multiselect v-else-if="props.type === 'material-list'" :value="currentValue" :options="materials"
+ :multiple="true" :taggable="true" :searchable="true" placeholder="Enter material name"></multiselect>
+ </div>
+ <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>
+</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);
+ }
+}
+
+#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/src/components/Header/SiteHeader.vue b/src/components/Header/SiteHeader.vue
new file mode 100644
index 0000000..f51e970
--- /dev/null
+++ b/src/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