diff options
| -rwxr-xr-x | backend/hist_data/gen_events_data.py | 1 | ||||
| -rwxr-xr-x | backend/histplorer.py | 47 | ||||
| -rw-r--r-- | backend/tests/test_histplorer.py | 14 | ||||
| -rw-r--r-- | src/App.vue | 75 | ||||
| -rw-r--r-- | src/components/InfoModal.vue | 6 | ||||
| -rw-r--r-- | src/components/SearchModal.vue | 207 | ||||
| -rw-r--r-- | src/components/TimeLine.vue | 9 | ||||
| -rw-r--r-- | src/components/icon/InfoIcon.vue | 8 | ||||
| -rw-r--r-- | src/index.css | 13 | ||||
| -rw-r--r-- | src/lib.ts | 14 | ||||
| -rw-r--r-- | src/store.ts | 27 |
11 files changed, 356 insertions, 65 deletions
diff --git a/backend/hist_data/gen_events_data.py b/backend/hist_data/gen_events_data.py index ee2fdc1..118b40c 100755 --- a/backend/hist_data/gen_events_data.py +++ b/backend/hist_data/gen_events_data.py @@ -178,6 +178,7 @@ def genData(wikidataFile: str, offsetsFile: str, dbFile: str, nProcs: int) -> No dbCur.execute('CREATE TABLE events (id INT PRIMARY KEY, title TEXT UNIQUE, ' \ 'start INT, start_upper INT, end INT, end_upper INT, fmt INT, ctg TEXT)') dbCur.execute('CREATE INDEX events_id_start_idx ON events(id, start)') + dbCur.execute('CREATE INDEX events_title_nocase_idx ON events(title COLLATE NOCASE)') if nProcs == 1: with bz2.open(wikidataFile, mode='rb') as file: for lineNum, line in enumerate(file, 1): diff --git a/backend/histplorer.py b/backend/histplorer.py index 86d0e2a..c20116e 100755 --- a/backend/histplorer.py +++ b/backend/histplorer.py @@ -13,7 +13,7 @@ Expected HTTP query parameters: range=-13000. means '13000 BC onwards' - scale: With type=events, specifies a date scale - incl: With type=events, specifies an event to include, as an event ID -- event: With type=info, specifies the event ID to get info for +- event: With type=info, specifies the event title to get info for - input: With type=sugg, specifies a search string to suggest for - limit: With type=events or type=sugg, specifies the max number of results - ctg: With type=events or type=sugg, specifies an event category to restrict results to @@ -91,14 +91,15 @@ class ImgInfo: return str(self.__dict__) class EventInfo: """ Used when responding to type=info requests """ - def __init__(self, desc: str, wikiId: int, imgInfo: ImgInfo): + def __init__(self, event: Event, desc: str, wikiId: int, imgInfo: ImgInfo): + self.event = event self.desc = desc self.wikiId = wikiId self.imgInfo = imgInfo # Used in unit testing def __eq__(self, other): return isinstance(other, EventInfo) and \ - (self.desc, self.wikiId, self.imgInfo) == (other.desc, other.wikiId, other.imgInfo) + (self.event, self.desc, self.wikiId, self.imgInfo) == (other.event, other.desc, other.wikiId, other.imgInfo) def __repr__(self): return str(self.__dict__) class SuggResponse: @@ -109,7 +110,7 @@ class SuggResponse: # Used in unit testing def __eq__(self, other): return isinstance(other, SuggResponse) and \ - (set(self.suggs), self.hasMore) == (set(other.suggs), other.hasMore) + (self.suggs, self.hasMore) == (other.suggs, other.hasMore) def __repr__(self): return str(self.__dict__) @@ -268,8 +269,8 @@ def eventEntryToResults( if n is not None: newDates[i] = dbDateToHistDate(n, fmt, i < 2) # - return Event(eventId, title, newDates[0], newDates[1], newDates[2], newDates[3], ctg, 0, pop) #return Event(eventId, title, newDates[0], newDates[1], newDates[2], newDates[3], ctg, imageId, pop) + return Event(eventId, title, newDates[0], newDates[1], newDates[2], newDates[3], ctg, 0, pop) def lookupUnitCounts( start: HistDate | None, end: HistDate | None, scale: int, dbCur: sqlite3.Cursor) -> dict[int, int] | None: # Build query @@ -294,23 +295,26 @@ def handleInfoReq(params: dict[str, str], dbCur: sqlite3.Cursor): if 'event' not in params: print('INFO: No \'event\' parameter for type=info request', file=sys.stderr) return None - try: - eventId = int(params['event']) - except ValueError: - print('INFO: Invalid value for \'event\' parameter', file=sys.stderr) - return None - return lookupEventInfo(eventId, dbCur) -def lookupEventInfo(eventId: int, dbCur: sqlite3.Cursor) -> EventInfo | None: - """ Look up an event with given ID, and return a descriptive EventInfo """ - return EventInfo(f'DESC {eventId}', 1, ImgInfo(f'http://example.org/{eventId}', 'license', 'artist', 'credit')) - #query = 'SELECT desc, wiki_id, url, license, artist, credit FROM events' \ + return lookupEventInfo(params['event'], dbCur) +def lookupEventInfo(eventTitle: str, dbCur: sqlite3.Cursor) -> EventInfo | None: + """ Look up an event with given title, and return a descriptive EventInfo """ + return EventInfo( + Event(1, eventTitle, HistDate(True, 2000, 10, 1), None, None, None, 'event', 10, 100), + f'DESC for {eventTitle}', 1, ImgInfo(f'http://example.org/{eventTitle}', 'license', 'artist', 'credit')) + #query = \ + # 'SELECT events.id, title, start, start_upper, end, end_upper, fmt, ctg, images.id, pop.pop, ' \ + # ' descs.desc, descs.wiki_id, ' \ + # ' images.url, images.license, images.artist, images.credit FROM events' \ + # ' INNER JOIN pop ON events.id = pop.id' \ # ' INNER JOIN descs ON events.id = descs.id' \ - # ' INNER JOIN event_imgs ON events.id = event_imgs.id INNER JOIN images ON event_imgs.img_id = images.id' \ - # ' WHERE events.id = ?' - #row = dbCur.execute(query, (eventId,)).fetchone() + # ' INNER JOIN event_imgs ON events.id = event_imgs.id' \ + # ' INNER JOIN images ON event_imgs.img_id = images.id' \ + # ' WHERE events.title = ? COLLATE NOCASE' + #row = dbCur.execute(query, (eventTitle,)).fetchone() #if row is not None: - # desc, wikiId, url, license, artist, credit = row - # return EventInfo(desc, wikiId, ImgInfo(url, license, artist, credit)) + # event = eventEntryToResults(row[:10]) + # desc, wikiId, url, license, artist, credit = row[10:] + # return EventInfo(event, desc, wikiId, ImgInfo(url, license, artist, credit)) #else: # return None @@ -350,12 +354,13 @@ def lookupSuggs(searchStr: str, resultLimit: int, ctg: str | None, dbCur: sqlite for (title,) in dbCur.execute(query, params): suggs.append(title) # If insufficient results, try substring search - existing = set(suggs) if len(suggs) < tempLimit: + existing = set(suggs) params = ['%' + searchStr + '%'] + ([ctg] if ctg is not None else []) for (title,) in dbCur.execute(query, params): if title not in existing: suggs.append(title) if len(suggs) == tempLimit: break + # return SuggResponse(suggs[:resultLimit], len(suggs) > resultLimit) diff --git a/backend/tests/test_histplorer.py b/backend/tests/test_histplorer.py index b3f7cb4..fcaafb5 100644 --- a/backend/tests/test_histplorer.py +++ b/backend/tests/test_histplorer.py @@ -124,12 +124,18 @@ class TestHandleReq(unittest.TestCase): ]) self.assertEqual(response.unitCounts, {-2000: 1, 1900: 2, 1990: 1}) def test_info_req(self): - response = handleReq(self.dbFile, {'QUERY_STRING': 'type=info&event=3'}) + response = handleReq(self.dbFile, {'QUERY_STRING': 'type=info&event=event%20three'}) self.assertEqual(response, - EventInfo('desc three', 300, ImgInfo('example.com/3', 'cc-by-sa 3.0', 'artist three', 'credits three'))) - response = handleReq(self.dbFile, {'QUERY_STRING': 'type=info&event=4'}) + EventInfo( + Event(3, 'event three', HistDate(True, 1990, 10, 10), HistDate(True, 2000, 10, 10), None, None, + 'discovery', 30, 0), + 'desc three', 300, ImgInfo('example.com/3', 'cc-by-sa 3.0', 'artist three', 'credits three'))) + response = handleReq(self.dbFile, {'QUERY_STRING': 'type=info&event=event%20four'}) self.assertEqual(response, - EventInfo('desc four', 400, ImgInfo('example.com/2', 'cc-by', 'artist two', 'credits two'))) + EventInfo( + Event(4, 'event four', HistDate(False, -2000, 10, 10), None, HistDate(False, 1, 10, 10), None, + 'event', 20, 1000), + 'desc four', 400, ImgInfo('example.com/2', 'cc-by', 'artist two', 'credits two'))) def test_sugg_req(self): response = handleReq(self.dbFile, {'QUERY_STRING': 'type=sugg&input=event t'}) self.assertEqual(response, SuggResponse(['event two', 'event three'], False)) 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 @@ <h1 class="my-auto ml-2 text-4xl" :style="{color: store.color.altDark}">Histplorer</h1> <div class="mx-auto"/> <!-- Spacer --> <!-- Icons --> - <icon-button :size="45" :style="buttonStyles" @click="onTimelineAdd" title="Add a timeline"> - <plus-icon/> + <icon-button :size="45" :style="buttonStyles"> + <help-icon/> </icon-button> <icon-button :size="45" :style="buttonStyles"> <settings-icon/> </icon-button> - <icon-button :size="45" :style="buttonStyles"> - <help-icon/> + <icon-button :size="45" :style="buttonStyles" @click="onTimelineAdd" title="Add a timeline"> + <plus-icon/> + </icon-button> + <icon-button :size="45" :style="buttonStyles" @click="searchOpen = true" title="Search"> + <search-icon/> </icon-button> </div> <!-- Content area --> @@ -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"/> <base-line :vert="vert" :timelines="timelines" class='m-1 sm:m-2'/> </div> <!-- Modals --> <transition name="fade"> - <info-modal v-if="infoModalEvent != null && infoModalData != null" - :event="infoModalEvent" :eventInfo="infoModalData" @close="infoModalEvent = null"/> + <search-modal v-if="searchOpen" :eventTree="eventTree" :titleToEvent="titleToEvent" + @close="searchOpen = false" @search="onSearch" @info-click="onInfoClick"/> + </transition> + <transition name="fade"> + <info-modal v-if="infoModalData != null" :eventInfo="infoModalData" @close="infoModalData = null"/> </transition> </div> </template> @@ -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<RBTree<HistEvent>> = shallowRef(new RBTree(cmpHistEvent)); let idToEvent: Map<number, HistEvent> = new Map(); +let titleToEvent: Map<string, HistEvent> = new Map(); const unitCountMaps: Ref<Map<number, number>[]> = 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<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">• • •</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> 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 { @@ -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, }; }, }); |
