From 442c0bbffc5c372c7ec3510914968f75ab6e4a4f Mon Sep 17 00:00:00 2001 From: Terry Truong Date: Thu, 5 Jan 2023 17:13:03 +1100 Subject: 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. --- src/App.vue | 75 +++++++++++--- src/components/InfoModal.vue | 6 +- src/components/SearchModal.vue | 207 +++++++++++++++++++++++++++++++++++++++ src/components/TimeLine.vue | 9 +- src/components/icon/InfoIcon.vue | 8 ++ src/index.css | 13 +++ src/lib.ts | 14 ++- src/store.ts | 27 ++--- 8 files changed, 319 insertions(+), 40 deletions(-) create mode 100644 src/components/SearchModal.vue create mode 100644 src/components/icon/InfoIcon.vue (limited to 'src') diff --git a/src/App.vue b/src/App.vue index 80ce803..2f5051a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -5,14 +5,17 @@

Histplorer

- - + + - - + + + + +
@@ -23,13 +26,16 @@ :eventTree="eventTree" :unitCountMaps="unitCountMaps" class="grow basis-full min-h-0 outline outline-1" @close="onTimelineClose(idx)" @state-chg="onTimelineChg($event, idx)" @event-display="onEventDisplay" - @event-click="onEventClick"/> + @info-click="onInfoClick"/> - + + + + @@ -40,11 +46,13 @@ import {ref, computed, onMounted, onUnmounted, Ref, shallowRef, ShallowRef} from import TimeLine from './components/TimeLine.vue'; import BaseLine from './components/BaseLine.vue'; import InfoModal from './components/InfoModal.vue'; +import SearchModal from './components/SearchModal.vue'; import IconButton from './components/IconButton.vue'; // Icons -import PlusIcon from './components/icon/PlusIcon.vue'; -import SettingsIcon from './components/icon/SettingsIcon.vue'; import HelpIcon from './components/icon/HelpIcon.vue'; +import SettingsIcon from './components/icon/SettingsIcon.vue'; +import PlusIcon from './components/icon/PlusIcon.vue'; +import SearchIcon from './components/icon/SearchIcon.vue'; // Other import {HistDate, HistEvent, queryServer, EventResponseJson, jsonToHistEvent, SCALES, stepDate, TimelineState, cmpHistEvent, dateToUnit, DateRangeTree, @@ -110,6 +118,7 @@ function onTimelineClose(idx: number){ // For storing and looking up events const eventTree: ShallowRef> = shallowRef(new RBTree(cmpHistEvent)); let idToEvent: Map = new Map(); +let titleToEvent: Map = new Map(); const unitCountMaps: Ref[]> = ref(SCALES.map(() => new Map())); // For each scale, maps units to event counts // For keeping event data under a memory limit @@ -149,6 +158,10 @@ function reduceEvents(){ eventTree.value = newTree; unitCountMaps.value = newMaps; idToEvent = eventsToKeep; + titleToEvent = new Map(); + for (let event of eventsToKeep.values()){ + titleToEvent.set(event.title, event); + } } // For getting events from server const EVENT_REQ_LIMIT = 300; @@ -214,6 +227,7 @@ async function onEventDisplay( }); let responseObj: EventResponseJson | null = await queryServer(urlParams); if (responseObj == null){ + console.log('WARNING: Server gave null response to event query'); return; } queriedRanges[scaleIdx].add([firstDate, lastDate]); @@ -225,6 +239,7 @@ async function onEventDisplay( if (success){ eventAdded = true; idToEvent.set(event.id, event); + titleToEvent.set(event.title, event); } } // Collect unit counts @@ -260,18 +275,25 @@ async function onEventDisplay( } // For info modal -const infoModalEvent = ref(null as HistEvent | null); const infoModalData = ref(null as EventInfo | null); -async function onEventClick(eventId: number){ +async function onInfoClick(eventTitle: string){ // Query server for event info - let urlParams = new URLSearchParams({type: 'info', event: String(eventId)}); + let urlParams = new URLSearchParams({type: 'info', event: eventTitle}); let responseObj: EventInfoJson | null = await queryServer(urlParams); if (responseObj != null){ - infoModalEvent.value = idToEvent.get(eventId)!; infoModalData.value = jsonToEventInfo(responseObj); + } else { + console.log('WARNING: Server gave null response to info query'); } } +// For search modal +const searchOpen = ref(false); +function onSearch(event: HistEvent){ + searchOpen.value = false; + console.log(`Need to jump to event "${event.title}" at ${event.start}`); +} + // For resize handling let lastResizeHdlrTime = 0; // Used to throttle resize handling let afterResizeHdlr = 0; // Used to trigger handler after ending a run of resize events @@ -298,6 +320,33 @@ async function onResize(){ onMounted(() => window.addEventListener('resize', onResize)); onUnmounted(() => window.removeEventListener('resize', onResize)); +// For keyboard shortcuts +function onKeyDown(evt: KeyboardEvent){ + if (store.disableShortcuts){ + return; + } + if (evt.key == 'Escape'){ + if (infoModalData.value != null){ + infoModalData.value = null; + } else if (searchOpen.value){ + searchOpen.value = false; + } + } else if (evt.key == 'f' && evt.ctrlKey){ + evt.preventDefault(); + // Open/focus search bar + if (!searchOpen.value){ + searchOpen.value = true; + } + } +} +onMounted(() => { + window.addEventListener('keydown', onKeyDown); + // Note: Need 'keydown' instead of 'keyup' to override default CTRL-F +}); +onUnmounted(() => { + window.removeEventListener('keydown', onKeyDown); +}); + // Styles const buttonStyles = computed(() => ({ color: store.color.text, 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, required: true}, eventInfo: {type: Object as PropType, 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 @@ + + + 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 @@
+ @click="emit('info-click', idToEvent.get(id)!.title)">
{{idToEvent.get(id)!.title}} @@ -96,7 +96,7 @@ const props = defineProps({ eventTree: {type: Object as PropType>, required: true}, unitCountMaps: {type: Object as PropType[]>, 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 @@ + diff --git a/src/index.css b/src/index.css index e050c82..e16c538 100644 --- a/src/index.css +++ b/src/index.css @@ -25,6 +25,19 @@ opacity: 1; } } +.animate-red-then-fade { + animation-name: red-then-fade; + animation-duration: 500ms; + animation-timing-function: ease-in; +} +@keyframes red-then-fade { + from { + background-color: rgba(255,0,0,0.2); + } + to { + background-color: transparent; + } +} /* Other */ @font-face { diff --git a/src/lib.ts b/src/lib.ts index 6f1a1fe..f6147dc 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -280,10 +280,12 @@ export class ImgInfo { } } export class EventInfo { + event: HistEvent; desc: string; wikiId: number; imgInfo: ImgInfo; - constructor(desc: string, wikiId: number, imgInfo: ImgInfo){ + constructor(event: HistEvent, desc: string, wikiId: number, imgInfo: ImgInfo){ + this.event = event; this.desc = desc; this.wikiId = wikiId; this.imgInfo = imgInfo; @@ -310,9 +312,6 @@ export async function queryServer(params: URLSearchParams, serverDataUrl=SERVER_ console.log(`Error with querying ${url.toString()}: ${error}`); return null; } - if (responseObj == null){ - console.log('WARNING: Server gave null response'); - } return responseObj; } export function getImagePath(imgId: number): string { @@ -341,6 +340,7 @@ export type EventResponseJson = { unitCounts: {[x: number]: number} | null, } export type EventInfoJson = { + event: HistEventJson, desc: string, wikiId: number, imgInfo: ImgInfoJson, @@ -351,6 +351,10 @@ export type ImgInfoJson = { artist: string, credit: string, } +export type SuggResponseJson = { + suggs: string[], + hasMore: boolean, +} export function jsonToHistDate(json: HistDateJson): HistDate { return new HistDate(json.gcal, json.year, json.month, json.day); } @@ -368,7 +372,7 @@ export function jsonToHistEvent(json: HistEventJson): HistEvent { ); } export function jsonToEventInfo(json: EventInfoJson): EventInfo { - return new EventInfo(json.desc, json.wikiId, jsonToImgInfo(json.imgInfo)); + return new EventInfo(jsonToHistEvent(json.event), json.desc, json.wikiId, jsonToImgInfo(json.imgInfo)); } export function jsonToImgInfo(json: ImgInfoJson): ImgInfo { return new ImgInfo(json.url, json.license, json.artist, json.credit); diff --git a/src/store.ts b/src/store.ts index 1d51cc7..393e4b8 100644 --- a/src/store.ts +++ b/src/store.ts @@ -8,19 +8,20 @@ import {CalDate} from './lib'; export const useStore = defineStore('store', { state: () => { const color = { // Note: For scrollbar colors on chrome, edit ./index.css - text: '#fafaf9', // stone-50 - textDark: '#a8a29e', // stone-400 - bg: '#292524', // stone-800 - bgLight: '#44403c', // stone-700 - bgDark: '#1c1917', // stone-900 - bgLight2: '#57534e', // stone-600 - bgDark2: '#0e0c0b', // darker version of stone-900 - alt: '#fde047', // yellow-300 - altDark: '#eab308', // yellow-500 - altDark2: '#ca8a04', // yellow-600 + text: '#fafaf9', // stone-50 + textDark: '#a8a29e', // stone-400 + bg: '#292524', // stone-800 + bgLight: '#44403c', // stone-700 + bgDark: '#1c1917', // stone-900 + bgLight2: '#57534e', // stone-600 + bgDark2: '#0e0c0b', // darker version of stone-900 + alt: '#fde047', // yellow-300 + altDark: '#eab308', // yellow-500 + altDark2: '#ca8a04', // yellow-600 altBg: '#6a5e2e', - alt2: '#2563eb', // sky-600 - bgAlt: '#f5f5f4', // stone-100 + alt2: '#2563eb', // sky-600 + bgAlt: '#f5f5f4', // stone-100 + bgAltDark: '#d6d3d1', // stone-300 }; return { tickLen: 16, @@ -46,6 +47,8 @@ export const useStore = defineStore('store', { showEventCounts: true, transitionDuration: 300, borderRadius: 5, // px + searchSuggLimit: 10, + disableShortcuts: false, }; }, }); -- cgit v1.2.3