Compare commits

..

7 Commits

Author SHA1 Message Date
burkkyy 0595285c25 Destory deck helper
Build and Push Docker Image / build (push) Successful in 2m58s
2026-06-29 00:17:15 -07:00
burkkyy 39afe80a6c Exit button 2026-06-29 00:06:25 -07:00
burkkyy 6161720955 Adding prog to review page 2026-06-29 00:04:27 -07:00
burkkyy b7eaa2ad74 Big quaility of life changes 2026-06-28 23:58:29 -07:00
burkkyy 4347cb9c91 Edit and delete flashcards! 2026-06-28 23:37:26 -07:00
burkkyy 6f68049120 Adding deck title to cards page 2026-06-28 23:29:07 -07:00
burkkyy f94aeca4cc using a kebab options for deck tree node 2026-06-28 23:25:44 -07:00
8 changed files with 564 additions and 32 deletions
@@ -1,4 +1,5 @@
import { FlashcardDeck } from "@/models" import db from "@/db/db-client"
import { Flashcard, FlashcardDeck } from "@/models"
import BaseService from "@/services/base-service" import BaseService from "@/services/base-service"
export class DestroyService extends BaseService { export class DestroyService extends BaseService {
@@ -7,7 +8,20 @@ export class DestroyService extends BaseService {
} }
async perform(): Promise<void> { async perform(): Promise<void> {
return this.flashcardDeck.destroy() await db.transaction(async () => {
await FlashcardDeck.update(
{ parentDeckId: null },
{ where: { parentDeckId: this.flashcardDeck.id } }
)
await Flashcard.destroy({
where: {
flashcardDeckId: this.flashcardDeck.id,
},
})
await this.flashcardDeck.destroy()
})
} }
} }
@@ -0,0 +1,256 @@
<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-book-open-blank-variant-outline"
title="Start Review"
@click="goToReviewPage"
/>
<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 { useRouter } from "vue-router"
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)
const router = useRouter()
function goToReviewPage() {
router.push({
name: "FlashcardDeckReviewPage",
params: {
flashcardDeckId: props.flashcardDeckId,
},
})
}
// 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="onDeckUpdated"
@deleted="onDeckDeleted"
/> />
</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"
@@ -140,13 +142,27 @@ watch(
// --- Selection --- // --- Selection ---
const selectedId = ref<number | null>(null) const selectedId = ref<number | null>(null)
const emit = defineEmits<{ select: [deck: DeckNode] }>() const emit = defineEmits<{
select: [deck: DeckNode]
updated: [flashcardDeckId: number]
deleted: [flashcardDeckId: number]
}>()
function onSelect(deck: DeckNode) { function onSelect(deck: DeckNode) {
selectedId.value = deck.id selectedId.value = deck.id
emit("select", deck) emit("select", deck)
} }
async function onDeckUpdated(flashcardDeckId: number) {
await fetch()
emit("updated", flashcardDeckId)
}
async function onDeckDeleted(flashcardDeckId: number) {
await fetch()
emit("deleted", flashcardDeckId)
}
// --- Drag & Drop --- // --- Drag & Drop ---
const snack = useSnack() const snack = useSnack()
@@ -170,13 +186,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 +204,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()
@@ -14,9 +14,7 @@
@click.stop="expanded = !expanded" @click.stop="expanded = !expanded"
> >
<v-icon size="default"> <v-icon size="default">
{{ {{ expanded ? "mdi-chevron-down" : "mdi-chevron-right" }}
hasChildren ? (expanded ? "mdi-chevron-down" : "mdi-chevron-right") : "mdi-circle-small"
}}
</v-icon> </v-icon>
</v-btn> </v-btn>
<v-icon <v-icon
@@ -30,20 +28,17 @@
> >
{{ deck.name }} {{ deck.name }}
</span> </span>
<v-btn <div
icon class="kebab-btn"
variant="text" @click.stop
density="comfortable"
size="default"
title="Add sub-deck"
class="add-btn"
@click.stop="$emit('add-child', deck.id)"
> >
<v-icon <FlashcardDeckKebab
size="default" :flashcard-deck-id="deck.id"
icon="mdi-plus" @created="$emit('created', $event)"
@updated="$emit('updated', $event)"
@deleted="$emit('deleted', $event)"
/> />
</v-btn> </div>
</div> </div>
<div <div
@@ -63,7 +58,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 +73,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 +85,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 +115,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>
@@ -1,20 +1,40 @@
<template> <template>
<v-card class="pa-5"> <v-card class="pa-5 flashcard-card">
<div <div
class="face-content text-h5 text-center" class="face-content text-h5 text-center"
v-html="renderMarkdown(flashcard.front)" v-html="renderMarkdown(flashcard.front)"
/> />
<div
class="kebab-wrapper"
@click.stop
>
<FlashcardKebab
:flashcard="flashcard"
@updated="$emit('updated', $event)"
@deleted="$emit('deleted', $event)"
/>
</div>
</v-card> </v-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { type Flashcard } from "@/api/flashcards-api" import { type Flashcard } from "@/api/flashcards-api"
import renderMarkdown from "@/utils/render-markdown" import renderMarkdown from "@/utils/render-markdown"
import FlashcardKebab from "@/components/flashcards/FlashcardKebab.vue"
defineProps<{ flashcard: Flashcard }>() defineProps<{ flashcard: Flashcard }>()
defineEmits<{
updated: [flashcard: Flashcard]
deleted: [flashcardId: number]
}>()
</script> </script>
<style scoped> <style scoped>
.flashcard-card {
position: relative;
}
.face-content { .face-content {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -24,4 +44,16 @@ defineProps<{ flashcard: Flashcard }>()
line-height: 1.5; line-height: 1.5;
white-space: pre-line; white-space: pre-line;
} }
.kebab-wrapper {
position: absolute;
top: 4px;
right: 4px;
opacity: 0;
transition: opacity 0.15s;
}
.flashcard-card:hover .kebab-wrapper {
opacity: 1;
}
</style> </style>
@@ -0,0 +1,167 @@
<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-pencil-outline"
title="Edit"
@click="openEdit"
/>
<v-list-item
prepend-icon="mdi-delete-outline"
title="Delete"
class="text-error"
@click="showDelete = true"
/>
</v-list>
</v-menu>
<v-dialog
v-model="showEdit"
width="500"
>
<v-form
ref="editFormRef"
@submit.prevent="updateFlashcard"
>
<v-card>
<v-card-title>Edit Flashcard</v-card-title>
<v-card-text class="d-flex flex-column ga-2">
<v-textarea
v-model="editFront"
label="Front"
rows="3"
auto-grow
:rules="[required]"
/>
<v-textarea
v-model="editBack"
label="Back"
rows="3"
auto-grow
: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 Flashcard</v-card-title>
<v-card-text>Are you sure you want to delete this flashcard?</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="deleteFlashcard"
/>
</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 flashcardsApi, { type Flashcard } from "@/api/flashcards-api"
import useSnack from "@/use/use-snack"
const props = defineProps<{ flashcard: Flashcard }>()
const emit = defineEmits<{
updated: [flashcard: Flashcard]
deleted: [flashcardId: number]
}>()
const snack = useSnack()
const isSubmitting = ref(false)
// Edit
const showEdit = ref(false)
const editFront = ref("")
const editBack = ref("")
const editFormRef = ref<InstanceType<typeof VForm> | null>(null)
function openEdit() {
editFront.value = props.flashcard.front
editBack.value = props.flashcard.back
showEdit.value = true
}
async function updateFlashcard() {
if (!editFormRef.value) return
const { valid } = await editFormRef.value.validate()
if (!valid) return
try {
isSubmitting.value = true
const { flashcard } = await flashcardsApi.update(props.flashcard.id, {
front: editFront.value,
back: editBack.value,
})
emit("updated", flashcard)
showEdit.value = false
snack.success("Flashcard updated")
} catch {
snack.error("Failed to update flashcard")
} finally {
isSubmitting.value = false
}
}
// Delete
const showDelete = ref(false)
async function deleteFlashcard() {
try {
isSubmitting.value = true
await flashcardsApi.delete(props.flashcard.id)
emit("deleted", props.flashcard.id)
showDelete.value = false
snack.success("Flashcard deleted")
} catch {
snack.error("Failed to delete flashcard")
} finally {
isSubmitting.value = false
}
}
</script>
+28 -1
View File
@@ -11,6 +11,8 @@
<FlashcardDeckTree <FlashcardDeckTree
ref="deckTree" ref="deckTree"
@select="onDeckSelected" @select="onDeckSelected"
@updated="onDeckUpdated"
@deleted="onDeckDeleted"
/> />
</v-card> </v-card>
</v-col> </v-col>
@@ -21,6 +23,9 @@
> >
<template v-if="selectedDeck"> <template v-if="selectedDeck">
<div class="d-flex align-center justify-space-between mb-4"> <div class="d-flex align-center justify-space-between mb-4">
<span class="text-h3">{{ selectedDeck.name }}</span>
<span class="ml-2">({{ totalCount }})</span>
<v-spacer /> <v-spacer />
<v-btn <v-btn
color="primary" color="primary"
@@ -42,7 +47,11 @@
md="6" md="6"
lg="4" lg="4"
> >
<FlashcardCard :flashcard="flashcard" /> <FlashcardCard
:flashcard="flashcard"
@updated="refreshFlashcards"
@deleted="refreshFlashcards"
/>
</v-col> </v-col>
</v-row> </v-row>
@@ -78,6 +87,7 @@ import useBreadcrumbs from "@/use/use-breadcrumbs"
import useFlashcards from "@/use/use-flashcards" import useFlashcards from "@/use/use-flashcards"
import useRouteQueryPagination from "@/use/utils/use-route-query-pagination" import useRouteQueryPagination from "@/use/utils/use-route-query-pagination"
import flashcardDecksApi from "@/api/flashcard-decks-api"
import FlashcardDeckTree, { import FlashcardDeckTree, {
type DeckNode, type DeckNode,
} from "@/components/flashcard-decks/FlashcardDeckTree.vue" } from "@/components/flashcard-decks/FlashcardDeckTree.vue"
@@ -110,6 +120,23 @@ function onDeckSelected(deck: DeckNode) {
selectedDeck.value = deck selectedDeck.value = deck
} }
function onDeckDeleted(flashcardDeckId: number) {
if (selectedDeck.value?.id !== flashcardDeckId) {
return
}
selectedDeck.value = null
}
async function onDeckUpdated(flashcardDeckId: number) {
if (selectedDeck.value?.id !== flashcardDeckId) {
return
}
const { flashcardDeck } = await flashcardDecksApi.get(flashcardDeckId)
selectedDeck.value = { ...selectedDeck.value, ...flashcardDeck }
}
function openFlashcardCreateDialog() { function openFlashcardCreateDialog() {
flashcardCreateDialog.value?.show() flashcardCreateDialog.value?.show()
} }
@@ -1,12 +1,14 @@
<template> <template>
<div> <div>
<div class="d-flex"> <div class="d-flex align-center">
<div class="text-h4">{{ title }}</div>
<v-spacer /> <v-spacer />
<v-btn <v-btn
class="mt-2" class="mt-2"
variant="tonal" variant="tonal"
prepend-icon="mdi-shuffle" prepend-icon="mdi-shuffle"
text="Shuffle" text="Shuffle"
@click="shuffle"
/> />
</div> </div>
@@ -32,6 +34,10 @@
<div class="text-medium-emphasis text-caption"> <div class="text-medium-emphasis text-caption">
{{ elapsedTime }} {{ elapsedTime }}
</div> </div>
<v-btn
text="Exit"
:to="{ name: 'FlashcardsPage' }"
/>
</div> </div>
<div <div
v-else v-else
@@ -94,6 +100,14 @@ const props = defineProps<{ flashcardDeckId: string }>()
const flashcardDeckIdAsNumber = computed(() => parseInt(props.flashcardDeckId)) const flashcardDeckIdAsNumber = computed(() => parseInt(props.flashcardDeckId))
const { flashcardDeck } = useFlashcardDeck(flashcardDeckIdAsNumber) const { flashcardDeck } = useFlashcardDeck(flashcardDeckIdAsNumber)
const title = computed(() => {
const deckName = flashcardDeck.value?.name ?? ""
const done = results.value.size
const total = flashcards.value.length
const progress = total > 0 ? ` (${done} / ${total})` : ""
return `${deckName}${progress}`
})
const flashcardsQueryOptions = computed<FlashcardQueryOptions>(() => { const flashcardsQueryOptions = computed<FlashcardQueryOptions>(() => {
return { return {
where: { where: {
@@ -166,6 +180,14 @@ function markCurrentCardCorrect() {
function markCurrentCardIncorrect() { function markCurrentCardIncorrect() {
markCurrentCard("incorrect") markCurrentCard("incorrect")
} }
function shuffle() {
for (let i = queue.value.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[queue.value[i], queue.value[j]] = [queue.value[j], queue.value[i]]
}
currentIndex.value = 0
}
</script> </script>
<style scoped> <style scoped>