add library, tsconfig.base.json

This commit is contained in:
polv committed 2021-10-25 05:19:13 +07:00
1 parent 0348f84255
commit ffbe5fed18
8 files changed
+758 -25

No files matched your search

+329
View File
@@ -0,0 +1,329 @@
<template>
<section>
<div class="card">
<header class="card-header" style="display: flex; flex-direction: row">
<div class="card-header-title" style="flex-grow: 1">
<p
class="has-context"
@click="
(evt) => {
selected = currentData
$refs.context.open(evt)
}
"
@contextmenu.prevent="
(evt) => {
selected = currentData
$refs.context.open(evt)
}
"
>
{{ title }}
</p>
<div
@click="isOpen = !isOpen"
style="flex-grow: 1; height: 100%; cursor: pointer"
></div>
</div>
<a
v-if="isOpen"
class="card-header-icon"
role="button"
@click="isList = !isList"
>
<b-icon :icon="isList ? 'th' : 'list-ul'"> </b-icon>
</a>
<a class="card-header-icon" role="button" @click="isOpen = !isOpen">
<b-icon :icon="isOpen ? 'caret-down' : 'caret-up'"> </b-icon>
</a>
</header>
<div
v-if="isOpen"
class="card-content"
:data-mode="isList ? 'list' : 'item'"
>
<div v-if="isList">
<b-table
:data="list"
paginated
:per-page="perPage"
@page-change="(p) => makeList(p)"
>
<b-table-column field="entry" label="Entry" v-slot="props">
<span
:class="
'tag clickable is-medium ' + getTagClass(props.row.entry)
"
@click.stop="
(evt) => {
selected = [props.row.entry]
$refs.context.open(evt)
}
"
@contextmenu.prevent="
(evt) => {
selected = [props.row.entry]
$refs.context.open(evt)
}
"
>
{{ props.row.entry }}
</span>
<span
v-for="t in props.row.alt || []"
:key="t"
:class="'tag clickable is-medium ' + getTagClass(t)"
@click.stop="
(evt) => {
selected = [t]
$refs.context.open(evt)
}
"
@contextmenu.prevent="
(evt) => {
selected = [t]
$refs.context.open(evt)
}
"
>
{{ t }}
</span>
</b-table-column>
<b-table-column field="reading" label="Pinyin" v-slot="props">
{{ (props.row.reading || []).join(' / ') }}
</b-table-column>
<b-table-column field="english" label="English" v-slot="props">
<div
class="no-scrollbar"
style="max-width: 40vw; max-height: 200px"
>
{{ (props.row.english || []).join(' / ') }}
</div>
</b-table-column>
<template slot="empty">
<div style="position: relative; height: 120px">
<b-loading active :is-full-page="false"></b-loading>
</div>
</template>
</b-table>
</div>
<div v-else>
<span
v-for="t in currentData"
:key="t"
:class="'tag clickable is-medium ' + getTagClass(t)"
@click.stop="
(evt) => {
selected = [t]
$refs.context.open(evt)
}
"
@contextmenu.prevent="
(evt) => {
selected = [t]
$refs.context.open(evt)
}
"
>
{{ t }}
</span>
</div>
</div>
</div>
<ContextMenu
ref="context"
:type="type"
:entry="selected"
:description="title + ' ' + description"
:additional="additional"
@quiz:added="(evt) => reload(evt.entries)"
@quiz:removed="(evt) => reload(evt.entries)"
/>
</section>
</template>
<script lang="ts">
import { Vue, Component, Ref, Prop } from 'nuxt-property-decorator'
import { api, IEntry, IType } from '~/assets/api'
import ContextMenu from '../ContextMenu.vue'
type ILibraryEntry = Pick<
IEntry,
'entry' | 'reading' | 'translation' | 'type'
> & {
originalEntry: string
}
@Component<LibraryCard>({
components: {
ContextMenu,
},
watch: {
open() {
this.isOpen = this.open
},
isList() {
this.makeList()
},
isOpen() {
this.reload(this.entries)
},
entries() {
this.isList = false
this.list = []
this.$nextTick(() => {
this.reload(this.entries)
})
},
},
created() {
this.isOpen = this.open
},
})
export default class LibraryCard extends Vue {
@Prop() title!: string
@Prop() entries!: string[]
@Prop() type!: IType
@Prop({ default: '' }) description!: string
@Prop({ default: false }) open!: boolean
@Prop({ default: () => [] }) additional!: {
name: string
handler: () => void
}[]
@Ref() context!: ContextMenu
isOpen = false
isList = false
list: ILibraryEntry[] = this.entries.map((el) => ({
originalEntry: el,
entry: [el],
reading: [],
translation: [],
type: this.type,
}))
perPage = 5
selected: string[] = []
srsLevel: {
[entry: string]: number
} = {}
readonly tagClassMap = [
(lv: number) => (lv > 2 ? 'is-success' : ''),
(lv: number) => (lv > 0 ? 'is-warning' : ''),
(lv: number) => (lv === 0 ? 'is-danger' : ''),
]
async reload(entries: string[]) {
if (!this.isOpen) {
return
}
if (entries.length > 0) {
entries = [...new Set(entries)]
const { result } = await api.quiz_getSrsLevel({
entries,
type: this.type,
})
entries.map((entry) => {
delete this.srsLevel[entry]
})
result.map(({ entry, srsLevel }) => {
this.srsLevel[entry] = srsLevel
})
this.$set(this, 'srsLevel', this.srsLevel)
this.$forceUpdate()
}
}
async makeList(p = 1) {
const offset = (p - 1) * this.perPage
const listMap = new Map<number, ILibraryEntry>()
Array(this.perPage)
.fill(null)
.map((_, i) => {
const el = this.list[offset + i]
if (el && !el.reading.length) {
listMap.set(offset + i, el)
}
})
await Promise.all(
[...listMap].map(([k, v]) =>
api
.all_getLookup({
entry: v.originalEntry,
type: this.type,
forced: true,
limit: 1,
})
.then((r) =>
listMap.set(k, {
...r.result[0]!,
...v,
})
)
)
)
for (const [k, v] of listMap) {
this.list = [...this.list.slice(0, k), v, ...this.list.slice(k + 1)]
}
await this.reload([...listMap].flatMap(([, { entry }]) => entry))
}
getTagClass(it: IEntry) {
const srsLevel = Math.min(
...it.entry.map((el) => this.srsLevel[el] || -Infinity)
)
if (srsLevel === -Infinity) return 'is-light'
if (srsLevel === -1) {
return 'is-info'
}
for (const fn of this.tagClassMap) {
const c = fn(srsLevel)
if (c) {
return c
}
}
return 'is-light'
}
}
</script>
<style lang="scss" scoped>
.tag {
margin-right: 0.5rem;
margin-bottom: 0.5rem;
}
.card-content[data-mode='item'] {
max-height: 400px;
overflow: scroll;
}
.has-context:hover {
color: blue;
cursor: pointer;
}
</style>
+3 -3
View File
@@ -257,7 +257,7 @@ export default class BrowseTab extends Vue {
this.selected.reading = [
await api
.all_getReading({
entry: this.selected.entry[0],
entry: this.selected.entry[0]!,
})
.then((r) => r.result),
]
@@ -266,7 +266,7 @@ export default class BrowseTab extends Vue {
if (!this.selected.translation.length) {
this.selected.translation = await api
.all_getMeaning({
entry: this.selected.entry[0],
entry: this.selected.entry[0]!,
})
.then((r) =>
r.result.filter((r0) => !r0.entry).map((r0) => r0.meaning)
@@ -360,7 +360,7 @@ export default class BrowseTab extends Vue {
const o = jsonClone(row)
this.selected = {
...o,
entry: o.entry[0],
entry: o.entry[0]!,
list: o.entry,
}
this.context.open(evt)
+11 -10
View File
@@ -1,6 +1,6 @@
<template>
<section>
<div class="HanziPage">
<div class="CharacterTab">
<form class="field" @submit.prevent="q = q0">
<label for="q" class="label">
Search
@@ -29,7 +29,7 @@
<div class="columns">
<div class="column is-6 entry-display">
<div
class="hanzi-display clickable font-han"
class="character-display clickable font-han"
@click="(evt) => openContext(evt, current, 'character')"
@contextmenu.prevent="
(evt) => openContext(evt, current, 'character')
@@ -278,12 +278,14 @@ import { Component, Prop, Ref, Vue, Watch } from 'nuxt-property-decorator'
import ContextMenu from '@/components/ContextMenu.vue'
import { api, IEntry } from '~/assets/api'
const TITLE = 'Kanji'
@Component<CharacterTab>({
components: {
ContextMenu,
},
async created() {
this.$emit('title', 'Hanzi')
this.$emit('title', TITLE)
this.q = this.query.q || ''
@@ -357,7 +359,7 @@ export default class CharacterTab extends Vue {
@Watch('q')
async onQChange(q: string) {
this.$emit('title', (q ? q + ' - ' : '') + 'Hanzi')
this.$emit('title', (q ? q + ' - ' : '') + TITLE)
if (/\p{sc=Han}/u.test(q)) {
const qs = q.split('').filter((h) => /\p{sc=Han}/u.test(h))
@@ -381,7 +383,7 @@ export default class CharacterTab extends Vue {
@Watch('current')
load() {
if (this.current) {
this.loadHanzi()
this.loadCharacter()
this.loadVocab()
this.loadSentences()
} else {
@@ -399,7 +401,7 @@ export default class CharacterTab extends Vue {
}
}
async loadHanzi() {
async loadCharacter() {
if (!/^\p{sc=Han}$/u.test(this.current)) {
this.sub = []
this.sup = []
@@ -429,6 +431,7 @@ export default class CharacterTab extends Vue {
} = await api.all_getLookup({
entry: this.current,
type: 'character',
limit: 1,
})
this.reading = r?.reading || []
@@ -455,10 +458,9 @@ export default class CharacterTab extends Vue {
.map((r) =>
api
.all_getLookup({ entry: r.entry, type: 'vocabulary', limit: 1 })
.then((r) => r.result[0])
.then((r) => r.result[0]!)
)
.filter((r) => r)
.map((r) => r!)
)
}
@@ -478,10 +480,9 @@ export default class CharacterTab extends Vue {
.map((r) =>
api
.all_getLookup({ entry: r.entry, type: 'sentence', limit: 1 })
.then((r) => r.result[0])
.then((r) => r.result[0]!)
)
.filter((r) => r)
.map((r) => r!)
)
}
}
+285
View File
@@ -0,0 +1,285 @@
<template>
<section>
<div class="LibraryPage">
<form @submit.prevent="q = q0">
<label for="q" class="label">
Search
<b-tooltip label="How to?" position="is-right">
<a
href="https://github.com/zhquiz/zhquiz/wiki/How-to-search-or-filter"
target="_blank"
rel="noopener noreferrer"
>
<b-icon icon="info-circle"></b-icon>
</a>
</b-tooltip>
</label>
<div class="field has-addons">
<p class="control is-expanded">
<input
v-model="q0"
class="input"
type="search"
name="q"
placeholder="Type here to search"
aria-label="search"
/>
</p>
<p class="control">
<button
class="button is-success"
type="button"
@click="openEditModal()"
>
Add new item
</button>
</p>
</div>
</form>
<div class="columns mt-4">
<div class="column">
<section class="card">
<div class="card-content">
<LibraryCard
v-for="(it, i) in local.result"
:id="it.id"
:key="i"
:title="it.title"
:entries="it.entries"
:type="it.type"
:description="it.description"
:additional="additionalContext(it)"
/>
<b-pagination
v-if="local.count > local.perPage"
v-model="local.page"
:total="local.count"
:per-page="local.perPage"
icon-prev="angle-left"
icon-next="angle-right"
@change="(p) => (local.page = p)"
/>
</div>
</section>
</div>
</div>
</div>
<b-modal v-model="isEditModal">
<div class="card">
<header class="card-header">
<div v-if="!edited.id" class="card-header-title">New list</div>
<div v-else class="card-header-title">Edit list</div>
</header>
<div class="card-content">
<b-field label="Title">
<b-input
v-model="edited.title"
placeholder="Must not be empty"
></b-input>
</b-field>
<b-field label="Entries">
<template slot="label">
Entries
<b-tooltip type="is-dark" label="Space or new-line separated">
<b-icon size="is-small" icon="info-circle"></b-icon>
</b-tooltip>
</template>
<b-input
v-model="entryString"
type="textarea"
placeholder="Space or new-line separated, must not be empty"
></b-input>
</b-field>
<b-field label="Description">
<b-input v-model="edited.description" type="textarea"></b-input>
</b-field>
<b-field label="Tag">
<b-taginput v-model="edited.tag"></b-taginput>
</b-field>
<b-field label="Additional options">
<b-checkbox
:value="edited.isShared"
@input="(ev) => $set(edited, 'isShared', ev)"
>
Make it availble for others (shared library)
</b-checkbox>
</b-field>
</div>
<footer class="card-footer">
<div class="card-footer-item">
<button
class="button is-success"
type="button"
@click="edited.id ? doUpdate() : doCreate()"
>
Save
</button>
<button
class="button is-cancel"
type="button"
@click="isEditModal = false"
>
Cancel
</button>
</div>
</footer>
</div>
</b-modal>
</section>
</template>
<script lang="ts">
import { Component, Vue, Watch } from 'nuxt-property-decorator'
import { api, ILibrary } from '~/assets/api'
import LibraryCard from '../cards/LibraryCard.vue'
@Component<LibraryTab>({
components: {
LibraryCard,
},
created() {
this.$emit('title', 'Library')
this.updateLocal()
},
})
export default class LibraryTab extends Vue {
q = ''
q0 = ''
isEditModal = false
local: {
result: ILibrary[]
count: number
page: number
perPage: number
} = {
result: [],
count: 0,
page: 1,
perPage: 10,
}
edited: ILibrary = {
id: '',
title: '',
list: [],
type: 'vocabulary',
description: '',
tag: [],
}
get entryString() {
return this.edited.list.join('\n')
}
set entryString(s) {
this.edited.list = s
.split('\n')
.filter((s) => s.trim())
.filter((a, i, r) => r.indexOf(a) === i)
}
additionalContext(it: ILibrary) {
if (!it.id) {
return []
}
return [
{
name: 'Edit list',
handler: () => {
this.openEditModal()
},
},
{
name: 'Delete list',
handler: () => {
if (it.id) {
this.doDelete(it.id)
}
},
},
]
}
openEditModal(it?: ILibrary) {
this.edited = it || {
id: '',
title: '',
list: [],
type: 'vocabulary',
description: '',
tag: [],
}
this.isEditModal = true
}
@Watch('q')
@Watch('local.page')
async updateLocal() {
const r = await api.library_getQuery({
q: this.q,
offset: this.local.page * this.local.perPage,
limit: this.local.perPage,
})
this.local = {
...this.local,
...r,
result: r.result,
}
}
async doCreate() {
await api.library_create(this.edited)
this.$buefy.snackbar.open(`Created list: ${this.edited.title}`)
this.isEditModal = false
this.local.page = 1
await this.updateLocal()
}
async doUpdate() {
const { id } = this.edited
if (id) {
await api.library_update(this.edited)
this.$buefy.snackbar.open(`Updated list: ${this.edited.title}`)
}
this.isEditModal = false
this.local.page = 1
await this.updateLocal()
}
async doDelete(id: string) {
await api.library_delete({ id })
this.$buefy.snackbar.open(`Deleted list: ${this.edited.title}`)
this.local.page = 1
await this.updateLocal()
}
}
</script>
<style scoped>
nav.pagination {
margin-top: 1rem;
}
.button + .button {
margin-left: 1rem;
}
.button.is-cancel {
background-color: rgb(215, 217, 219);
}
</style>