aboutsummaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
authorTerry Truong <terry06890@gmail.com>2023-01-05 17:13:03 +1100
committerTerry Truong <terry06890@gmail.com>2023-01-05 17:23:39 +1100
commit442c0bbffc5c372c7ec3510914968f75ab6e4a4f (patch)
treebc3ae52ec3954ce574961bce9d64f2d02516d18b /src/components
parenta3b13e700d8d65e27c1d90960b6ab6292e433c2c (diff)
Add partially-complete search modal
For now, use placeholder code for jumping to a search result. Add db index for case-insensitive event title searching. Make type=info requests accept title instead of ID (for looking up a searched-for title). Make EventInfo contain an Event field (for showing info in search suggestions). Add titleToEvent map in App, for use by SearchModal to look up searched-for titles. Add keyboard shortcuts to open/close search and info modals.
Diffstat (limited to 'src/components')
-rw-r--r--src/components/InfoModal.vue6
-rw-r--r--src/components/SearchModal.vue207
-rw-r--r--src/components/TimeLine.vue9
-rw-r--r--src/components/icon/InfoIcon.vue8
4 files changed, 220 insertions, 10 deletions
diff --git a/src/components/InfoModal.vue b/src/components/InfoModal.vue
index 6ab2bde..3e03187 100644
--- a/src/components/InfoModal.vue
+++ b/src/components/InfoModal.vue
@@ -74,7 +74,7 @@ import SCollapsible from './SCollapsible.vue';
import CloseIcon from './icon/CloseIcon.vue';
import DownIcon from './icon/DownIcon.vue';
import ExternalLinkIcon from './icon/ExternalLinkIcon.vue';
-import {HistEvent, EventInfo} from '../lib';
+import {EventInfo} from '../lib';
import {useStore} from '../store';
// Refs
@@ -86,14 +86,14 @@ const store = useStore();
// Props + events
const props = defineProps({
- event: {type: Object as PropType<HistEvent>, required: true},
eventInfo: {type: Object as PropType<EventInfo>, required: true},
});
const emit = defineEmits(['close']);
// For data display
+const event = computed(() => props.eventInfo.event)
const datesDisplayStr = computed(() => {
- return props.event.start.toString() + (props.event.end == null ? '' : ' to ' + props.event.end.toString())
+ return event.value.start.toString() + (event.value.end == null ? '' : ' to ' + event.value.end.toString())
});
function licenseToUrl(license: string){
license = license.toLowerCase().replaceAll('-', ' ');
diff --git a/src/components/SearchModal.vue b/src/components/SearchModal.vue
new file mode 100644
index 0000000..65d2496
--- /dev/null
+++ b/src/components/SearchModal.vue
@@ -0,0 +1,207 @@
+<template>
+<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onClose" ref="rootRef">
+ <div class="absolute left-1/2 -translate-x-1/2 top-1/4 -translate-y-1/2 min-w-3/4 md:min-w-[12cm] flex"
+ :style="styles">
+ <input type="text" class="block border p-1 px-2 rounded-l-[inherit] grow" ref="inputRef"
+ @keyup.enter="onSearch" @keyup.esc="onClose"
+ @input="onInput" @keydown.down.prevent="onDownKey" @keydown.up.prevent="onUpKey"/>
+ <div class="p-1 hover:cursor-pointer">
+ <search-icon @click.stop="onSearch" class="w-8 h-8"/>
+ </div>
+ <div class="absolute top-[100%] w-full overflow-hidden" :style="suggContainerStyles">
+ <div v-for="(sugg, idx) of searchSuggs" :key="sugg"
+ :style="{backgroundColor: idx == focusedSuggIdx ? store.color.bgAltDark : store.color.bgAlt}"
+ class="border-b p-1 px-2 hover:underline hover:cursor-pointer flex"
+ @click="resolveSearch(sugg)">
+ <div class="grow overflow-hidden whitespace-nowrap text-ellipsis">
+ <span>{{suggDisplayStrings[idx][0]}}</span>
+ <span class="font-bold text-yellow-600">{{suggDisplayStrings[idx][1]}}</span>
+ <span>{{suggDisplayStrings[idx][2]}}</span>
+ </div>
+ <info-icon class="hover:cursor-pointer my-auto w-5 h-5"
+ @click.stop="onInfoIconClick(sugg)"/>
+ </div>
+ <div v-if="hasMoreSuggs" class="text-center">&#x2022; &#x2022; &#x2022;</div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script setup lang="ts">
+import {ref, computed, onMounted, onUnmounted, PropType} from 'vue';
+import SearchIcon from './icon/SearchIcon.vue';
+import InfoIcon from './icon/InfoIcon.vue';
+import {HistEvent, queryServer, EventInfoJson, jsonToEventInfo, SuggResponseJson} from '../lib';
+import {useStore} from '../store';
+import {RBTree} from '../rbtree';
+
+// Refs
+const rootRef = ref(null as HTMLDivElement | null);
+const inputRef = ref(null as HTMLInputElement | null);
+
+// Global store
+const store = useStore();
+
+// Props + events
+const props = defineProps({
+ eventTree: {type: Object as PropType<RBTree<HistEvent>>, required: true},
+ titleToEvent: {type: Object as PropType<Map<string, HistEvent>>, required: true},
+});
+const emit = defineEmits(['search', 'close', 'info-click']);
+
+// Search-suggestion data
+const searchSuggs = ref([] as string[]);
+const hasMoreSuggs = ref(false);
+const suggsInput = ref(''); // The input that resulted in the current suggestions (used to highlight matching text)
+const suggDisplayStrings = computed((): [string, string, string][] => {
+ let result: [string, string, string][] = [];
+ let input = suggsInput.value;
+ // Split each suggestion's text into parts before/within/after an input match
+ for (let title of searchSuggs.value){
+ let idx = title.toLowerCase().indexOf(input.toLowerCase());
+ if (idx != -1){
+ result.push([
+ title.substring(0, idx),
+ title.substring(idx, idx + input.length),
+ title.substring(idx + input.length)
+ ]);
+ } else {
+ result.push([input, '', '']);
+ }
+ }
+ return result;
+});
+const focusedSuggIdx = ref(null as null | number); // Index of a suggestion selected using the arrow keys
+
+// For server requests
+const lastReqTime = ref(0);
+const pendingReqParams = ref(null as null | URLSearchParams); // Holds data for latest request to make
+const pendingReqInput = ref(''); // Holds the user input associated with pendingReqData
+const pendingDelayedSuggReq = ref(0); // Set via setTimeout() for making a request despite a previous one still waiting
+async function onInput(){
+ let input = inputRef.value!;
+ // Check for empty input
+ if (input.value.length == 0){
+ searchSuggs.value = [];
+ hasMoreSuggs.value = false;
+ focusedSuggIdx.value = null;
+ return;
+ }
+ // Create URL params
+ let urlParams = new URLSearchParams({
+ type: 'sugg',
+ input: input.value,
+ limit: String(store.searchSuggLimit),
+ });
+ // Code for querying server
+ pendingReqParams.value = urlParams;
+ pendingReqInput.value = input.value;
+ let doReq = async () => {
+ let reqInput = pendingReqInput.value;
+ let responseObj: SuggResponseJson | null = await queryServer(pendingReqParams.value!);
+ if (responseObj == null){
+ return;
+ }
+ searchSuggs.value = responseObj.suggs;
+ hasMoreSuggs.value = responseObj.hasMore;
+ suggsInput.value = reqInput;
+ // Auto-select first result if present
+ if (searchSuggs.value.length > 0){
+ focusedSuggIdx.value = 0;
+ } else {
+ focusedSuggIdx.value = null;
+ }
+ };
+ // Query server, delaying/skipping if a request was recently sent
+ let currentTime = new Date().getTime();
+ if (lastReqTime.value == 0){
+ lastReqTime.value = currentTime;
+ await doReq();
+ if (lastReqTime.value == currentTime){
+ lastReqTime.value = 0;
+ }
+ } else if (pendingDelayedSuggReq.value == 0){
+ lastReqTime.value = currentTime;
+ pendingDelayedSuggReq.value = window.setTimeout(async () => {
+ pendingDelayedSuggReq.value = 0;
+ await doReq();
+ if (lastReqTime.value == currentTime){
+ lastReqTime.value = 0;
+ }
+ }, 300);
+ }
+}
+
+// For search events
+function onSearch(){
+ if (focusedSuggIdx.value == null){
+ let input = inputRef.value!.value.toLowerCase();
+ resolveSearch(input)
+ } else {
+ resolveSearch(searchSuggs.value[focusedSuggIdx.value]);
+ }
+}
+async function resolveSearch(eventTitle: string){
+ if (eventTitle == ''){
+ return;
+ }
+ // Check if the event data is already here
+ if (props.titleToEvent.has(eventTitle)){
+ emit('search', props.titleToEvent.get(eventTitle));
+ return;
+ }
+ // Query server for event
+ let urlParams = new URLSearchParams({type: 'info', event: eventTitle});
+ let responseObj: EventInfoJson | null = await queryServer(urlParams);
+ if (responseObj != null){
+ let eventInfo = jsonToEventInfo(responseObj);
+ emit('search', eventInfo.event);
+ } else {
+ // Trigger failure animation
+ let input = inputRef.value!;
+ input.classList.remove('animate-red-then-fade');
+ input.offsetWidth; // Triggers reflow
+ input.classList.add('animate-red-then-fade');
+ }
+}
+
+// More event handling
+function onClose(evt: Event){
+ if (evt.target == rootRef.value){
+ emit('close');
+ }
+}
+function onDownKey(){
+ if (focusedSuggIdx.value != null){
+ focusedSuggIdx.value = (focusedSuggIdx.value + 1) % searchSuggs.value.length;
+ }
+}
+function onUpKey(){
+ if (focusedSuggIdx.value != null){
+ focusedSuggIdx.value = (focusedSuggIdx.value - 1 + searchSuggs.value.length) % searchSuggs.value.length;
+ // The addition after '-1' is to avoid becoming negative
+ }
+}
+function onInfoIconClick(eventTitle: string){
+ emit('info-click', eventTitle);
+}
+
+// Focus input on mount
+onMounted(() => inputRef.value!.focus())
+
+// Styles
+const styles = computed((): Record<string,string> => {
+ let br = store.borderRadius;
+ return {
+ backgroundColor: store.color.bgAlt,
+ borderRadius: (searchSuggs.value.length == 0) ? `${br}px` : `${br}px ${br}px 0 0`,
+ };
+});
+const suggContainerStyles = computed((): Record<string,string> => {
+ let br = store.borderRadius;
+ return {
+ backgroundColor: store.color.bgAlt,
+ borderRadius: `0 0 ${br}px ${br}px`,
+ };
+});
+</script>
diff --git a/src/components/TimeLine.vue b/src/components/TimeLine.vue
index 277a263..e5fbf34 100644
--- a/src/components/TimeLine.vue
+++ b/src/components/TimeLine.vue
@@ -48,7 +48,7 @@
<div v-for="id in idToPos.keys()" :key="id" class="absolute animate-fadein z-20" :style="eventStyles(id)">
<!-- Image -->
<div class="rounded-full cursor-pointer hover:brightness-125" :style="eventImgStyles(id)"
- @click="onEventClick(id)"></div>
+ @click="emit('info-click', idToEvent.get(id)!.title)"></div>
<!-- Label -->
<div class="text-center text-stone-100 text-sm whitespace-nowrap text-ellipsis overflow-hidden">
{{idToEvent.get(id)!.title}}
@@ -96,7 +96,7 @@ const props = defineProps({
eventTree: {type: Object as PropType<RBTree<HistEvent>>, required: true},
unitCountMaps: {type: Object as PropType<Map<number, number>[]>, required: true},
});
-const emit = defineEmits(['close', 'state-chg', 'event-display', 'event-click']);
+const emit = defineEmits(['close', 'state-chg', 'event-display', 'info-click']);
// For size tracking
const width = ref(0);
@@ -1109,11 +1109,6 @@ watch(firstDate, onStateChg);
const skipTransition = ref(true);
onMounted(() => setTimeout(() => {skipTransition.value = false}, 100));
-// Click handling
-function onEventClick(eventId: number){
- emit('event-click', eventId);
-}
-
// Styles
const mainlineStyles = computed(() => {
return {
diff --git a/src/components/icon/InfoIcon.vue b/src/components/icon/InfoIcon.vue
new file mode 100644
index 0000000..47a14cd
--- /dev/null
+++ b/src/components/icon/InfoIcon.vue
@@ -0,0 +1,8 @@
+<template>
+<svg viewBox="0 0 24 24" fill="none"
+ stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+ <circle cx="12" cy="12" r="10"/>
+ <line x1="12" y1="16" x2="12" y2="12"/>
+ <line x1="12" y1="8" x2="12.01" y2="8"/>
+</svg>
+</template>