using a kebab options for deck tree node

This commit is contained in:
2026-06-28 23:25:44 -07:00
parent 9777a4229a
commit f94aeca4cc
4 changed files with 271 additions and 26 deletions
@@ -1,3 +1,4 @@
import db from "@/db/db-client"
import { FlashcardDeck } from "@/models" import { FlashcardDeck } from "@/models"
import BaseService from "@/services/base-service" import BaseService from "@/services/base-service"
@@ -7,7 +8,13 @@ export class DestroyService extends BaseService {
} }
async perform(): Promise<void> { async perform(): Promise<void> {
return this.flashcardDeck.destroy() await db.transaction(async (transaction) => {
await FlashcardDeck.update(
{ parentDeckId: null },
{ where: { parentDeckId: this.flashcardDeck.id }, transaction }
)
await this.flashcardDeck.destroy({ transaction })
})
} }
} }
@@ -0,0 +1,239 @@
<template>
<v-menu>
<template #activator="{ props: menuProps }">
<v-btn
icon
variant="text"
density="comfortable"
size="default"
v-bind="menuProps"
>
<v-icon icon="mdi-dots-vertical" />
</v-btn>
</template>
<v-list density="compact">
<v-list-item
prepend-icon="mdi-folder-plus-outline"
title="Add subdeck"
@click="openAddSubdeck"
/>
<v-list-item
prepend-icon="mdi-pencil-outline"
title="Edit deck"
@click="openEdit"
/>
<v-list-item
prepend-icon="mdi-delete-outline"
title="Delete this deck"
class="text-error"
@click="openDelete"
/>
</v-list>
</v-menu>
<v-dialog
v-model="showAddSubdeck"
width="400"
>
<v-form
ref="addFormRef"
@submit.prevent="createSubdeck"
>
<v-card>
<v-card-title>Add Sub-Deck</v-card-title>
<v-card-text>
<v-text-field
v-model="newDeckName"
label="Name"
autofocus
:rules="[required]"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
variant="text"
text="Cancel"
@click="showAddSubdeck = false"
/>
<v-btn
type="submit"
color="primary"
variant="flat"
:loading="isSubmitting"
text="Create"
/>
</v-card-actions>
</v-card>
</v-form>
</v-dialog>
<v-dialog
v-model="showEdit"
width="400"
>
<v-form
ref="editFormRef"
@submit.prevent="updateDeck"
>
<v-card>
<v-card-title>Edit Deck</v-card-title>
<v-card-text>
<v-text-field
v-model="editName"
label="Name"
autofocus
:rules="[required]"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
variant="text"
text="Cancel"
@click="showEdit = false"
/>
<v-btn
type="submit"
color="primary"
variant="flat"
:loading="isSubmitting"
text="Save"
/>
</v-card-actions>
</v-card>
</v-form>
</v-dialog>
<v-dialog
v-model="showDelete"
width="400"
>
<v-card>
<v-card-title>Delete Deck</v-card-title>
<v-card-text>Are you sure you want to delete this deck?</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
variant="text"
text="Cancel"
@click="showDelete = false"
/>
<v-btn
color="error"
variant="flat"
:loading="isSubmitting"
text="Delete"
@click="deleteDeck"
/>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref } from "vue"
import { VForm } from "vuetify/components"
import { required } from "@/utils/validators"
import flashcardDecksApi from "@/api/flashcard-decks-api"
import useSnack from "@/use/use-snack"
const props = defineProps<{
flashcardDeckId: number
}>()
const emit = defineEmits<{
created: [flashcardDeckId: number]
updated: [flashcardDeckId: number]
deleted: [flashcardDeckId: number]
}>()
const snack = useSnack()
const isSubmitting = ref(false)
// Add subdeck
const showAddSubdeck = ref(false)
const newDeckName = ref("")
const addFormRef = ref<InstanceType<typeof VForm> | null>(null)
function openAddSubdeck() {
newDeckName.value = ""
showAddSubdeck.value = true
}
async function createSubdeck() {
if (!addFormRef.value) return
const { valid } = await addFormRef.value.validate()
if (!valid) return
try {
isSubmitting.value = true
const { flashcardDeck } = await flashcardDecksApi.create({
name: newDeckName.value,
parentDeckId: props.flashcardDeckId,
})
emit("created", flashcardDeck.id)
showAddSubdeck.value = false
snack.success("Sub-deck created")
} catch {
snack.error("Failed to create sub-deck")
} finally {
isSubmitting.value = false
}
}
// Edit deck
const showEdit = ref(false)
const editName = ref("")
const editFormRef = ref<InstanceType<typeof VForm> | null>(null)
async function openEdit() {
try {
const { flashcardDeck } = await flashcardDecksApi.get(props.flashcardDeckId)
editName.value = flashcardDeck.name
showEdit.value = true
} catch {
snack.error("Failed to load deck")
}
}
async function updateDeck() {
if (!editFormRef.value) return
const { valid } = await editFormRef.value.validate()
if (!valid) return
try {
isSubmitting.value = true
await flashcardDecksApi.update(props.flashcardDeckId, { name: editName.value })
emit("updated", props.flashcardDeckId)
showEdit.value = false
snack.success("Deck updated")
} catch {
snack.error("Failed to update deck")
} finally {
isSubmitting.value = false
}
}
// Delete deck
const showDelete = ref(false)
function openDelete() {
showDelete.value = true
}
async function deleteDeck() {
try {
isSubmitting.value = true
await flashcardDecksApi.delete(props.flashcardDeckId)
emit("deleted", props.flashcardDeckId)
showDelete.value = false
snack.success("Deck deleted")
} catch {
snack.error("Failed to delete deck")
} finally {
isSubmitting.value = false
}
}
</script>
@@ -35,7 +35,9 @@
:selected-id="selectedId" :selected-id="selectedId"
@select="onSelect" @select="onSelect"
@move="handleMove" @move="handleMove"
@add-child="openCreateDialog" @created="fetch()"
@updated="fetch()"
@deleted="fetch()"
/> />
</template> </template>
</draggable> </draggable>
@@ -46,7 +48,7 @@
variant="text" variant="text"
prepend-icon="mdi-plus" prepend-icon="mdi-plus"
text="Add Deck" text="Add Deck"
@click="openCreateDialog(null)" @click="openCreateDialog"
/> />
<v-dialog <v-dialog
@@ -58,7 +60,7 @@
@submit.prevent="createDeck" @submit.prevent="createDeck"
> >
<v-card> <v-card>
<v-card-title>{{ activeParentId !== null ? "Add Sub-Deck" : "Add Deck" }}</v-card-title> <v-card-title>Add Deck</v-card-title>
<v-card-text> <v-card-text>
<v-text-field <v-text-field
v-model="newDeckName" v-model="newDeckName"
@@ -170,13 +172,11 @@ function onRootChange(event: { added?: { element: DeckNode } }) {
// --- Create --- // --- Create ---
const showCreateDialog = ref(false) const showCreateDialog = ref(false)
const activeParentId = ref<number | null>(null)
const newDeckName = ref("") const newDeckName = ref("")
const isCreating = ref(false) const isCreating = ref(false)
const formRef = ref<InstanceType<typeof VForm> | null>(null) const formRef = ref<InstanceType<typeof VForm> | null>(null)
function openCreateDialog(parentId: number | null) { function openCreateDialog() {
activeParentId.value = parentId
newDeckName.value = "" newDeckName.value = ""
showCreateDialog.value = true showCreateDialog.value = true
} }
@@ -190,7 +190,7 @@ async function createDeck() {
isCreating.value = true isCreating.value = true
await flashcardDecksApi.create({ await flashcardDecksApi.create({
name: newDeckName.value, name: newDeckName.value,
parentDeckId: activeParentId.value, parentDeckId: null,
}) })
showCreateDialog.value = false showCreateDialog.value = false
await fetch() await fetch()
@@ -30,20 +30,14 @@
> >
{{ deck.name }} {{ deck.name }}
</span> </span>
<v-btn <FlashcardDeckKebab
icon class="kebab-btn"
variant="text" :flashcard-deck-id="deck.id"
density="comfortable" @click.stop
size="default" @created="$emit('created', $event)"
title="Add sub-deck" @updated="$emit('updated', $event)"
class="add-btn" @deleted="$emit('deleted', $event)"
@click.stop="$emit('add-child', deck.id)"
>
<v-icon
size="default"
icon="mdi-plus"
/> />
</v-btn>
</div> </div>
<div <div
@@ -63,7 +57,9 @@
:selected-id="selectedId" :selected-id="selectedId"
@select="$emit('select', $event)" @select="$emit('select', $event)"
@move="$emit('move', $event)" @move="$emit('move', $event)"
@add-child="$emit('add-child', $event)" @created="$emit('created', $event)"
@updated="$emit('updated', $event)"
@deleted="$emit('deleted', $event)"
/> />
</template> </template>
</draggable> </draggable>
@@ -76,6 +72,7 @@ import { ref, computed } from "vue"
import draggable from "vuedraggable" import draggable from "vuedraggable"
import type { DeckNode } from "@/components/flashcard-decks/FlashcardDeckTree.vue" import type { DeckNode } from "@/components/flashcard-decks/FlashcardDeckTree.vue"
import FlashcardDeckKebab from "@/components/flashcard-decks/FlashcardDeckKebab.vue"
const props = defineProps<{ const props = defineProps<{
deck: DeckNode deck: DeckNode
@@ -87,7 +84,9 @@ const children = defineModel<DeckNode[]>("children", { required: true })
const emit = defineEmits<{ const emit = defineEmits<{
select: [deck: DeckNode] select: [deck: DeckNode]
move: [payload: { deckId: number; newParentId: number | null }] move: [payload: { deckId: number; newParentId: number | null }]
"add-child": [parentId: number] created: [flashcardDeckId: number]
updated: [flashcardDeckId: number]
deleted: [flashcardDeckId: number]
}>() }>()
const expanded = ref(false) const expanded = ref(false)
@@ -115,12 +114,12 @@ function onChange(event: { added?: { element: DeckNode } }) {
background: rgb(var(--v-theme-lightprimary)); background: rgb(var(--v-theme-lightprimary));
} }
.add-btn { .kebab-btn {
opacity: 0; opacity: 0;
transition: opacity 0.15s; transition: opacity 0.15s;
} }
.deck-node:hover .add-btn { .deck-node:hover .kebab-btn {
opacity: 1; opacity: 1;
} }
</style> </style>