diff options
Diffstat (limited to 'components/editor')
| -rw-r--r-- | components/editor/OptionsPanel.vue | 18 | ||||
| -rw-r--r-- | components/editor/Sidebar.vue | 28 | ||||
| -rw-r--r-- | components/editor/SidebarCategory.vue | 95 | ||||
| -rw-r--r-- | components/editor/SidebarQuest.vue | 68 | ||||
| -rw-r--r-- | components/editor/category/ChildrenOptionsPanel.vue | 51 | ||||
| -rw-r--r-- | components/editor/category/OptionsPanel.vue | 52 | ||||
| -rw-r--r-- | components/editor/quest/OptionsPanel.vue | 131 | ||||
| -rw-r--r-- | components/editor/quest/TasksOptionsPanel.vue | 84 | ||||
| -rw-r--r-- | components/editor/quest/modal/Delete.vue | 30 | ||||
| -rw-r--r-- | components/editor/quest/modal/Duplicate.vue | 58 | ||||
| -rw-r--r-- | components/editor/quest/modal/Rename.vue | 58 | ||||
| -rw-r--r-- | components/editor/task/Configuration.vue | 182 | ||||
| -rw-r--r-- | components/editor/task/ConfigurationRow.vue | 193 | ||||
| -rw-r--r-- | components/editor/task/modal/Change.vue | 61 | ||||
| -rw-r--r-- | components/editor/task/modal/Create.vue | 75 |
15 files changed, 1184 insertions, 0 deletions
diff --git a/components/editor/OptionsPanel.vue b/components/editor/OptionsPanel.vue new file mode 100644 index 0000000..1415d84 --- /dev/null +++ b/components/editor/OptionsPanel.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/Sidebar.vue b/components/editor/Sidebar.vue new file mode 100644 index 0000000..a46cdbe --- /dev/null +++ b/components/editor/Sidebar.vue @@ -0,0 +1,28 @@ +<script setup lang="ts"> +import { useSessionStore } from '@/stores/session'; +import { storeToRefs } from 'pinia'; + +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/SidebarCategory.vue b/components/editor/SidebarCategory.vue new file mode 100644 index 0000000..3e0db9b --- /dev/null +++ b/components/editor/SidebarCategory.vue @@ -0,0 +1,95 @@ +<script setup lang="ts"> +import { useSessionStore, type EditorCategory } from '@/stores/session'; +import { computed, ref, toRefs } from 'vue'; +import { stripColorCodes } from '@/lib/util'; + +const props = defineProps<{ + category: EditorCategory; +}>(); + +const { category } = toRefs(props); + +const expanded = ref(true); + +const sessionStore = useSessionStore(); +const route = useRoute(); + +const questsInCategory = computed(() => { + return sessionStore.getQuestsInCategory(category.value.id); +}); + +const expandCategory = () => { + expanded.value = !expanded.value; +}; + +const setSelectedCategory = () => { + navigateTo({ path: `/category/${category.value.id}` }) +}; + +const selected = computed(() => { + return route.path.startsWith('/category') && route.params.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/SidebarQuest.vue b/components/editor/SidebarQuest.vue new file mode 100644 index 0000000..a7b3e3f --- /dev/null +++ b/components/editor/SidebarQuest.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 route = useRoute(); + +const setSelectedQuest = () => { + navigateTo({ path: `/quest/${quest.value.id}` }) +}; + +const selected = computed(() => { + return route.path.startsWith('/quest') && route.params.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/category/ChildrenOptionsPanel.vue b/components/editor/category/ChildrenOptionsPanel.vue new file mode 100644 index 0000000..57dcef2 --- /dev/null +++ b/components/editor/category/ChildrenOptionsPanel.vue @@ -0,0 +1,51 @@ +<script setup lang="ts"> +import { useSessionStore, type EditorCategory } from '@/stores/session'; +import { computed } from '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/OptionsPanel.vue b/components/editor/category/OptionsPanel.vue new file mode 100644 index 0000000..112b063 --- /dev/null +++ b/components/editor/category/OptionsPanel.vue @@ -0,0 +1,52 @@ +<script setup lang="ts"> +import { useSessionStore, type EditorCategory } from '@/stores/session'; +import { computed } from '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/quest/OptionsPanel.vue b/components/editor/quest/OptionsPanel.vue new file mode 100644 index 0000000..de32abb --- /dev/null +++ b/components/editor/quest/OptionsPanel.vue @@ -0,0 +1,131 @@ +<script setup lang="ts"> +import { useSessionStore, type EditorQuest } from '@/stores/session'; +import { computed, ref } from '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/TasksOptionsPanel.vue b/components/editor/quest/TasksOptionsPanel.vue new file mode 100644 index 0000000..7742408 --- /dev/null +++ b/components/editor/quest/TasksOptionsPanel.vue @@ -0,0 +1,84 @@ +<script setup lang="ts"> +import { useSessionStore, type EditorQuest } from '@/stores/session'; +import { computed, ref } from '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> + <EditorTaskConfiguration 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/modal/Delete.vue b/components/editor/quest/modal/Delete.vue new file mode 100644 index 0000000..47c6388 --- /dev/null +++ b/components/editor/quest/modal/Delete.vue @@ -0,0 +1,30 @@ +<script setup lang="ts"> +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/Duplicate.vue b/components/editor/quest/modal/Duplicate.vue new file mode 100644 index 0000000..e089222 --- /dev/null +++ b/components/editor/quest/modal/Duplicate.vue @@ -0,0 +1,58 @@ +<script setup lang="ts"> +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/Rename.vue b/components/editor/quest/modal/Rename.vue new file mode 100644 index 0000000..7339219 --- /dev/null +++ b/components/editor/quest/modal/Rename.vue @@ -0,0 +1,58 @@ +<script setup lang="ts"> +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/task/Configuration.vue b/components/editor/task/Configuration.vue new file mode 100644 index 0000000..f408726 --- /dev/null +++ b/components/editor/task/Configuration.vue @@ -0,0 +1,182 @@ +<script setup lang="ts"> +import { useSessionStore, type EditorQuest } from '@/stores/session'; +import { computed, ref } from '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"> + <EditorTaskConfigurationRow + 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: any) => 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> + + <EditorTaskModalChange 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/task/ConfigurationRow.vue b/components/editor/task/ConfigurationRow.vue new file mode 100644 index 0000000..fa512b6 --- /dev/null +++ b/components/editor/task/ConfigurationRow.vue @@ -0,0 +1,193 @@ +<script setup lang="ts"> +import { useSessionStore } from '@/stores/session'; +import { computed, ref, toRefs, watch } from '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/editor/task/modal/Change.vue b/components/editor/task/modal/Change.vue new file mode 100644 index 0000000..db7d96b --- /dev/null +++ b/components/editor/task/modal/Change.vue @@ -0,0 +1,61 @@ +<script setup lang="ts"> +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/task/modal/Create.vue b/components/editor/task/modal/Create.vue new file mode 100644 index 0000000..d286759 --- /dev/null +++ b/components/editor/task/modal/Create.vue @@ -0,0 +1,75 @@ +<script setup lang="ts"> +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 |
