Basic goal editing / creation / deletion

Doesn't save yet
This commit is contained in:
Lordmau5 2023-11-19 14:01:28 +01:00
parent cc9282e661
commit fd4adedcb3
13 changed files with 566 additions and 19 deletions

4
components.d.ts vendored
View File

@ -7,8 +7,12 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
EditGoal: typeof import('./src/composables/EditGoal.vue')['default']
EditGoalDialog: typeof import('./src/composables/EditGoalDialog.vue')['default']
EditorComponent: typeof import('./src/components/EditorComponent.vue')['default']
GameList: typeof import('./src/composables/GameList.vue')['default']
GeneratorComponent: typeof import('./src/components/GeneratorComponent.vue')['default']
GoalEditorDialog: typeof import('./src/components/GoalEditorDialog.vue')['default']
MarkdownRenderer: typeof import('./src/components/MarkdownRenderer.vue')['default']
NavbarComponent: typeof import('./src/components/NavbarComponent.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']

View File

@ -14,6 +14,7 @@
},
"dependencies": {
"@quasar/extras": "^1.16.8",
"@sindresorhus/string-hash": "^2.0.0",
"@types/markdown-it": "^13.0.6",
"@vue/runtime-core": "^3.3.8",
"class-transformer": "^0.5.1",

15
pnpm-lock.yaml generated
View File

@ -8,6 +8,9 @@ dependencies:
'@quasar/extras':
specifier: ^1.16.8
version: 1.16.8
'@sindresorhus/string-hash':
specifier: ^2.0.0
version: 2.0.0
'@types/markdown-it':
specifier: ^13.0.6
version: 13.0.6
@ -791,6 +794,18 @@ packages:
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
dev: true
/@sindresorhus/fnv1a@3.1.0:
resolution: {integrity: sha512-KV321z5m/0nuAg83W1dPLy85HpHDk7Sdi4fJbwvacWsEhAh+rZUW4ZfGcXmUIvjZg4ss2bcwNlRhJ7GBEUG08w==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dev: false
/@sindresorhus/string-hash@2.0.0:
resolution: {integrity: sha512-eNmMOd5DZkiu9LxIeHdh1XvDbcpFXV4HdBqg9hlg8YNKDvE6qmHiJ+Vy+rFrzXofRYmtheNv4A3ESad8unxwwA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dependencies:
'@sindresorhus/fnv1a': 3.1.0
dev: false
/@stylistic/eslint-plugin-js@1.2.0:
resolution: {integrity: sha512-1Zi/AlQzOzTlTegupd3vrUYHd02ilvk7x5O9ZRFjYGtUcwHVk+WTEKk/3Nmr8yuvzEiXqUNFJ8bv8b4rLYCPRQ==}
dependencies:

View File

@ -205,4 +205,3 @@ watch(learn_more, newValue => {
selected_game.value = undefined;
});
</script>
@/js/lib/LocalGames

View File

View File

@ -0,0 +1,134 @@
<template>
<q-card flat bordered style="min-width: 700px; max-width: 80vw; min-height: 10vh; max-height: 80vh;">
<q-card-section class="q-gutter-y-md column">
<q-input
v-model="goal_name"
square
filled
counter
clearable
maxlength="40"
label="Name"
type="text"
></q-input>
<q-select
label="Select Tags"
square
filled
v-model="goal_tags"
use-input
use-chips
multiple
clearable
hide-dropdown-icon
input-debounce="0"
:options="all_tags"
@new-value="addTag"
></q-select>
</q-card-section>
<q-separator />
<q-card-actions>
<q-btn v-if="data.goal" flat color="red" icon="delete" :label="delete_label" @click="deleteGoal()" :disabled="!delete_enabled" />
<q-space/>
<q-btn flat color="red" icon="cancel" label="Cancel" @click="cancel()" />
<q-btn flat color="green" icon="save" label="Save" @click="emitGoal()" :disabled="!canSave()"/>
</q-card-actions>
</q-card>
</template>
<script setup lang="ts">
import BingoGoal from '@/js/lib/BingoGoal.ts';
import {
stringCompare
} from '@/js/lib/Util.ts';
import type BingoCategory from '@/js/lib/BingoCategory.ts';
import type BingoGoalList from '@/js/lib/BingoGoalList.ts';
const emit = defineEmits([
'cancel',
'deleteGoal',
'emitGoal'
]);
const {
data
} = defineProps<{
data: {
all_tags: string[],
goal?: BingoGoal,
},
}>();
const goal_name = ref('');
const all_tags: Ref<string[]> = ref([ ...data.all_tags ]);
const goal_tags: Ref<string[]> = ref([ ]);
goal_name.value = data.goal?.name ?? '';
goal_tags.value = data.goal?.tags ?? [];
const delete_label = ref('Delete (5)');
const delete_enabled = ref(false);
onMounted(() => {
let countdown = 5;
let interval = setInterval(() => {
if (--countdown <= 0) {
clearInterval(interval);
delete_label.value = 'Delete';
delete_enabled.value = true;
return;
}
else {
delete_label.value = `Delete (${ countdown })`;
}
}, 1000);
});
function canSave() {
return goal_name.value?.length > 0;
}
function addTag(value: string, done: Function) {
// Exit if tag already exists
if (goal_tags.value.some(tag => stringCompare(tag, value))) {
done();
return;
}
const tag = all_tags.value.find(tag => stringCompare(tag, value));
value = tag ?? value;
if (!data.all_tags.some(tag => stringCompare(tag, value))) {
all_tags.value.push(value);
}
done(value, 'toggle');
console.log('All tags', all_tags);
console.log('Goal tags', goal_tags);
}
function deleteGoal() {
emit('deleteGoal', data.goal);
}
function cancel() {
emit('cancel');
}
function emitGoal() {
const goal = new BingoGoal(goal_name.value);
goal.tags = goal_tags.value;
emit('emitGoal', goal);
}
</script>

View File

@ -0,0 +1,308 @@
<template>
<q-tree
:nodes="nodes"
node-key="label"
tick-strategy="leaf"
v-model:selected="selected"
v-model:ticked="ticked"
v-model:expanded="expanded"
>
<template v-slot:header-goal="prop">
<div class="row items-center">
<q-btn
v-if="editMode"
icon="edit"
flat
round
size="sm"
color="orange"
@click="openEditGoalDialog(prop.node)"
/>
<div>{{ prop.node.label }}</div>
<q-badge outline v-for="tag in prop.node.goal.tags" :key="tag" :color="getColorForString(tag)" class="q-ml-sm">
<div class="text-weight-bold q-my-xs">{{ tag }}</div>
</q-badge>
</div>
</template>
<template v-slot:header-goal-list="prop">
<div class="row items-center">
<q-btn v-if="editMode" icon="edit" flat round size="sm" color="orange"/>
<div>{{ prop.node.label }}</div>
<q-badge outline color="orange" class="q-ml-sm">
<div class="text-weight-bold q-my-xs">Goal List</div>
</q-badge>
</div>
</template>
<template v-slot:header-category="prop">
<div class="row items-center">
<q-btn v-if="editMode" icon="edit" flat round size="sm" color="orange"/>
<div>{{ prop.node.label }}</div>
<q-badge outline color="blue" class="q-ml-sm">
<div class="text-weight-bold q-my-xs">Category</div>
</q-badge>
</div>
</template>
<!-- Add Goal -->
<template v-slot:header-add-goal="prop">
<div class="row items-center">
<q-btn outline icon="add" label="Goal" color="light-blue" size="sm" @click="openEditGoalDialog(prop.node)"></q-btn>
</div>
</template>
<!-- Add Goal List -->
<template v-slot:header-add-goal-list="prop">
<div class="row items-center">
<q-btn outline icon="add" label="Goal List" color="light-blue" size="sm"></q-btn>
</div>
</template>
<!-- Add Category -->
<template v-slot:header-add-category="prop">
<div class="row items-center">
<q-btn outline icon="add" label="Category" color="light-blue" size="sm"></q-btn>
</div>
</template>
</q-tree>
<q-dialog v-model="edit_goal_dialog" persistent>
<EditGoalDialog
:data="edit_goal_data"
@cancel="edit_goal_dialog = false"
@delete-goal="delete_goal"
@emit-goal="emit_goal"
/>
</q-dialog>
</template>
<script setup lang="ts">
interface Node {
label: string;
header?: string; // 'goal' | 'goal-list' | 'category' | 'add-goal' | 'add-goal-list' | 'add-category'
children?: Node[];
goal?: BingoGoal;
parent?: BingoCategory | BingoGoalList;
tickable?: boolean;
noTick?: boolean;
}
import {
getColorForString
} from '@/js/lib/Util.ts';
import BingoCategory from '@/js/lib/BingoCategory.ts';
import type BingoGame from '@/js/lib/BingoGame.ts';
import BingoGoal from '@/js/lib/BingoGoal.ts';
import BingoGoalList from '@/js/lib/BingoGoalList.ts';
const props = defineProps<{
editMode: boolean;
game: BingoGame | undefined;
}>();
const editMode = props.editMode;
const selected = ref<Node[]>([]);
const ticked = ref<Node[]>([]);
const expanded = ref<Node[]>([]);
/* Edit Goal Dialog */
interface EditGoalData {
goal?: BingoGoal;
all_tags: string[];
parent_group: BingoCategory | BingoGoalList | undefined;
}
const edit_goal_data = ref<EditGoalData>({
all_tags: [] as string[],
parent_group: undefined
});
const edit_goal_dialog = ref(false);
const openEditGoalDialog = (node: Node) => {
edit_goal_data.value.goal = node.goal;
edit_goal_data.value.parent_group = node.parent;
edit_goal_data.value.all_tags = props.game?.getAllTags() ?? [];
edit_goal_dialog.value = true;
};
const delete_goal = (goal: BingoGoal) => {
// We always assume parent group is a goal list. You can't add goals to categories.
const goal_list = edit_goal_data.value.parent_group as BingoGoalList;
const index = goal_list.goals.findIndex(g => g === goal);
goal_list.goals.splice(index, 1);
// Reset values
edit_goal_data.value = {
goal: undefined,
all_tags: [],
parent_group: undefined
};
edit_goal_dialog.value = false;
};
const emit_goal = (goal: BingoGoal) => {
// We always assume parent group is a goal list. You can't add goals to categories.
const goal_list = edit_goal_data.value.parent_group as BingoGoalList;
// Add new goal
if (!edit_goal_data.value.goal) {
goal_list.goals.push(goal);
}
// Replace existing goal
else {
const index = goal_list.goals.findIndex(g => g === edit_goal_data.value.goal);
goal_list.goals.splice(index, 1, goal);
}
// Reset values
edit_goal_data.value = {
goal: undefined,
all_tags: [],
parent_group: undefined
};
edit_goal_dialog.value = false;
};
// const getAllGoals = (node: Node): BingoGoal[] => {
// const goals: BingoGoal[] = [];
// if (node.children) {
// for (const child of node.children) {
// goals.push(...getAllGoals(child));
// }
// }
// else if (node.goal) {
// goals.push(node.goal);
// }
// return goals;
// };
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// const selectByTag = (tag: string) => {
// ticked.value.length = 0;
// ticked.value.push(...nodes.value.filter(node => {
// return getAllGoals(node).map(goal => goal.tags.includes(tag));
// }));
// console.log(ticked.value);
// };
const nodes = computed(() => {
const results: Node[] = [];
if (!props.game) {
return results;
}
results.push(...props.game.items.map(item => {
const is_category = item instanceof BingoCategory;
const is_goal_list = item instanceof BingoGoalList;
const group_node: Node = {
label: item.name,
header: is_category
? 'category'
: is_goal_list
? 'goal-list'
: '',
children: [],
tickable: !editMode,
noTick: editMode
};
if (is_category) {
group_node.children = item.goal_lists.map(goal_list => {
const goal_list_node: Node = {
label: goal_list.name,
header: 'goal-list',
children: [],
tickable: !editMode,
noTick: editMode
};
goal_list_node.children?.push(...goal_list.goals.map(goal => {
const goal_node: Node = {
label: goal.name,
header: 'goal',
goal,
parent: goal_list,
tickable: !editMode,
noTick: editMode
};
return goal_node;
}));
if (editMode) {
goal_list_node.children?.push({
label: 'Add Goal',
header: 'add-goal',
parent: goal_list,
tickable: false,
noTick: true
});
}
return goal_list_node;
});
if (editMode) {
group_node.children?.push({
label: 'Add Goal List',
header: 'add-goal-list',
tickable: false,
noTick: true
});
}
}
else if (is_goal_list) {
group_node.children = item.goals.map(goal => {
const goal_node: Node = {
label: goal.name,
header: 'goal',
goal,
parent: item,
tickable: !editMode,
noTick: editMode
};
return goal_node;
});
if (editMode) {
group_node.children.push({
label: 'Add Goal',
header: 'add-goal',
parent: item,
tickable: false,
noTick: true
});
}
}
return group_node;
}));
if (editMode) {
results.push({
label: 'Add Goal List',
header: 'add-goal-list',
tickable: false,
noTick: true
});
results.push({
label: 'Add Category',
header: 'add-category',
tickable: false,
noTick: true
});
}
return results;
});
</script>

View File

@ -3,12 +3,14 @@ import BingoCategory from '@/js/lib/BingoCategory.ts';
import BingoGoal from '@/js/lib/BingoGoal.ts';
import BingoGoalList from '@/js/lib/BingoGoalList.ts';
import SuccessResponse from '@/js/lib/SuccessResponse.ts';
import Util from '@/js/lib/Util.ts';
import {
stringCompare
} from '@/js/lib/Util.ts';
import {
Transform, plainToInstance, type TransformFnParams, Exclude
} from 'class-transformer';
type BingoCategoryOrGoalList = BingoCategory | BingoGoalList;
export type BingoCategoryOrGoalList = BingoCategory | BingoGoalList;
export default class BingoGame {
id: string;
@ -86,7 +88,7 @@ export default class BingoGame {
removeItemByName(name: string): SuccessResponse {
const group = this.items.find(
g => Util.stringCompare(g.name, name)
g => stringCompare(g.name, name)
);
if (!group) {
return SuccessResponse.error('Category not found');
@ -95,6 +97,26 @@ export default class BingoGame {
return this.removeItem(group);
}
getAllTags(): string[] {
const tags: string[] = [];
this.items.map(group => {
const goal_lists = group instanceof BingoGoalList ? [ group ] : group.goal_lists;
goal_lists.map(subgroup => {
subgroup.goals.map(goal => {
goal.tags.map(tag => {
if (!tags.includes(tag)) {
tags.push(tag);
}
});
});
});
});
return tags;
}
getGoalsByTags(...tags: string[]): BingoGoal[] {
const goals: BingoGoal[] = [];
@ -105,7 +127,7 @@ export default class BingoGame {
subgroup.goals.map(goal => {
if (goal.tags.some(
tag => tags.some(
inputTag => Util.stringCompare(tag, inputTag)
inputTag => stringCompare(tag, inputTag)
)
)) {
goals.push(goal);

View File

@ -1,6 +1,8 @@
import SuccessResponse from '@/js/lib/SuccessResponse.js';
import Util from './Util';
import {
stringCompare
} from '@/js/lib/Util.ts';
export default class BingoGoal {
name: string;
@ -15,7 +17,7 @@ export default class BingoGoal {
addTag(tag: string): SuccessResponse {
if (this.tags.some(
existingTag => Util.stringCompare(existingTag, tag)
existingTag => stringCompare(existingTag, tag)
)) {
return SuccessResponse.error('Tag already exists');
}
@ -28,13 +30,13 @@ export default class BingoGoal {
removeTag(tag: string): SuccessResponse {
if (!this.tags.some(
existingTag => Util.stringCompare(existingTag, tag)
existingTag => stringCompare(existingTag, tag)
)) {
return SuccessResponse.error('Tag doesn\'t exist');
}
else {
this.tags = this.tags.filter(
existingTag => !Util.stringCompare(existingTag, tag)
existingTag => !stringCompare(existingTag, tag)
);
return SuccessResponse.success();

View File

@ -1,7 +1,33 @@
export default class Util {
static stringCompare(str1: string, str2: string): boolean {
return str1.localeCompare(str2, undefined, {
sensitivity: 'accent'
}) === 0;
}
const colors = [
'red',
'pink',
'purple',
'deep-purple',
'indigo',
'blue',
'light-blue',
'cyan',
'teal',
'green',
'light-green',
'lime',
'yellow',
'amber',
'orange',
'deep-orange',
'brown',
'grey',
'blue-grey'
];
import stringHash from '@sindresorhus/string-hash';
export function stringCompare(str1: string, str2: string): boolean {
return str1.localeCompare(str2, undefined, {
sensitivity: 'accent'
}) === 0;
}
export function getColorForString(input: string): string {
return colors[stringHash(input) % colors.length];
}

View File

@ -42,9 +42,9 @@ export const useLocalGamesStore = defineStore('localGames', () => {
// });
console.info('--- Finished fetching games!');
console.info('');
console.log(games.value);
console.info('');
}
async function saveGames() {

View File

@ -1,3 +1,35 @@
<template>
<EditorComponent/>
<!-- <EditorComponent/> -->
<GameList :game="yakuza" :edit-mode="true"/>
<!-- <q-dialog v-model="dialog">
<q-card style="min-width: 700px; max-width: 80vw; min-height: 10vh; max-height: 80vh;">
<GoalEditorDialog :tags="tags"/>
</q-card>
</q-dialog> -->
</template>
<script setup lang="ts">
import type BingoGame from '@/js/lib/BingoGame.ts';
import {
useLocalGamesStore
} from '@/stores/local-games.ts';
const localGames = useLocalGamesStore();
const yakuza = ref();
onMounted(async() => {
await localGames.fetchGames();
yakuza.value = localGames.games[0];
console.log(yakuza.value);
});
const dialog = ref<boolean>(false);
const selected_game = ref<BingoGame | undefined>();
</script>

View File

@ -22,7 +22,11 @@ export default defineConfig({
plugins: [
VueRouter(),
Components({
dts: true
dts: true,
dirs: [
'src/components',
'src/composables'
]
}),
AutoImport({
include: [