
- Now finds holidays in addition to dates and events - Emojis added to mark different result types - Matching improvements: insensitive to diacritics, finds closest holiday/date to view, concise regex matching - Avoid jumping to dates immediately while browsing the result dropdown - Improved hotkey handling Ctrl+F (always focus and select)
575 lines
17 KiB
Vue
575 lines
17 KiB
Vue
<template>
|
|
<div class="search-bar" @keydown="onContainerKey">
|
|
<input
|
|
ref="searchInputRef"
|
|
v-model="searchQuery"
|
|
type="search"
|
|
placeholder="Date or Event..."
|
|
aria-label="Search dates, holidays and events"
|
|
@keydown="handleSearchKeydown"
|
|
/>
|
|
<ul
|
|
v-if="searchQuery.trim() && searchResults.length"
|
|
class="search-dropdown"
|
|
role="listbox"
|
|
:aria-activedescendant="activeResultId"
|
|
>
|
|
<li
|
|
v-for="(r, i) in searchResults"
|
|
:key="r.id"
|
|
:id="'sr-' + r.id"
|
|
:class="{ active: i === searchIndex }"
|
|
role="option"
|
|
@mousedown.prevent="selectResult(i)"
|
|
>
|
|
<span class="title">{{ r.title }}</span
|
|
><span class="date">{{ r.startDate }}</span>
|
|
</li>
|
|
</ul>
|
|
<div v-else-if="searchQuery.trim() && !searchResults.length" class="search-empty">
|
|
No matches
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, watch, nextTick, computed, defineExpose, onUnmounted, onMounted } from 'vue'
|
|
import { useCalendarStore } from '@/stores/CalendarStore'
|
|
import {
|
|
fromLocalString,
|
|
DEFAULT_TZ,
|
|
monthAbbr,
|
|
getLocalizedMonthName,
|
|
toLocalString,
|
|
getMondayOfISOWeek,
|
|
formatTodayString,
|
|
makeTZDate,
|
|
getISOWeek,
|
|
} from '@/utils/date'
|
|
import { addDays } from 'date-fns'
|
|
import { getDate as getNearestOccurrence } from '@/utils/events'
|
|
import { getHolidaysForYear } from '@/utils/holidays'
|
|
|
|
const emit = defineEmits(['activate', 'preview'])
|
|
const props = defineProps({ referenceDate: { type: String, default: null } })
|
|
const calendarStore = useCalendarStore()
|
|
|
|
const searchQuery = ref('')
|
|
const searchResults = ref([])
|
|
const searchIndex = ref(0)
|
|
const searchInputRef = ref(null)
|
|
let previewTimer = null
|
|
|
|
// Accent-insensitive lowercasing
|
|
const norm = (s) =>
|
|
s
|
|
.normalize('NFD')
|
|
.replace(/\p{Diacritic}/gu, '')
|
|
.toLowerCase()
|
|
let lastQuery = ''
|
|
let frozenRefStr = null // reference date frozen at last query change
|
|
const YEAR_SCAN_OFFSETS = [-4, -3, -2, -1, 0, 1, 2, 3, 4]
|
|
function buildSearchResults(queryChanged = false) {
|
|
const raw = searchQuery.value.trim()
|
|
if (!raw) {
|
|
searchResults.value = []
|
|
searchIndex.value = 0
|
|
lastQuery = raw
|
|
return
|
|
}
|
|
const listAll = raw === '*'
|
|
const search = norm(raw)
|
|
const out = []
|
|
let refStrLive = props.referenceDate || calendarStore.today || calendarStore.now
|
|
if (refStrLive.includes('T')) refStrLive = refStrLive.slice(0, 10)
|
|
if (queryChanged || !frozenRefStr) frozenRefStr = refStrLive
|
|
const refStr = frozenRefStr
|
|
const nowDate = fromLocalString(refStr, DEFAULT_TZ)
|
|
for (const ev of calendarStore.events.values()) {
|
|
const title = '⚜️ ' + (ev.title || '').trim()
|
|
if (!(listAll || norm(title).includes(search))) continue
|
|
let displayStart = ev.startDate
|
|
if (ev.recur) {
|
|
const nearest = getNearestOccurrence(ev, refStr, DEFAULT_TZ)
|
|
if (nearest?.dateStr) displayStart = nearest.dateStr
|
|
}
|
|
out.push({ id: ev.id, title, startDate: displayStart })
|
|
}
|
|
if (calendarStore.config?.holidays?.enabled) {
|
|
try {
|
|
calendarStore._ensureHolidaysInitialized?.()
|
|
const refYear = nowDate.getFullYear()
|
|
const yearWindow = YEAR_SCAN_OFFSETS.map((o) => refYear + o)
|
|
const bestByName = Object.create(null)
|
|
for (const yr of yearWindow) {
|
|
for (const h of getHolidaysForYear(yr) || []) {
|
|
const name = (h.name || '').trim().split(/\s*\/\s*/)[0]
|
|
if (!name) continue
|
|
if (!listAll && !norm(name).includes(search)) continue
|
|
let dateObj
|
|
try {
|
|
dateObj = new Date(h.date)
|
|
} catch {
|
|
dateObj = null
|
|
}
|
|
if (!dateObj || isNaN(dateObj)) continue
|
|
const diff = Math.abs(dateObj - nowDate)
|
|
const key = name.toLowerCase()
|
|
const prev = bestByName[key]
|
|
if (!prev || diff < prev.diff) bestByName[key] = { name, dateObj, diff }
|
|
}
|
|
}
|
|
for (const key in bestByName) {
|
|
const { name, dateObj } = bestByName[key]
|
|
const dateStr = toLocalString(dateObj, DEFAULT_TZ)
|
|
out.push({
|
|
id: '__holiday__' + dateStr + ':' + key,
|
|
title: `✨ ${name}`,
|
|
startDate: dateStr,
|
|
_holiday: true,
|
|
_dupeKey: '__holiday__' + dateStr + ':' + key,
|
|
})
|
|
}
|
|
} catch (e) {
|
|
if (process.env.NODE_ENV !== 'production') console.debug('[Search] holiday search skipped', e)
|
|
}
|
|
}
|
|
if (queryChanged) {
|
|
out.sort((a, b) => (a.startDate < b.startDate ? -1 : a.startDate > b.startDate ? 1 : 0))
|
|
} else if (searchResults.value.length) {
|
|
const order = new Map(searchResults.value.map((r, i) => [r.id, i]))
|
|
out.sort((a, b) => {
|
|
const ai = order.has(a.id) ? order.get(a.id) : 1e9
|
|
const bi = order.has(b.id) ? order.get(b.id) : 1e9
|
|
if (ai !== bi) return ai - bi
|
|
return a.startDate < b.startDate ? -1 : a.startDate > b.startDate ? 1 : 0
|
|
})
|
|
}
|
|
const gotoDateStr = parseGoToDateCandidate(raw, refStr)
|
|
if (gotoDateStr) {
|
|
const dateObj = fromLocalString(gotoDateStr, DEFAULT_TZ)
|
|
out.unshift({
|
|
id: '__goto__' + gotoDateStr,
|
|
title: '📅 ' + formatTodayString(dateObj),
|
|
startDate: gotoDateStr,
|
|
_goto: true,
|
|
})
|
|
}
|
|
searchResults.value = out
|
|
if (searchIndex.value >= out.length) searchIndex.value = 0
|
|
lastQuery = raw
|
|
}
|
|
|
|
watch(searchQuery, (nv, ov) => {
|
|
buildSearchResults(nv.trim() !== lastQuery)
|
|
})
|
|
watch(
|
|
() => calendarStore.events,
|
|
() => {
|
|
if (searchQuery.value.trim()) buildSearchResults(false)
|
|
},
|
|
{ deep: true },
|
|
)
|
|
watch(
|
|
() => props.referenceDate,
|
|
() => {
|
|
if (searchQuery.value.trim()) buildSearchResults(false)
|
|
},
|
|
)
|
|
|
|
function focusSearch(selectAll = true) {
|
|
nextTick(() => {
|
|
if (searchInputRef.value) {
|
|
searchInputRef.value.focus()
|
|
if (selectAll) searchInputRef.value.select()
|
|
}
|
|
})
|
|
}
|
|
|
|
function navigate(delta) {
|
|
const n = searchResults.value.length
|
|
if (!n) return
|
|
searchIndex.value = (searchIndex.value + delta + n) % n
|
|
// Ensure active item is visible
|
|
const r = searchResults.value[searchIndex.value]
|
|
if (r) {
|
|
const el = document.getElementById('sr-' + r.id)
|
|
if (el) el.scrollIntoView({ block: 'nearest' })
|
|
}
|
|
if (previewTimer) clearTimeout(previewTimer)
|
|
if (r)
|
|
previewTimer = setTimeout(() => {
|
|
if (r === searchResults.value[searchIndex.value]) emit('preview', r)
|
|
}, 200)
|
|
}
|
|
function selectResult(idx) {
|
|
searchIndex.value = idx
|
|
const r = searchResults.value[searchIndex.value]
|
|
if (r) {
|
|
if (previewTimer) {
|
|
clearTimeout(previewTimer)
|
|
previewTimer = null
|
|
}
|
|
emit('activate', r)
|
|
// Clear query after activation (auto-close handled by parent visibility)
|
|
searchQuery.value = ''
|
|
}
|
|
}
|
|
function handleSearchKeydown(e) {
|
|
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'f') {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
focusSearch(true)
|
|
return
|
|
}
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
navigate(1)
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
navigate(-1)
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
selectResult(searchIndex.value)
|
|
} else if (e.key === 'Escape') {
|
|
if (searchQuery.value) {
|
|
searchQuery.value = ''
|
|
e.preventDefault()
|
|
}
|
|
}
|
|
}
|
|
function onContainerKey(e) {
|
|
/* capture for list navigation if needed */
|
|
}
|
|
|
|
const activeResultId = computed(() => {
|
|
const r = searchResults.value[searchIndex.value]
|
|
return r ? 'sr-' + r.id : null
|
|
})
|
|
|
|
defineExpose({ focusSearch })
|
|
onUnmounted(() => {
|
|
if (previewTimer) clearTimeout(previewTimer)
|
|
})
|
|
// global Ctrl/Cmd+F -> search
|
|
let globalFindHandler = null
|
|
onMounted(() => {
|
|
globalFindHandler = (e) => {
|
|
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'f') {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
focusSearch(true)
|
|
}
|
|
}
|
|
window.addEventListener('keydown', globalFindHandler, { capture: true })
|
|
})
|
|
onUnmounted(() => {
|
|
if (globalFindHandler) {
|
|
window.removeEventListener('keydown', globalFindHandler, { capture: true })
|
|
globalFindHandler = null
|
|
}
|
|
})
|
|
|
|
function parseGoToDateCandidate(input, refStr) {
|
|
const s = input.trim()
|
|
if (!s) return null
|
|
const base = refStr ? fromLocalString(refStr, DEFAULT_TZ) : new Date(),
|
|
baseYear = base.getFullYear()
|
|
// now/today -> system date
|
|
if (/^(now|today)$/i.test(s)) {
|
|
const sys = new Date()
|
|
return toLocalString(
|
|
makeTZDate(sys.getFullYear(), sys.getMonth(), sys.getDate(), DEFAULT_TZ),
|
|
DEFAULT_TZ,
|
|
)
|
|
}
|
|
const localized = Array.from({ length: 12 }, (_, i) => getLocalizedMonthName(i))
|
|
const monthFromToken = (tok) => {
|
|
if (!tok) return null
|
|
const tNorm = norm(tok.trim())
|
|
if (/^\d{1,2}$/.test(tok)) {
|
|
const n = +tok
|
|
return n >= 1 && n <= 12 ? n : null
|
|
}
|
|
for (let i = 0; i < 12; i++) {
|
|
if (norm(monthAbbr[i]) === tNorm || norm(monthAbbr[i].slice(0, 3)) === tNorm) return i + 1
|
|
}
|
|
for (let i = 0; i < 12; i++) {
|
|
const full = norm(localized[i])
|
|
if (full === tNorm || full.startsWith(tNorm)) return i + 1
|
|
}
|
|
return null
|
|
}
|
|
// month token -> 15th of nearest year
|
|
const soleMonth = s.match(/^(\p{L}+)[.,;:]?$/u)
|
|
if (soleMonth) {
|
|
const rawMonthTok = soleMonth[1]
|
|
const m = monthFromToken(rawMonthTok)
|
|
if (m) {
|
|
let bestYear = baseYear
|
|
let best = Infinity
|
|
for (const cand of YEAR_SCAN_OFFSETS.map((o) => baseYear + o)) {
|
|
const mid = new Date(cand, m - 1, 15)
|
|
const diff = Math.abs(mid - base)
|
|
if (diff < best) {
|
|
best = diff
|
|
bestYear = cand
|
|
}
|
|
}
|
|
return toLocalString(makeTZDate(bestYear, m - 1, 15, DEFAULT_TZ), DEFAULT_TZ)
|
|
}
|
|
}
|
|
const isoFull = s.match(/^(\d{4})-(\d{2})-(\d{2})$/)
|
|
if (isoFull) {
|
|
const y = +isoFull[1],
|
|
mm = +isoFull[2],
|
|
d = +isoFull[3]
|
|
return toLocalString(makeTZDate(y, mm - 1, d, DEFAULT_TZ), DEFAULT_TZ)
|
|
}
|
|
// wNN -> Monday of nearest ISO week
|
|
const weekOnly = s.match(/^w(\d{1,2})[.,;:]?$/i)
|
|
if (weekOnly) {
|
|
const wk = +weekOnly[1]
|
|
if (wk >= 1 && wk <= 53) {
|
|
const has53Weeks = (year) => getISOWeek(makeTZDate(year, 11, 28, DEFAULT_TZ)) === 53
|
|
let bestYear = baseYear,
|
|
bestDiff = Infinity,
|
|
bestDate = null
|
|
for (const off of YEAR_SCAN_OFFSETS) {
|
|
const cand = baseYear + off
|
|
if (wk === 53 && !has53Weeks(cand)) continue
|
|
const jan4 = makeTZDate(cand, 0, 4, DEFAULT_TZ)
|
|
const target = addDays(jan4, (wk - 1) * 7)
|
|
const monday = getMondayOfISOWeek(target)
|
|
const diff = Math.abs(monday - base)
|
|
if (diff < bestDiff) {
|
|
bestDiff = diff
|
|
bestYear = cand
|
|
bestDate = monday
|
|
}
|
|
}
|
|
if (bestDate) return toLocalString(bestDate, DEFAULT_TZ)
|
|
}
|
|
}
|
|
const isoMonth = s.match(/^(\d{4})[-/](\d{2})$/)
|
|
if (isoMonth) {
|
|
const y = +isoMonth[1],
|
|
mm = +isoMonth[2]
|
|
return toLocalString(makeTZDate(y, mm - 1, 15, DEFAULT_TZ), DEFAULT_TZ)
|
|
}
|
|
// year+week variants
|
|
let isoWeek = s.match(/^(\d{4})[-/]?w(\d{1,2})$/i)
|
|
if (!isoWeek) isoWeek = s.match(/^w(\d{1,2})[-/]?(\d{4})$/i)
|
|
if (!isoWeek) isoWeek = s.match(/^(\d{4})\s+w(\d{1,2})$/i)
|
|
if (!isoWeek) isoWeek = s.match(/^w(\d{1,2})\s+(\d{4})$/i)
|
|
if (isoWeek) {
|
|
const wy = +isoWeek[1]
|
|
const w = +isoWeek[2]
|
|
if (w >= 1 && w <= 53) {
|
|
if (w === 53 && getISOWeek(makeTZDate(wy, 11, 28, DEFAULT_TZ)) !== 53) return null
|
|
const jan4 = makeTZDate(wy, 0, 4, DEFAULT_TZ)
|
|
const target = addDays(jan4, (w - 1) * 7)
|
|
return toLocalString(getMondayOfISOWeek(target), DEFAULT_TZ)
|
|
}
|
|
return null
|
|
}
|
|
let d = null,
|
|
m = null,
|
|
y = null,
|
|
yearExplicit = false
|
|
const dot = s.match(/^(\d{1,2})\.([\p{L}]+|\d{1,2})(?:\.(\d{4}))?\.?$/u)
|
|
if (dot) {
|
|
d = +dot[1]
|
|
m = monthFromToken(dot[2])
|
|
if (dot[3]) {
|
|
y = +dot[3]
|
|
yearExplicit = true
|
|
}
|
|
}
|
|
if (m == null) {
|
|
const usFull = s.match(/^([\p{L}]+|\d{1,2})\/(\d{1,2})\/(\d{4})$/u)
|
|
if (usFull) {
|
|
m = monthFromToken(usFull[1])
|
|
d = +usFull[2]
|
|
y = +usFull[3]
|
|
yearExplicit = true
|
|
} else {
|
|
const usShort = s.match(/^([\p{L}]+|\d{1,2})\/(\d{1,2})$/u)
|
|
if (usShort) {
|
|
m = monthFromToken(usShort[1])
|
|
d = +usShort[2]
|
|
}
|
|
}
|
|
}
|
|
if (m == null) {
|
|
const tokens = s.split(/[ ,]+/).filter(Boolean)
|
|
if (tokens.length >= 2 && tokens.length <= 3) {
|
|
let monthIdx = tokens.findIndex((t) => /\p{L}/u.test(t) && monthFromToken(t) != null)
|
|
if (monthIdx === -1) monthIdx = tokens.findIndex((t) => monthFromToken(t) != null)
|
|
if (monthIdx !== -1) {
|
|
const monthTok = tokens[monthIdx]
|
|
const monthTokIsNum = /^\d{1,2}$/.test(monthTok)
|
|
const hasNonMonthLetter = tokens.some(
|
|
(t, i) => i !== monthIdx && /\p{L}/u.test(t) && monthFromToken(t) == null,
|
|
)
|
|
const otherNumeric = tokens.some((t, i) => i !== monthIdx && /^\d{1,2}$/.test(t))
|
|
if (monthTokIsNum && hasNonMonthLetter && !otherNumeric) {
|
|
monthIdx = -1
|
|
}
|
|
}
|
|
if (monthIdx !== -1) {
|
|
m = monthFromToken(tokens[monthIdx])
|
|
const others = tokens.filter((_, i) => i !== monthIdx)
|
|
let dayExplicit = false
|
|
for (const rawTok of others) {
|
|
const tok = rawTok.replace(/^[.,;:]+|[.,;:]+$/g, '')
|
|
if (!tok) continue
|
|
if (/^\d+$/.test(tok)) {
|
|
const num = +tok
|
|
if (num > 100) {
|
|
y = num
|
|
yearExplicit = true
|
|
} else if (!d) {
|
|
d = num
|
|
dayExplicit = true
|
|
}
|
|
} else if (!y && /^\d{4}[.,;:]?$/.test(tok)) {
|
|
const num = parseInt(tok, 10)
|
|
if (num > 1000) {
|
|
y = num
|
|
yearExplicit = true
|
|
}
|
|
}
|
|
}
|
|
if (!d && !dayExplicit) d = 15
|
|
}
|
|
}
|
|
}
|
|
if (m != null && d != null && !yearExplicit) {
|
|
let bestYear = baseYear,
|
|
bestDiff = Infinity
|
|
for (const cand of YEAR_SCAN_OFFSETS.map((o) => baseYear + o)) {
|
|
const dt = new Date(cand, m - 1, d)
|
|
if (dt.getMonth() !== m - 1) continue
|
|
const diff = Math.abs(dt - base)
|
|
if (diff < bestDiff) {
|
|
bestDiff = diff
|
|
bestYear = cand
|
|
}
|
|
}
|
|
y = bestYear
|
|
}
|
|
if (y != null && m != null && d != null) {
|
|
if (y < 1000 || y > 9999 || m < 1 || m > 12 || d < 1 || d > 31) return null
|
|
return toLocalString(makeTZDate(y, m - 1, d, DEFAULT_TZ), DEFAULT_TZ)
|
|
}
|
|
return null
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.search-bar {
|
|
flex: 0 1 20rem;
|
|
margin-inline: auto; /* center with equal free-space on both sides */
|
|
position: relative;
|
|
}
|
|
.search-bar input {
|
|
width: 100%;
|
|
padding: 0.32rem 0.5rem;
|
|
padding-inline-start: 2.05rem; /* increased space for icon */
|
|
border-radius: 0.45rem;
|
|
border: 1px solid color-mix(in srgb, var(--muted) 35%, transparent);
|
|
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
|
font: inherit;
|
|
line-height: 1.1;
|
|
color: var(--ink);
|
|
outline: none;
|
|
transition:
|
|
border-color 0.15s ease,
|
|
box-shadow 0.15s ease,
|
|
background 0.2s;
|
|
}
|
|
.search-bar::before {
|
|
content: '🔍';
|
|
position: absolute;
|
|
inset-inline-start: 0.55rem;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
font-size: 0.85rem;
|
|
pointer-events: none;
|
|
opacity: 0.75;
|
|
line-height: 1;
|
|
filter: saturate(0.8);
|
|
}
|
|
.search-bar input:focus-visible {
|
|
border-color: color-mix(in srgb, var(--accent, #4b7) 70%, transparent);
|
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #4b7) 45%, transparent);
|
|
background: color-mix(in srgb, var(--panel) 95%, transparent);
|
|
}
|
|
.search-bar input::-webkit-search-cancel-button {
|
|
cursor: pointer;
|
|
}
|
|
.search-dropdown {
|
|
position: absolute;
|
|
top: calc(100% + 0.25rem);
|
|
left: 0;
|
|
right: 0;
|
|
z-index: 1400;
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0.2rem;
|
|
background: color-mix(in srgb, var(--panel) 92%, transparent);
|
|
backdrop-filter: blur(0.6em);
|
|
-webkit-backdrop-filter: blur(0.6em);
|
|
border: 1px solid color-mix(in srgb, var(--muted) 35%, transparent);
|
|
border-radius: 0.55rem;
|
|
max-height: 16rem;
|
|
overflow: auto;
|
|
box-shadow: 0 0.5em 1.25em rgba(0, 0, 0, 0.3);
|
|
font-size: 0.8rem;
|
|
}
|
|
.search-dropdown li {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 0.5rem;
|
|
padding: 0.35rem 0.5rem;
|
|
cursor: pointer;
|
|
border-radius: 0.4rem;
|
|
}
|
|
.search-dropdown li.active {
|
|
background: color-mix(in srgb, var(--accent, #4b7) 45%, transparent);
|
|
color: var(--ink, #111);
|
|
font-weight: 600;
|
|
}
|
|
.search-dropdown li:hover:not(.active) {
|
|
background: color-mix(in srgb, var(--panel) 70%, transparent);
|
|
}
|
|
.search-dropdown .title {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.search-dropdown .date {
|
|
opacity: 0.6;
|
|
font-family: monospace;
|
|
}
|
|
.search-empty {
|
|
position: absolute;
|
|
top: calc(100% + 0.25rem);
|
|
left: 0;
|
|
right: 0;
|
|
padding: 0.45rem 0.6rem;
|
|
background: color-mix(in srgb, var(--panel) 92%, transparent);
|
|
backdrop-filter: blur(0.6em);
|
|
-webkit-backdrop-filter: blur(0.6em);
|
|
border: 1px solid color-mix(in srgb, var(--muted) 35%, transparent);
|
|
border-radius: 0.55rem;
|
|
box-shadow: 0 0.5em 1.25em rgba(0, 0, 0, 0.3);
|
|
font-size: 0.7rem;
|
|
opacity: 0.65;
|
|
pointer-events: none;
|
|
text-align: center;
|
|
}
|
|
</style>
|