generated from alphane/template
flashcards in the frontend!!!
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
v-model="showDialog"
|
||||
width="400"
|
||||
>
|
||||
<v-form
|
||||
ref="formRef"
|
||||
@submit.prevent="validateAndCreate"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title>Create New Flashcard Deck</v-card-title>
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="flashcardDeck.name"
|
||||
label="Name"
|
||||
:rules="[required]"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn
|
||||
type="submit"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
text="Create"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-form>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import { VForm } from "vuetify/components"
|
||||
|
||||
import { required } from "@/utils/validators"
|
||||
import { booleanTransformer } from "@/utils/use-route-query-transformers"
|
||||
|
||||
import useSnack from "@/use/use-snack"
|
||||
import flashcardDecksApi, { FlashcardDeck } from "@/api/flashcard-decks-api"
|
||||
|
||||
const flashcardDeck = ref<Partial<FlashcardDeck>>({})
|
||||
|
||||
const showDialog = useRouteQuery<string, boolean>("showFlashcardDeckCreateDialog", "false", {
|
||||
transform: booleanTransformer,
|
||||
})
|
||||
|
||||
function show() {
|
||||
flashcardDeck.value = {}
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
function close() {
|
||||
showDialog.value = false
|
||||
}
|
||||
|
||||
const emit = defineEmits<{ created: [flashcardDeckId: number] }>()
|
||||
|
||||
const isCreating = ref(false)
|
||||
const formRef = ref<InstanceType<typeof VForm> | null>(null)
|
||||
const snack = useSnack()
|
||||
|
||||
async function validateAndCreate() {
|
||||
if (formRef.value === null) return
|
||||
|
||||
const { valid } = await formRef.value.validate()
|
||||
if (!valid) return
|
||||
|
||||
try {
|
||||
isCreating.value = true
|
||||
const { flashcardDeck: newFlashcardDeck } = await flashcardDecksApi.create(flashcardDeck.value)
|
||||
emit("created", newFlashcardDeck.id)
|
||||
close()
|
||||
snack.success("Flashcard Deck Created")
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
snack.error("Failed to create Flashcard Deck")
|
||||
} finally {
|
||||
isCreating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
close,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<v-btn
|
||||
class="ml-3"
|
||||
color="primary"
|
||||
prepend-icon="mdi-book-open-blank-variant-outline"
|
||||
text="Start Review"
|
||||
@click="startReview"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from "vue-router"
|
||||
|
||||
const props = defineProps<{ flashcardDeckId: number }>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function startReview() {
|
||||
goToReviewPage()
|
||||
}
|
||||
|
||||
function goToReviewPage() {
|
||||
router.push({
|
||||
name: "FlashcardDeckReviewPage",
|
||||
params: {
|
||||
flashcardDeckId: props.flashcardDeckId,
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex align-center justify-space-between mb-1">
|
||||
<span class="title">Decks</span>
|
||||
<v-btn
|
||||
variant="text"
|
||||
prepend-icon="mdi-plus"
|
||||
@click="openCreateDialog(null)"
|
||||
>
|
||||
Add Deck
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="d-flex justify-center py-4"
|
||||
>
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
size="24"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="tree.length === 0"
|
||||
class="text-center py-6 text-body-2 text-medium-emphasis"
|
||||
>
|
||||
No decks yet. Create one to get started.
|
||||
</div>
|
||||
|
||||
<draggable
|
||||
v-model="tree"
|
||||
:group="{ name: 'decks' }"
|
||||
item-key="id"
|
||||
@change="onRootChange"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<FlashcardDeckTreeNode
|
||||
v-model:children="element.children"
|
||||
:deck="element"
|
||||
:selected-id="selectedId"
|
||||
@select="onSelect"
|
||||
@move="handleMove"
|
||||
@add-child="openCreateDialog"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
</template>
|
||||
|
||||
<v-dialog
|
||||
v-model="showCreateDialog"
|
||||
width="400"
|
||||
>
|
||||
<v-form
|
||||
ref="formRef"
|
||||
@submit.prevent="createDeck"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title>{{ activeParentId !== null ? "Add Sub-Deck" : "Add 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="showCreateDialog = false"
|
||||
/>
|
||||
<v-btn
|
||||
type="submit"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:loading="isCreating"
|
||||
text="Create"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-form>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { FlashcardDeck } from "@/api/flashcard-decks-api"
|
||||
|
||||
export type DeckNode = FlashcardDeck & { children: DeckNode[] }
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue"
|
||||
import draggable from "vuedraggable"
|
||||
import { VForm } from "vuetify/components"
|
||||
|
||||
import flashcardDecksApi from "@/api/flashcard-decks-api"
|
||||
import { required } from "@/utils/validators"
|
||||
|
||||
import useFlashcardDecks from "@/use/use-flashcard-decks"
|
||||
import useSnack from "@/use/use-snack"
|
||||
|
||||
import FlashcardDeckTreeNode from "@/components/flashcard-decks/FlashcardDeckTreeNode.vue"
|
||||
|
||||
const { flashcardDecks, isLoading, fetch } = useFlashcardDecks()
|
||||
|
||||
const tree = ref<DeckNode[]>([])
|
||||
|
||||
function buildTreeOptimized(decks: FlashcardDeck[]): DeckNode[] {
|
||||
const map = new Map<number, DeckNode>()
|
||||
const roots: DeckNode[] = []
|
||||
|
||||
decks.forEach((d) => map.set(d.id, { ...d, children: [] }))
|
||||
|
||||
decks.forEach((d) => {
|
||||
const node = map.get(d.id)!
|
||||
if (d.parentDeckId === null) {
|
||||
roots.push(node)
|
||||
} else {
|
||||
const parent = map.get(d.parentDeckId)
|
||||
if (parent) parent.children.push(node)
|
||||
}
|
||||
})
|
||||
return roots
|
||||
}
|
||||
|
||||
watch(
|
||||
flashcardDecks,
|
||||
(newDecks) => {
|
||||
tree.value = buildTreeOptimized(newDecks)
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
// --- Selection ---
|
||||
|
||||
const selectedId = ref<number | null>(null)
|
||||
const emit = defineEmits<{ select: [deck: DeckNode] }>()
|
||||
|
||||
function onSelect(deck: DeckNode) {
|
||||
selectedId.value = deck.id
|
||||
emit("select", deck)
|
||||
}
|
||||
|
||||
// --- Drag & Drop ---
|
||||
|
||||
const snack = useSnack()
|
||||
|
||||
async function handleMove({ deckId, newParentId }: { deckId: number; newParentId: number | null }) {
|
||||
try {
|
||||
await flashcardDecksApi.update(deckId, { parentDeckId: newParentId })
|
||||
// await fetch()
|
||||
} catch {
|
||||
snack.error("Failed to move deck")
|
||||
tree.value = buildTreeOptimized(flashcardDecks.value)
|
||||
}
|
||||
}
|
||||
|
||||
function onRootChange(event: { added?: { element: DeckNode } }) {
|
||||
if (event.added) {
|
||||
handleMove({ deckId: event.added.element.id, newParentId: null })
|
||||
}
|
||||
}
|
||||
|
||||
// --- Create ---
|
||||
|
||||
const showCreateDialog = ref(false)
|
||||
const activeParentId = ref<number | null>(null)
|
||||
const newDeckName = ref("")
|
||||
const isCreating = ref(false)
|
||||
const formRef = ref<InstanceType<typeof VForm> | null>(null)
|
||||
|
||||
function openCreateDialog(parentId: number | null) {
|
||||
activeParentId.value = parentId
|
||||
newDeckName.value = ""
|
||||
showCreateDialog.value = true
|
||||
}
|
||||
|
||||
async function createDeck() {
|
||||
if (!formRef.value) return
|
||||
const { valid } = await formRef.value.validate()
|
||||
if (!valid) return
|
||||
|
||||
try {
|
||||
isCreating.value = true
|
||||
await flashcardDecksApi.create({
|
||||
name: newDeckName.value,
|
||||
parentDeckId: activeParentId.value,
|
||||
})
|
||||
showCreateDialog.value = false
|
||||
await fetch()
|
||||
} catch {
|
||||
snack.error("Failed to create deck")
|
||||
} finally {
|
||||
isCreating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ refresh: fetch })
|
||||
</script>
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="deck-node d-flex align-center px-3 py-2 rounded"
|
||||
:class="{ selected: isSelected }"
|
||||
@click="$emit('select', deck)"
|
||||
>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
density="comfortable"
|
||||
size="default"
|
||||
class="mr-1"
|
||||
@click.stop="expanded = !expanded"
|
||||
>
|
||||
<v-icon size="default">
|
||||
{{
|
||||
hasChildren ? (expanded ? "mdi-chevron-down" : "mdi-chevron-right") : "mdi-circle-small"
|
||||
}}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
<v-icon
|
||||
size="default"
|
||||
class="mr-2 text-medium-emphasis"
|
||||
:icon="expanded && hasChildren ? 'mdi-folder-open-outline' : 'mdi-folder-outline'"
|
||||
/>
|
||||
<span
|
||||
class="flex-grow-1 text-body-1"
|
||||
:class="{ 'font-weight-medium': isSelected }"
|
||||
>
|
||||
{{ deck.name }}
|
||||
</span>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
density="comfortable"
|
||||
size="default"
|
||||
title="Add sub-deck"
|
||||
class="add-btn"
|
||||
@click.stop="$emit('add-child', deck.id)"
|
||||
>
|
||||
<v-icon
|
||||
size="default"
|
||||
icon="mdi-plus"
|
||||
/>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="expanded"
|
||||
class="pl-5"
|
||||
>
|
||||
<draggable
|
||||
v-model="children"
|
||||
:group="{ name: 'decks' }"
|
||||
item-key="id"
|
||||
@change="onChange"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<FlashcardDeckTreeNode
|
||||
v-model:children="element.children"
|
||||
:deck="element"
|
||||
:selected-id="selectedId"
|
||||
@select="$emit('select', $event)"
|
||||
@move="$emit('move', $event)"
|
||||
@add-child="$emit('add-child', $event)"
|
||||
/>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue"
|
||||
import draggable from "vuedraggable"
|
||||
|
||||
import type { DeckNode } from "@/components/flashcard-decks/FlashcardDeckTree.vue"
|
||||
|
||||
const props = defineProps<{
|
||||
deck: DeckNode
|
||||
selectedId?: number | null
|
||||
}>()
|
||||
|
||||
const children = defineModel<DeckNode[]>("children", { required: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [deck: DeckNode]
|
||||
move: [payload: { deckId: number; newParentId: number | null }]
|
||||
"add-child": [parentId: number]
|
||||
}>()
|
||||
|
||||
const expanded = ref(true)
|
||||
const isSelected = computed(() => props.selectedId === props.deck.id)
|
||||
const hasChildren = computed(() => children.value.length > 0)
|
||||
|
||||
function onChange(event: { added?: { element: DeckNode } }) {
|
||||
if (event.added) {
|
||||
emit("move", { deckId: event.added.element.id, newParentId: props.deck.id })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.deck-node {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.deck-node:hover {
|
||||
background: rgb(var(--v-theme-hoverColor));
|
||||
}
|
||||
|
||||
.deck-node.selected {
|
||||
background: rgb(var(--v-theme-lightprimary));
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.deck-node:hover .add-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<v-data-table-server
|
||||
v-model:items-per-page="perPage"
|
||||
v-model:page="page"
|
||||
v-model:sort-by="sortBy"
|
||||
:headers="headers"
|
||||
:items="flashcardDecks"
|
||||
:items-length="totalCount"
|
||||
:loading="isLoading"
|
||||
@click:row="rowClicked"
|
||||
@update:page="updatePage"
|
||||
>
|
||||
<template
|
||||
v-for="(_, name) in $slots"
|
||||
:key="name"
|
||||
#[name]="slotProps"
|
||||
>
|
||||
<slot
|
||||
:name="name"
|
||||
v-bind="slotProps"
|
||||
></slot>
|
||||
</template>
|
||||
</v-data-table-server>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export const DEFAULT_HEADERS = [
|
||||
{ title: "Name", key: "name", align: "start" as const, sortable: true },
|
||||
{ title: "Created At", key: "createdAt", align: "start" as const, sortable: true },
|
||||
]
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
|
||||
import useVuetifySortByToSafeRouteQuery, {
|
||||
type SortItem,
|
||||
} from "@/use/utils/use-vuetify-sort-by-to-safe-route-query"
|
||||
import useVuetifySortByToSequelizeSafeOrder from "@/use/utils/use-vuetify-sort-by-to-sequelize-safe-order"
|
||||
import useRouteQueryPagination from "@/use/utils/use-route-query-pagination"
|
||||
import useFlashcardDecks, {
|
||||
type FlashcardDeck,
|
||||
type FlashcardDeckFiltersOptions,
|
||||
type FlashcardDeckQueryOptions,
|
||||
type FlashcardDeckWhereOptions,
|
||||
} from "@/use/use-flashcard-decks"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
headers?: { title: string; key: string }[]
|
||||
filters?: FlashcardDeckFiltersOptions
|
||||
where?: FlashcardDeckWhereOptions
|
||||
sortBy?: SortItem[]
|
||||
routeQuerySuffix?: string
|
||||
waiting?: boolean
|
||||
}>(),
|
||||
{
|
||||
headers: () => DEFAULT_HEADERS,
|
||||
filters: () => ({}),
|
||||
where: () => ({}),
|
||||
sortBy: () => [],
|
||||
routeQuerySuffix: "FlashcardDecks",
|
||||
waiting: false,
|
||||
}
|
||||
)
|
||||
|
||||
const { page, perPage } = useRouteQueryPagination({ routeQuerySuffix: props.routeQuerySuffix })
|
||||
const sortBy = useVuetifySortByToSafeRouteQuery(`sortBy${props.routeQuerySuffix}`, props.sortBy)
|
||||
const order = useVuetifySortByToSequelizeSafeOrder(sortBy)
|
||||
|
||||
const queryOptions = computed<FlashcardDeckQueryOptions>(() => ({
|
||||
where: props.where,
|
||||
filters: props.filters,
|
||||
order: order.value,
|
||||
page: page.value,
|
||||
perPage: perPage.value,
|
||||
}))
|
||||
|
||||
const { flashcardDecks, totalCount, isLoading, refresh } = useFlashcardDecks(queryOptions, {
|
||||
skipWatchIf: () => props.waiting,
|
||||
})
|
||||
|
||||
type DeckTableRow = {
|
||||
item: FlashcardDeck
|
||||
}
|
||||
|
||||
const emit = defineEmits<{ clicked: [deck: FlashcardDeck] }>()
|
||||
|
||||
function rowClicked(_event: unknown, row: DeckTableRow) {
|
||||
emit("clicked", row.item)
|
||||
}
|
||||
|
||||
function updatePage(newPage: number) {
|
||||
if (isLoading.value || props.waiting) return
|
||||
|
||||
page.value = newPage
|
||||
}
|
||||
|
||||
defineExpose({ refresh, totalCount })
|
||||
</script>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
v-model="showDialog"
|
||||
width="500"
|
||||
>
|
||||
<v-form
|
||||
ref="formRef"
|
||||
@submit.prevent="validateAndCreate"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title>Add Flashcard</v-card-title>
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-textarea
|
||||
v-model="flashcard.front"
|
||||
label="Front"
|
||||
rows="3"
|
||||
:rules="[required]"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-textarea
|
||||
v-model="flashcard.back"
|
||||
label="Back"
|
||||
rows="3"
|
||||
:rules="[required]"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
variant="text"
|
||||
text="Cancel"
|
||||
@click="close"
|
||||
/>
|
||||
<v-btn
|
||||
type="submit"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:loading="isCreating"
|
||||
text="Create"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-form>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import { VForm } from "vuetify/components"
|
||||
|
||||
import { required } from "@/utils/validators"
|
||||
import useSnack from "@/use/use-snack"
|
||||
import flashcardsApi, { type Flashcard } from "@/api/flashcards-api"
|
||||
|
||||
const props = defineProps<{
|
||||
flashcardDeckId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ created: [flashcardId: number] }>()
|
||||
|
||||
const showDialog = ref(false)
|
||||
const isCreating = ref(false)
|
||||
const formRef = ref<InstanceType<typeof VForm> | null>(null)
|
||||
const flashcard = ref<Partial<Flashcard>>({})
|
||||
const snack = useSnack()
|
||||
|
||||
function show() {
|
||||
flashcard.value = {}
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
function close() {
|
||||
showDialog.value = false
|
||||
}
|
||||
|
||||
async function validateAndCreate() {
|
||||
if (!formRef.value) return
|
||||
|
||||
const { valid } = await formRef.value.validate()
|
||||
if (!valid) return
|
||||
|
||||
try {
|
||||
isCreating.value = true
|
||||
const { flashcard: newFlashcard } = await flashcardsApi.create({
|
||||
...flashcard.value,
|
||||
flashcardDeckId: props.flashcardDeckId,
|
||||
})
|
||||
emit("created", newFlashcard.id)
|
||||
close()
|
||||
snack.success("Flashcard created")
|
||||
} catch {
|
||||
snack.error("Failed to create flashcard")
|
||||
} finally {
|
||||
isCreating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show, close })
|
||||
</script>
|
||||
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="flashcard-scene">
|
||||
<div
|
||||
class="flashcard"
|
||||
:class="{ 'is-flipped': isFlipped }"
|
||||
@click="flip"
|
||||
>
|
||||
<!-- Front -->
|
||||
<v-card
|
||||
class="flashcard-face flashcard-face--front"
|
||||
rounded="xl"
|
||||
elevation="4"
|
||||
>
|
||||
<div class="face-label text-textSecondary text-caption text-uppercase font-weight-bold">
|
||||
Question
|
||||
</div>
|
||||
<div
|
||||
class="face-content text-h5 text-center"
|
||||
v-html="renderMarkdown(flashcard.front)"
|
||||
/>
|
||||
<div class="face-hint text-textSecondary text-caption">Click to reveal</div>
|
||||
</v-card>
|
||||
|
||||
<!-- Back -->
|
||||
<v-card
|
||||
class="flashcard-face flashcard-face--back"
|
||||
color="white"
|
||||
rounded="xl"
|
||||
elevation="4"
|
||||
>
|
||||
<div class="face-label text-textSecondary text-caption text-uppercase font-weight-bold">
|
||||
Answer
|
||||
</div>
|
||||
<div
|
||||
class="face-content text-h5 text-center"
|
||||
v-html="renderMarkdown(flashcard.back)"
|
||||
/>
|
||||
</v-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import { type Flashcard } from "@/api/flashcards-api"
|
||||
|
||||
import renderMarkdown from "@/utils/render-markdown"
|
||||
|
||||
defineProps<{ flashcard: Flashcard }>()
|
||||
|
||||
const isFlipped = ref(false)
|
||||
|
||||
function flip() {
|
||||
isFlipped.value = !isFlipped.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.flashcard-scene {
|
||||
perspective: 1200px;
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
height: 380px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.flashcard {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.flashcard.is-flipped {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.flashcard-face {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 48px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.flashcard-face--back {
|
||||
transform: rotateY(180deg);
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
.flashcard-face--back :deep(*) {
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
.face-label {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 28px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.face-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1.5;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.face-hint {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
}
|
||||
|
||||
.face-actions {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<v-data-table-server
|
||||
v-model:items-per-page="perPage"
|
||||
v-model:page="page"
|
||||
v-model:sort-by="sortBy"
|
||||
:headers="headers"
|
||||
:items="flashcards"
|
||||
:items-length="totalCount"
|
||||
:loading="isLoading"
|
||||
@click:row="rowClicked"
|
||||
@update:page="updatePage"
|
||||
>
|
||||
<template #item.front="{ item }">
|
||||
<span class="text-truncate d-inline-block">{{ item.front }}</span>
|
||||
</template>
|
||||
<template #item.back="{ item }">
|
||||
<span class="text-truncate d-inline-block">{{ item.back }}</span>
|
||||
</template>
|
||||
<template
|
||||
v-for="(_, name) in $slots"
|
||||
:key="name"
|
||||
#[name]="slotProps"
|
||||
>
|
||||
<slot
|
||||
:name="name"
|
||||
v-bind="slotProps"
|
||||
></slot>
|
||||
</template>
|
||||
</v-data-table-server>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export const DEFAULT_HEADERS = [
|
||||
{ title: "Front", key: "front", align: "start" as const, sortable: false },
|
||||
{ title: "Back", key: "back", align: "start" as const, sortable: false },
|
||||
{ title: "Created At", key: "createdAt", align: "start" as const, sortable: true },
|
||||
]
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
|
||||
import useVuetifySortByToSafeRouteQuery, {
|
||||
type SortItem,
|
||||
} from "@/use/utils/use-vuetify-sort-by-to-safe-route-query"
|
||||
import useVuetifySortByToSequelizeSafeOrder from "@/use/utils/use-vuetify-sort-by-to-sequelize-safe-order"
|
||||
import useRouteQueryPagination from "@/use/utils/use-route-query-pagination"
|
||||
import useFlashcards, {
|
||||
type Flashcard,
|
||||
type FlashcardFiltersOptions,
|
||||
type FlashcardQueryOptions,
|
||||
type FlashcardWhereOptions,
|
||||
} from "@/use/use-flashcards"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
headers?: { title: string; key: string }[]
|
||||
filters?: FlashcardFiltersOptions
|
||||
where?: FlashcardWhereOptions
|
||||
sortBy?: SortItem[]
|
||||
routeQuerySuffix?: string
|
||||
waiting?: boolean
|
||||
}>(),
|
||||
{
|
||||
headers: () => DEFAULT_HEADERS,
|
||||
filters: () => ({}),
|
||||
where: () => ({}),
|
||||
sortBy: () => [],
|
||||
routeQuerySuffix: "Flashcards",
|
||||
waiting: false,
|
||||
}
|
||||
)
|
||||
|
||||
const { page, perPage } = useRouteQueryPagination({ routeQuerySuffix: props.routeQuerySuffix })
|
||||
const sortBy = useVuetifySortByToSafeRouteQuery(`sortBy${props.routeQuerySuffix}`, props.sortBy)
|
||||
const order = useVuetifySortByToSequelizeSafeOrder(sortBy)
|
||||
|
||||
const queryOptions = computed<FlashcardQueryOptions>(() => ({
|
||||
where: props.where,
|
||||
filters: props.filters,
|
||||
order: order.value,
|
||||
page: page.value,
|
||||
perPage: perPage.value,
|
||||
}))
|
||||
|
||||
const { flashcards, totalCount, isLoading, refresh } = useFlashcards(queryOptions, {
|
||||
skipWatchIf: () => props.waiting,
|
||||
})
|
||||
|
||||
type FlashcardTableRow = {
|
||||
item: Flashcard
|
||||
}
|
||||
|
||||
const emit = defineEmits<{ clicked: [flashcard: Flashcard] }>()
|
||||
|
||||
function rowClicked(_event: unknown, row: FlashcardTableRow) {
|
||||
emit("clicked", row.item)
|
||||
}
|
||||
|
||||
function updatePage(newPage: number) {
|
||||
if (isLoading.value || props.waiting) return
|
||||
|
||||
page.value = newPage
|
||||
}
|
||||
|
||||
defineExpose({ refresh, totalCount })
|
||||
</script>
|
||||
@@ -9,6 +9,20 @@
|
||||
<AppLogo />
|
||||
</div>
|
||||
|
||||
<v-tabs
|
||||
class="ml-2"
|
||||
color="primary"
|
||||
>
|
||||
<v-tab
|
||||
:to="{ name: 'DashboardPage' }"
|
||||
text="Dashboard"
|
||||
/>
|
||||
<v-tab
|
||||
:to="{ name: 'FlashcardsPage' }"
|
||||
text="Flashcards"
|
||||
/>
|
||||
</v-tabs>
|
||||
|
||||
<v-spacer />
|
||||
|
||||
<!-- TODO Add notifications -->
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<v-row class="fill-height">
|
||||
<v-col
|
||||
cols="12"
|
||||
md="3"
|
||||
>
|
||||
<v-card
|
||||
height="100%"
|
||||
class="pa-2"
|
||||
>
|
||||
<FlashcardDeckTree
|
||||
ref="deckTree"
|
||||
@select="onDeckSelected"
|
||||
/>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col
|
||||
cols="12"
|
||||
md="9"
|
||||
>
|
||||
<template v-if="selectedDeck">
|
||||
<div class="d-flex align-center justify-space-between mb-4">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-plus"
|
||||
text="Add Flashcard"
|
||||
@click="openFlashcardCreateDialog"
|
||||
/>
|
||||
<FlashcardDeckStartReviewBtn :flashcard-deck-id="selectedDeck.id" />
|
||||
</div>
|
||||
|
||||
<v-divider class="my-5" />
|
||||
|
||||
<v-card :border="true">
|
||||
<FlashcardsDataTableServer
|
||||
ref="flashcardsTable"
|
||||
:where="{ flashcardDeckId: selectedDeck.id }"
|
||||
/>
|
||||
</v-card>
|
||||
|
||||
<FlashcardCreateDialog
|
||||
v-if="selectedDeck"
|
||||
ref="flashcardCreateDialog"
|
||||
:flashcard-deck-id="selectedDeck.id"
|
||||
@created="flashcardsTable?.refresh()"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="d-flex align-center justify-center h-100 text-medium-emphasis"
|
||||
>
|
||||
Select a deck to view its flashcards
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
|
||||
import useBreadcrumbs from "@/use/use-breadcrumbs"
|
||||
|
||||
import FlashcardDeckTree, {
|
||||
type DeckNode,
|
||||
} from "@/components/flashcard-decks/FlashcardDeckTree.vue"
|
||||
import FlashcardsDataTableServer from "@/components/flashcards/FlashcardsDataTableServer.vue"
|
||||
import FlashcardCreateDialog from "@/components/flashcards/FlashcardCreateDialog.vue"
|
||||
import FlashcardDeckStartReviewBtn from "@/components/flashcard-decks/FlashcardDeckStartReviewBtn.vue"
|
||||
|
||||
const deckTree = ref<InstanceType<typeof FlashcardDeckTree> | null>(null)
|
||||
const selectedDeck = ref<DeckNode | null>(null)
|
||||
const flashcardsTable = ref<InstanceType<typeof FlashcardsDataTableServer> | null>(null)
|
||||
const flashcardCreateDialog = ref<InstanceType<typeof FlashcardCreateDialog> | null>(null)
|
||||
|
||||
function onDeckSelected(deck: DeckNode) {
|
||||
selectedDeck.value = deck
|
||||
}
|
||||
|
||||
function openFlashcardCreateDialog() {
|
||||
flashcardCreateDialog.value?.show()
|
||||
}
|
||||
|
||||
useBreadcrumbs("Study")
|
||||
</script>
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
class="mt-2"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-shuffle"
|
||||
text="Shuffle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-skeleton-loader
|
||||
v-if="isLoading"
|
||||
type="card"
|
||||
/>
|
||||
<div v-else-if="isEmpty(flashcards)"></div>
|
||||
<div
|
||||
v-else
|
||||
class="review-layout"
|
||||
>
|
||||
<FlashcardReviewCard
|
||||
:key="currentIndex"
|
||||
class="mt-5"
|
||||
:flashcard="flashcards[currentIndex]"
|
||||
/>
|
||||
<div class="review-nav">
|
||||
<v-btn
|
||||
icon="mdi-chevron-left"
|
||||
variant="text"
|
||||
size="x-large"
|
||||
:disabled="currentIndex === 0"
|
||||
@click="prev"
|
||||
/>
|
||||
<v-btn
|
||||
class="mr-3"
|
||||
color="error"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-close"
|
||||
>
|
||||
0
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="ml-3"
|
||||
color="success"
|
||||
variant="tonal"
|
||||
append-icon="mdi-check"
|
||||
>
|
||||
0
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon="mdi-chevron-right"
|
||||
variant="text"
|
||||
size="x-large"
|
||||
:disabled="currentIndex === flashcards.length - 1"
|
||||
@click="next"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { isEmpty, isNil } from "lodash"
|
||||
import { computed, ref } from "vue"
|
||||
|
||||
import useFlashcardDeck from "@/use/use-flashcard-deck"
|
||||
import useFlashcards, { FlashcardQueryOptions } from "@/use/use-flashcards"
|
||||
|
||||
import FlashcardReviewCard from "@/components/flashcards/FlashcardReviewCard.vue"
|
||||
|
||||
const props = defineProps<{ flashcardDeckId: string }>()
|
||||
|
||||
const flashcardDeckIdAsNumber = computed(() => parseInt(props.flashcardDeckId))
|
||||
|
||||
const { flashcardDeck } = useFlashcardDeck(flashcardDeckIdAsNumber)
|
||||
|
||||
const flashcardsQueryOptions = computed<FlashcardQueryOptions>(() => {
|
||||
return {
|
||||
where: {
|
||||
flashcardDeckId: flashcardDeck.value?.id,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const { flashcards, isLoading } = useFlashcards(flashcardsQueryOptions, {
|
||||
skipWatchIf: () => isNil(flashcardDeck.value),
|
||||
})
|
||||
|
||||
const currentIndex = ref(0)
|
||||
|
||||
function next() {
|
||||
if (currentIndex.value < flashcards.value.length - 1) {
|
||||
currentIndex.value++
|
||||
}
|
||||
}
|
||||
|
||||
function prev() {
|
||||
if (currentIndex.value > 0) {
|
||||
currentIndex.value--
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.review-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.review-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -34,6 +34,23 @@ const routes: RouteRecordRaw[] = [
|
||||
title: "Dashboard",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "FlashcardsPage",
|
||||
path: "flashcards",
|
||||
component: () => import("@/pages/FlashcardsPage.vue"),
|
||||
meta: {
|
||||
title: "Flashcards",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "FlashcardDeckReviewPage",
|
||||
path: "flashcard-deck/:flashcardDeckId/review",
|
||||
component: () => import("@/pages/flashcard-deck-reviews/FlashcardDeckReviewPage.vue"),
|
||||
props: true,
|
||||
meta: {
|
||||
title: "Deck review",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
component: () => import("@/layouts/LayoutWithBreadcrumbs.vue"),
|
||||
|
||||
@@ -189,8 +189,8 @@ const CHIRPY_JEKYLL_DARK_THEME: ThemeTypes = {
|
||||
"border-opacity": 1,
|
||||
},
|
||||
colors: {
|
||||
primary: "#8ab4f8",
|
||||
secondary: "#40b883",
|
||||
primary: "#07a8f7",
|
||||
secondary: "#8ab4f8",
|
||||
info: "#0075d1",
|
||||
success: "#0fa30f",
|
||||
accent: "#8ab4f8",
|
||||
@@ -199,7 +199,7 @@ const CHIRPY_JEKYLL_DARK_THEME: ThemeTypes = {
|
||||
purple: "#b370f5",
|
||||
indigo: "#7c6af0",
|
||||
lightprimary: "#1a2540",
|
||||
lightsecondary: "#0d2e20",
|
||||
lightsecondary: "#072a3a",
|
||||
lightsuccess: "#163c24",
|
||||
lighterror: "#3a0000",
|
||||
lightwarning: "#3a2a10",
|
||||
|
||||
@@ -4,6 +4,7 @@ import markedKatex from "marked-katex-extension"
|
||||
import "katex/dist/katex.min.css"
|
||||
|
||||
marked.use(markedKatex({ throwOnError: false }))
|
||||
marked.use({ breaks: true })
|
||||
|
||||
export function renderMarkdown(source: string): string {
|
||||
return DOMPurify.sanitize(marked.parse(source) as string)
|
||||
|
||||
Reference in New Issue
Block a user