<template> <main> <div class="search_container"> <Autocomplete v-on:submit="select_result" v-on:update-lang-form="update_lang_form"> </Autocomplete> <SearchToolbar @updatePos="update_pos" @updateScope="update_scope" /> </div> <div id="notifications" v-if="$route.name && !error" :class="$vuetify.breakpoint.name"> <div id="suggestions" v-if="!article && !no_results"> <div class="search_notification" v-if="inflection_suggestions && inflection_suggestions.length"> <v-icon left color="primary">info</v-icon><em>{{queryString}}</em> {{$t('notifications.inflected')}}<!-- --><span v-for="(item,index) in inflection_suggestions" :key="index"><!-- --><router-link :to="generate_path({q: item[0]})" @click.native="inflection_link(item[0])">{{item[0]}}</router-link><!-- -->{{index == inflection_suggestions.length-1? '.' : ', '}}</span> </div> <div class="search_notification" v-if="lang=='bm,nn' && similar && similar.length > 0 && (search_results.nn && search_results.nn.length == 0)"> <v-icon left color="primary">info</v-icon>{{$t('notifications.similar_nn')}}<!-- --><span v-for="(item,index) in similar" :key="index"><!-- --><router-link :to="generate_path({q: queryString+'|'+item[0]})" @click.native="similar_link(queryString+'|'+item[0])">{{item[0]}}</router-link><!-- -->{{index == similar.length-1? '.' : ', '}} </span> </div> <div class="search_notification" v-if="lang=='bm,nn' && similar && similar.length > 0 && (search_results.bm && search_results.bm.length == 0)"> <v-icon left color="primary">info</v-icon>{{$t('notifications.similar_bm')}}<!-- --><span v-for="(item,index) in similar" :key="index"><!-- --><router-link :to="generate_path({q: queryString+'|'+item[0]})" @click.native="similar_link(queryString+'|'+item[0])">{{item[0]}}</router-link><!-- -->{{index == similar.length-1? '.' : ', '}} </span> </div> </div> <div id="return_to_results" v-if="$vuetify.breakpoint.mdAndUp && article && $store.state.searchRoute"> <router-link id="return_link" :to="$store.state.searchRoute" @click.native="return_to_results()"> <v-icon left class="nav_arrow">chevron_left</v-icon>{{$t("notifications.back")}} </router-link> </div> <div class="no_results" v-if="no_results && !error"> <div> <p> <v-icon left color=primary>error</v-icon> <strong>{{$t('notifications.no_results')}}<span v-if="pos_selected">{{$t('notifications.no_pos_results', {pos: $t('pos_tags_plural.'+pos_selected)})}}</span></strong> </p> <p class="below-notification" v-if="!article && inflection_suggestions && inflection_suggestions.length"> <em>{{this.queryString}}</em>{{$t('notifications.inflected')}} <span v-for="(item,index) in inflection_suggestions" :key="index"><!-- --><router-link :to="generate_path({q: item[0]})" @click.native="inflection_link(item[0])">{{item[0]}}</router-link><!-- -->{{index == inflection_suggestions.length-1? '.' : ', '}}</span> <p class="below-notification" v-if="lang=='bm' && suggest_other_dict">{{$t('notifications.suggest_dict[1]')}} <router-link :to="generate_lang_path('nn')" @click.native="language_link('nn')">{{$t('dicts.nn')}}</router-link><!-- --></p> <p class="below-notification" v-if="lang=='nn' && suggest_other_dict">{{$t('notifications.suggest_dict[0]')}}<br>{{$t('notifications.suggest_dict[1]')}} <router-link :to="generate_lang_path('bm')" @click.native="language_link('bm')">{{$t('dicts_inline.bm')}}</router-link> </p> <p class="below-notification" v-if="suggest_fulltext"> {{$t('notifications.fulltext[0]')}} <router-link :to="generate_path({scope: scope+'f'})" @click.native="fulltext_link()"> {{$t('notifications.fulltext[1]')}} </router-link> </p> <article v-if="similar && similar.length" :class="'v-sheet v-card rounded-xl did_you_mean ' + $vuetify.breakpoint.name"> <span class="dict-label">{{$t('notifications.similar')}}</span> <v-list> <template v-for="(item, index) in similar"> <v-list-item :key="index"> <router-link :to="generate_path({q: item[0]})" @click.native="similar_link(item[0])">{{item[0]}}</router-link> <span class="dict-parentheses" v-if="lang=='bm,nn'"> ({{["bokmål","nynorsk","bokmål, nynorsk"][item[1]-1]}})</span> </v-list-item> </template> </v-list> </article> </div> </div> </div> <SearchResults :results_bm="search_results.bm || []" :results_nn="search_results.nn || []" :lang="lang" @article-click="article_link_click" @details-click="details_click" @update-page="update_page" v-if="$route.name && !article && !error && !no_results" /> <div id="spinner" v-if="waiting"> <v-progress-circular indeterminate color="secondary" size="120"></v-progress-circular> </div> <div id="single_article_container" v-if="article && !error && !no_results" :class="$store.state.searchRoute && $vuetify.breakpoint.mdAndUp && lang == 'bm,nn' ? article.dictionary : null"> <Article :key="article_key" :article="article" title_id="result0" @article-click="article_link_click" @details-click="details_click" articleLookup/> </div> <div class="welcome" v-show="!error && ($route.name=='/' || !$route.name)"> <div class="monthly" :class="$vuetify.breakpoint.name"> <div> <Article :article="monthly_bm" title_id="result0" @article-click="article_link_click" @details-click="details_click" /> </div> <div> <Article :article="monthly_nn" title_id="result1" @article-click="article_link_click" @details-click="details_click" /> </div> </div> </div> <div class="error" v-if="error"> <div> <h1 tabindex="0" id="result0">{{error.title}}</h1> <p>{{error.description}}</p> </div> </div> </main> </template> <script> import axios from "axios" import entities from '../utils/entities.js' import Article from './Article.vue' import SearchResults from './SearchResults.vue' import Autocomplete from './Autocomplete.vue' import SearchToolbar from './SearchToolbar.vue' const ENDPOINT = process.env.VUE_APP_ENDPOINT const FALLBACK_ENDPOINT = process.env.VUE_APP_FALLBACK_ENDPOINT function navigate_to_article(self, origin) { self.article = null self.waiting_for_articles = true const lang = self.$route.params.lang self.api.get(lang + '/article/' + self.$route.params.id + ".json") //self.api.get("https://httpstat.us/502") .then(function(response){ self.article = Object.assign(response.data, {'dictionary': lang, results: self.search_results}) self.error = null }) .catch(function(error){ self.handle_error(error, {retry: navigate_to_article, arg: origin, article: true}) }) .then(function(response){ self.waiting_for_articles = false self.replace_history() //if (origin) self.$plausible.trackEvent('internal link incoming', {props: {origin}}) }) } async function load_articles(self, query, offset, n, dict) { let article_IDs = self.article_info.articles[dict] if (article_IDs) { if (offset > article_IDs.length) { n = 0 } else if (offset + n > article_IDs.length) { n = article_IDs.length % n } if (n > 0 && (self.lang == dict || self.lang == "bm,nn")) { article_IDs = article_IDs.slice(offset, offset + n) return Promise.all(article_IDs.map((article_id) => { return self.api.get(`${dict}/article/${article_id}.json`) //return self.api.get(`https://httpstat.us/502`) })) .then((response) => { let results = response.map((element, index) => { return Object.assign(element.data, { dictionary: dict }) }) self.article = null self.search_results[dict] = results }) .catch(error => { self.handle_error(error, {}) }) } else { self.search_results[dict] = [] } } return Promise.resolve() } function navigate_to_query(self, word, keep_page) { self.error = null self.no_results = null self.waiting_for_articles = true self.inflection_suggestions = [] self.similar = [] self.suggest_fulltext = false self.suggest_other_dict = false if (!self.event) { self.event = {match: word} } let query = self.event let q = query.match if (!keep_page) { self.page = 1 } let advanced_search = /[?_*%|]/.test(q) // Get inflections if (!advanced_search && self.$route.name == 'search') { let params = {q, dict: self.lang, dform: 'int', include: "i", meta: 'n', wc: self.pos_selected} self.api.get('api/suggest?', {params}) //self.api.get('https://httpstat.us/502') .then((response) => { self.inflection_suggestions = response.data.a.inflect }).catch(error =>{ self.handle_error(error, {retry: navigate_to_query, arg: q}) self.replace_history() }) } // Get article IDs let params = {w: query.match, dict: self.lang, scope: self.scope} let offset = 0 if (self.page) { offset = self.perPage * (self.page -1) } if (self.pos_selected) params.wc = self.pos_selected self.api.get('api/articles?', {params}).then((response) => { //self.api.get('https://httpstat.us/502', {params}).then((response) => { self.article_info = response.data self.search_results = {} let bm_length = response.data.articles.bm ? response.data.articles.bm.length : 0 let nn_length = response.data.articles.nn ? response.data.articles.nn.length : 0 let total_length = bm_length + nn_length let dict = self.lang // Similar if (bm_length == 0 || nn_length == 0) { if (!advanced_search) { if (dict == 'bm,nn' && total_length > 0) { dict = bm_length == 0? 'bm' : 'nn' } let params = {q, dict, dform: 'int', include: "s", wc: self.pos_selected} self.api.get('api/suggest?', {params}) //axios.get('https://httpstat.us/502') .then((response) => { self.similar = response.data.a.similar self.replace_history() }).catch(error => { self.handle_error(error, {retry: navigate_to_query, arg: q}) self.replace_history() }) } else { self.similar = [] } } if (total_length == 0) { self.waiting_for_articles = false self.no_results = true if (!self.scope.includes('f')) { let params = {q, dict, n: 1, dform: 'int', include: 'f', wc: self.pos_selected} self.api.get('api/suggest?', {params}).then((response) => { self.suggest_fulltext = response.data.cnt > 0 }).catch(error => { self.handle_error(error, {retry: navigate_to_query, arg: q}) self.replace_history() }) } if (dict != 'bm,nn') { let params = {q, n: 1, dict: dict=='bm'?'nn':'bm', dform: 'int', include: 'e', wc: self.pos_selected} self.api.get('api/suggest?', {params}).then((response) => { self.suggest_other_dict = response.data.cnt > 0 && response.data.a.exact[0][0] == q }).catch(error => { self.handle_error(error, {retry: navigate_to_query, arg: q}) self.replace_history() }) } self.replace_history() // fixes routing bug when going back from suggested search } else { self.no_results = false Promise.all([ load_articles(self, query, offset, self.perPage, "bm"), load_articles(self, query, offset, self.perPage, "nn") ]) .then(() => { self.waiting_for_articles = false self.$store.commit('setSearchRoute', self.$route.fullPath) self.replace_history() }) } }).catch(error =>{ self.handle_error(error, {retry: navigate_to_query, arg: q}) self.replace_history() }) } export default { name: 'DictionaryView', data: function() { return { api: null, fallback: false, article_key: 0, search_results: {}, lang: this.$store.state.defaultDict, waiting_for_articles: true, waiting_for_metadata: true, article: null, error: null, no_results: false, monthly_bm: null, monthly_nn: null, event: null, scope: "ei", pos_selected: "ALL", article_info: null, page: 1, perPage: 10, inflection_suggestions: null, similar: null, selected: null, suggest_fulltext: false, suggest_other_dict: false } }, computed: { queryString: function() { return this.$route.query.q || this.$route.params.q }, waiting: function() { return (this.waiting_for_articles || this.waiting_for_metadata) && this.$route.name != 'root' } }, metaInfo() { if (this.no_results) { return {meta: [{name: "robots", content: 'none'}]} } let q = "" if (this.queryString) { q = this.queryString + (this.lang == 'bm,nn' ? ' - ' : ' | ') } let desc = " viser skrivemåte og bøying i tråd med norsk rettskriving. Språkrådet og Universitetet i Bergen står bak ordbøkene." switch (this.lang) { case 'bm,nn': return {title: q+'ordbøkene.no', meta: [{name: "description", vmid: 'description', content: "Bokmålsordboka og Nynorskordboka"+desc}]} case 'bm': return {title: q+"Bokmålsordboka", meta: [{name: "description", vmid: 'description', content: "Bokmålsordboka"+desc}]} case 'nn': return {title: q+"Nynorskordboka", meta: [{name: "description", vmid: 'description', content: "Nynorskordboka"+desc}] } } }, components: { Article, Autocomplete, SearchResults, SearchToolbar }, methods: { load_welcome_and_metadata: function() { let self = this Promise.all([ self.api.get('bm/concepts.json').then(function(response){ let concepts = response.data.concepts entities.bm = concepts }), self.api.get('nn/concepts.json').then(function(response){ let concepts = response.data.concepts entities.nn = concepts }) ]).then(function(_) { self.waiting_for_metadata = false if (self.$route.name == 'search') { navigate_to_query(self, self.$route.query.q, true) } else if(self.$route.name == 'word') { self.scope = 'e' self.pos = null navigate_to_query(self, self.$route.params.q, true) } else if(self.$route.name == 'lookup'){ navigate_to_article(self, self.$route.path) } else { self.waiting_for_articles = false self.replace_history() self.load_monthly_bm() self.load_monthly_nn() } }).catch(function(error){ self.api = axios.create({baseURL: FALLBACK_ENDPOINT}) if (!self.error || !self.error.response) { if (self.fallback) { if (error.response) { self.error = {title: self.$t('error.server.title'), description: self.$t('error.server.description', {code: error.response.status}), response: error.response} } else if (error.message == "Network Error") { self.error = {title: self.$t('error.network.title'), description: self.$t('error.network.description')} } else { self.error = {title: self.$t('error.generic.title'), description: self.$t('error.generic.description')} } self.waiting_for_metadata = false self.waiting_for_articles = false } else { self.fallback = true self.load_welcome_and_metadata() } } }) }, replace_history: function() { history.replaceState({article: this.article, search_results: this.search_results, article_info: this.article_info, lang: this.lang, error: this.error, no_results: this.no_results, pos_selected: this.pos_selected, scope: this.scope, page: this.page, perPage: this.perPage, inflection_suggestions: this.inflection_suggestions, similar: this.similar, suggest_fulltext: this.suggest_fulltext, suggest_other_dict: this.suggest_other_dict}, '') }, total_results: function() { if (this.article_info) { let total = 0 if (this.article_info.articles.bm) { total += this.article_info.articles.bm.length } if (this.article_info.articles.nn) { total += this.article_info.articles.nn.length } return total } }, load_monthly_bm: function() { let self = this this.api.get('bm/article/502.json').then(function(response){ self.monthly_bm = Object.assign(response.data, {dictionary: 'bm'}) }) }, load_monthly_nn: function() { let self = this this.api.get('nn/article/493.json').then(function(response){ self.monthly_nn = Object.assign(response.data, {dictionary: 'nn'}) }) }, handle_error: function(error, retry_params) { this.waiting_for_articles = false this.no_results = false this.search_results = {} this.inflection_suggestions = [] this.similar = [] this.suggest_fulltext = false this.suggest_other_dict = false this.api = axios.create({baseURL: FALLBACK_ENDPOINT}) if (!this.error || !this.error.response) { if (this.fallback || !retry_params.retry) { this.fallback = true if (error.response) { if (error.response.status == 404) { if (retry_params.article) { this.error = {title: this.$t('error.404.title'), description: this.$t('error.no_article', {id: this.$route.params.id}), article: true, response: error.response} } else { this.error = {title: this.$t('error.404.title'), description: this.$t('error.404.description'), article: retry_params.article, response: error.response} } } else if (error.response.status == 503) { this.error = {title: this.$t('error.503.title'), description: this.$t('error.503.description'), article: retry_params.article, response: error.response} } else if (String(error.response.status)[0] == "5") { this.error = {title: this.$t('error.server.title'), description: this.$t('error.server.description', {code: error.response.status}), article: retry_params.article, response: error.response} } else { this.error = {title: this.$t('error.generic_code.title'), description: this.$t('error.generic_code.description', {code: error.response.status}), article: retry_params.article, response: error.response} } } else if (error.message == "Network Error") { this.error = {title: this.$t('error.network.title'), description: this.$t('error.network.description'), article: retry_params.article, response: error.response} } else { this.error = {title: this.$t('error.generic.title'), description: this.$t('error.generic.description'), article: retry_params.article, response: error.response} } } else { this.fallback = true retry_params.retry(this, retry_params.arg) } } }, inflection_link: function (word) { //this.$plausible.trackEvent('inflection link', {props: {lang: this.previous.params.lang, from: this.previous.query.q, to: word}}) this.event = null navigate_to_query(this, word) }, similar_link: function (word) { //this.$plausible.trackEvent('similar link', {props: {lang: this.previous.params.lang, from: this.previous.query.q, to: word}}) this.event = null navigate_to_query(this, word) }, fulltext_link: function () { this.event = null this.scope = this.scope + "f" navigate_to_query(this, this.queryString) }, language_link: function (lang) { this.lang = lang this.event = null navigate_to_query(this, this.queryString) }, select_result: function (event) { this.event = event let path = `/${this.lang}/search` let pos = this.pos_param() let query = {q: event.match || event.q} if (pos) query["pos"] = pos if (this.scope) query["scope"] = this.scope this.$router.push({path, query}) navigate_to_query(this) // Tracking let track_props = {query: event.q} if (event.match) track_props.match = event.match //this.$plausible.trackEvent('dropdown selection', { props: track_props }) }, pos_param: function() { if (this.pos_selected) return this.pos_selected.toLowerCase() return null }, update_page: function() { this.waiting_for_articles = true let q = this.queryString let path = `/${this.lang}/search` let pos = this.pos_param() let query = {q: q, page: this.page} if (pos != 'all') query.pos = pos if (this.scope) query.scope = this.scope if (this.perPage) query.perPage = this.perPage this.$router.push({path, query}) let offset = 0 if (this.page) { offset = this.perPage * (this.page -1) } let self = this Promise.all([ load_articles(this, query, offset, this.perPage, "bm"), load_articles(this, query, offset, this.perPage, "nn")]).then(() => { self.replace_history() self.$forceUpdate() /* // Debugging if (self.page < Math.ceil(Math.max(self.article_info.articles.bm.length, self.article_info.articles.nn.length)/self.perPage)) { self.page+=1 self.update_page() } */ } ).then(() => { this.$store.commit('setSearchRoute', this.$route.fullPath) this.waiting_for_articles = false }) }, generate_path: function(params) { if (this.$route.name == "word") { return this.$router.resolve({name: "search", query: {q: this.$route.params.q, ...params}}).href } else { return this.$router.resolve({query: {...this.$route.query, ...params}}).href } }, generate_lang_path: function(dict) { return this.$route.fullPath.replace(/\/(bm|nn|bm,nn)\//, "/"+dict+"/") }, reload_params: function() { let q = this.queryString if (q) { let path = `/${this.lang}/search` let pos = this.pos_param() let query = {q} if (pos) query.pos = pos if (this.scope) query.scope = this.scope if (this.scope) query.scope = this.scope if (this.perPage) query.perPage = this.perPage this.$router.push({path, query}) navigate_to_query(this, q) } }, update_lang_form: function (lang) { this.lang = lang this.$store.commit("setDefaultDict", lang) this.page = 1 }, update_scope: function(scope) { this.scope = scope this.page = 1 this.reload_params() }, update_pos: function (pos) { this.pos_selected = pos this.page = 1 this.reload_params() }, update_per_page: function(perPage) { this.perPage = perPage this.$store.commit('setPerPage', this.perPage) this.page = 1 this.reload_params() }, article_link_click: function(item) { let event = window.event if (!(event.ctrlKey || event.shiftKey)) { if (this.article && this.article.article_id == item.article_id){ this.article_key++ this.replace_history() }else{ navigate_to_article(this, item.source) } } }, details_click: function(item) { let event = window.event if (!(event.ctrlKey || event.shiftKey)) { this.article = item.article this.replace_history() } }, return_to_results: function() { this.article = null this.replace_history() }, set_fulltext_highlight: function() { if (this.$route.query.q && this.scope.includes("f")) { let q = this.$route.query.q q = q.replace(/\*|%/, "[^\\s]*") q = q.replace(/_|\?/, "[^\\s]") this.$store.commit('setFulltextHighlight', q) } else { this.$store.commit('setFulltextHighlight', false) } } }, mounted: function(){ this.api = axios.create({baseURL: ENDPOINT}) this.lang = this.$route.params.lang || this.$store.state.defaultDict || 'bm,nn' if (this.$route.query.pos) { this.pos_selected = this.$route.query.pos.toUpperCase() } else this.pos_selected = null if (this.$route.query.scope) { this.scope = this.$route.query.scope this.set_fulltext_highlight() } if (this.$route.query.page) this.page = parseInt(this.$route.query.page) if (this.$route.query.perPage) { this.perPage = parseInt(this.$route.query.perPage) } else { this.perPage = parseInt(this.$store.state.perPage) } this.load_welcome_and_metadata() }, watch: { $route(to, from) { this.previous = from if (to.fullPath == "/") { this.load_monthly_bm() this.load_monthly_nn() } if (to.name == 'lookup' && from.fullPath == '/') { this.$store.commit('setSearchRoute', null) } if (to.name == 'search') { this.set_fulltext_highlight() } } }, created: function() { let self = this window.onpopstate = function (event) { if (event.state) { if (event.state.lang) { self.article = event.state.article self.search_results = event.state.search_results self.article_info = event.state.article_info self.lang = event.state.lang self.pos_selected = event.state.pos_selected self.scope = event.state.scope self.error = event.state.error self.no_results = event.state.no_results self.page = event.state.page, self.perPage = event.state.perPage self.inflection_suggestions = event.state.inflection_suggestions, self.similar = event.state.similar, self.suggest_fulltext = event.state.suggest_fulltext, self.suggest_other_dict = event.state.suggest_other_dict if (!self.$route.hash && self.$route.name != 'search') { history.scrollRestoration = 'manual' window.scrollTo(0,0) } else { history.scrollRestoration = 'auto' } } else { console.log("Navigation errror") } } } } } </script> <style> main { flex: 1 0 auto; background-color: var(--v-tertiary-base); display: flex; flex-direction: column; } div.welcome { padding-top: 10px; flex-grow: 10; background-image: url('../assets/cate-brodersen-b7ukrYhnt8c-unsplash.webp'); background-repeat: no-repeat; background-position-x: center; background-position-y: -380px; padding-bottom: 10px; } div.welcome article { border-style: none; } .search_container { background-color: var(--v-tertiary-base); padding-top: 10px; } #spinner { margin: auto; } #search_results, #spinner, #notifications, #single_article_container, div.search_container, .error { padding-left: calc((100vw - 1200px) / 2); padding-right: calc((100vw - 1200px) / 2); } div.welcome { padding-left: calc((100vw - 917px) / 2); padding-right: calc((100vw - 917px) / 2); } #single_article_container { box-shadow: 0px 3px 1px -2px rgb(0 0 0 / 20%), 0px 2px 2px 0px rgb(0 0 0 / 14%), 0px 1px 5px 0px rgb(0 0 0 / 12%); background-color: white; height: 100%; } #single_article_container.nn { justify-content: flex-end !important; display: flex; } #single_article_container.nn article, #single_article_container.bm article{ width: 50%; } #notifications .search_notification { padding-top: 10px; padding-bottom: 0px; margin-left: 10px; font-size: 18px; } #suggestions { padding-left: 10px; } .error p, .no_results p { margin-left: 15px; } .no_results { padding-top: 24px; } .error div{ padding: 10px; padding-top: 24px; } #spinner { padding-top: 40px; } div.monthly { display: flex; width: 100%; } div.monthly > div { flex: 50%; } div.monthly.sm, div.monthly.xs { flex-direction: column; } div.monthly details, div.monthly h3 { display: none; } .v-label span { color: var(--v-primary-base); } .lang_select_container { padding-left: 10px; } .pos_select_container { padding-left: 10px; padding-right: 10px; padding-bottom: 0px; padding-top: 10px; } li.suggestion { font-weight: bold; padding-left: 20px; padding-top: 5px; padding-bottom: 5px; border: 0px; background-image: none; } ::selection { background: var(--v-secondary-base); color: white; } #return_to_results { padding-left: 10px; padding-top: 10px; padding-bottom: 10px; display: table-cell; } #return_to_results a { color: var(--v-text-base) !important; text-decoration: none; } .nav_arrow { color: var(--v-primary-base) !important; } .col { padding: 10px; } .below-notification { padding-left: 10px; } .did_you_mean.md, .did_you_mean.lg, .did_you_mean.xl { max-width: 40%; } .dict-parentheses { color: rgba(0,0,0,0.6); font-size: 85%; } </style>