diff options
| author | Leonardo Bishop <me@leonardobishop.com> | 2024-03-10 00:13:25 +0000 |
|---|---|---|
| committer | Leonardo Bishop <me@leonardobishop.com> | 2024-03-10 00:13:25 +0000 |
| commit | 9a11e0f4a38297006b89cc7bb2a60734111582e0 (patch) | |
| tree | 5ebddde79e67b659714b5dbdbfcea289f06a4ae5 /components | |
| parent | 817478f3cf357fc09778d9dc3cf67a667e21f042 (diff) | |
Migrate to nuxt
Diffstat (limited to 'components')
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 |
