@ -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> |
@ -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> |
@ -0,0 +1,71 @@ | |||
{ | |||
"compilerOptions": { | |||
/* Visit https://aka.ms/tsconfig.json to read more about this file */ | |||
/* Basic Options */ | |||
// "incremental": true, /* Enable incremental compilation */ | |||
// "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ | |||
// "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ | |||
// "lib": [], /* Specify library files to be included in the compilation. */ | |||
// "allowJs": true, /* Allow javascript files to be compiled. */ | |||
// "checkJs": true, /* Report errors in .js files. */ | |||
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ | |||
// "declaration": true, /* Generates corresponding '.d.ts' file. */ | |||
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ | |||
// "sourceMap": true, /* Generates corresponding '.map' file. */ | |||
// "outFile": "./", /* Concatenate and emit output to single file. */ | |||
// "outDir": "./", /* Redirect output structure to the directory. */ | |||
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ | |||
// "composite": true, /* Enable project compilation */ | |||
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ | |||
// "removeComments": true, /* Do not emit comments to output. */ | |||
// "noEmit": true, /* Do not emit outputs. */ | |||
// "importHelpers": true, /* Import emit helpers from 'tslib'. */ | |||
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ | |||
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ | |||
/* Strict Type-Checking Options */ | |||
"strict": true, /* Enable all strict type-checking options. */ | |||
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ | |||
"strictNullChecks": true, /* Enable strict null checks. */ | |||
"strictFunctionTypes": true, /* Enable strict checking of function types. */ | |||
"strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ | |||
"strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ | |||
"noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ | |||
"alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ | |||
/* Additional Checks */ | |||
"noUnusedLocals": true, /* Report errors on unused locals. */ | |||
"noUnusedParameters": true, /* Report errors on unused parameters. */ | |||
"noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ | |||
"noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ | |||
"noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ | |||
"noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ | |||
/* Module Resolution Options */ | |||
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ | |||
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ | |||
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ | |||
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ | |||
// "typeRoots": [], /* List of folders to include type definitions from. */ | |||
// "types": [], /* Type declaration files to be included in compilation. */ | |||
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ | |||
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ | |||
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ | |||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ | |||
/* Source Map Options */ | |||
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ | |||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ | |||
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ | |||
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ | |||
/* Experimental Options */ | |||
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ | |||
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ | |||
/* Advanced Options */ | |||
"skipLibCheck": true, /* Skip type checking of declaration files. */ | |||
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ | |||
} | |||
} |