mirror of
https://github.com/osmarks/website
synced 2025-01-28 18:04:45 +00:00
667 lines
27 KiB
JavaScript
667 lines
27 KiB
JavaScript
const idb = require("idb")
|
|
const { solve } = require("yalps")
|
|
const { ignorePaths } = require("./common")
|
|
|
|
const prefetchHook = () => {
|
|
let lastFetch = 0
|
|
const prefetch = event => {
|
|
if (Date.now() - lastFetch >= 200) {
|
|
let target = event.target
|
|
while (!target.hasAttribute("href")) {
|
|
target = target.parentElement
|
|
}
|
|
const href = new URL(target.getAttribute("href"), window.location)
|
|
if (href.origin === window.location.origin && (href.pathname !== window.location.pathname || href.search !== window.location.search)) {
|
|
for (const ignorePath of ignorePaths) {
|
|
if (href.pathname.startsWith(ignorePath)) return
|
|
}
|
|
console.log("prefetch", href.href)
|
|
fetch(href)
|
|
lastFetch = Date.now()
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const node of document.querySelectorAll("a[href]")) {
|
|
node.addEventListener("touchstart", prefetch)
|
|
node.addEventListener("mouseover", prefetch)
|
|
}
|
|
}
|
|
|
|
// attempt to register service worker
|
|
if ("serviceWorker" in navigator) {
|
|
navigator.serviceWorker.register("/sw.js", { scope: "/" }).then(reg => {
|
|
if (reg.installing) {
|
|
console.log("Service worker installing")
|
|
} else if (reg.waiting) {
|
|
console.log("Service worker installed")
|
|
} else if (reg.active) {
|
|
console.log("Service worker active")
|
|
prefetchHook()
|
|
}
|
|
}).catch(error => {
|
|
// registration failed
|
|
console.log("Registration failed with " + error)
|
|
});
|
|
} else {
|
|
console.log("Service workers are not supported.")
|
|
}
|
|
|
|
// https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript
|
|
const hashString = function(str, seed = 0) {
|
|
let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed
|
|
for (let i = 0, ch; i < str.length; i++) {
|
|
ch = str.charCodeAt(i)
|
|
h1 = Math.imul(h1 ^ ch, 2654435761)
|
|
h2 = Math.imul(h2 ^ ch, 1597334677)
|
|
}
|
|
h1 = Math.imul(h1 ^ h1>>>16, 2246822507) ^ Math.imul(h2 ^ h2>>>13, 3266489909);
|
|
h2 = Math.imul(h2 ^ h2>>>16, 2246822507) ^ Math.imul(h1 ^ h1>>>13, 3266489909)
|
|
return 4294967296 * (2097151 & h2) + (h1>>>0)
|
|
}
|
|
|
|
const colHash = (str, saturation = 100, lightness = 70) => `hsl(${hashString(str) % 360}, ${saturation}%, ${lightness}%)`
|
|
window.colHash = colHash
|
|
|
|
const e = (cls, parent, content, type="div") => {
|
|
const element = document.createElement(type)
|
|
element.classList.add(cls)
|
|
if (content) { element.appendChild(document.createTextNode(content)) }
|
|
if (parent) { parent.appendChild(element) }
|
|
return element
|
|
}
|
|
|
|
// Arbitrary Points code, wrapped in an IIFE to not pollute the global environment much more than it already is
|
|
window.points = (async () => {
|
|
const achievementInfo = {
|
|
test: {
|
|
title: "Test",
|
|
conditions: "testing",
|
|
description: "This achievement is for testing purposes.",
|
|
points: 10
|
|
},
|
|
firstAchievement: {
|
|
title: "Achievement-Achieving Achievement™",
|
|
conditions: "unlocking another achievement",
|
|
description: "You achieved your first achievement, so here's an achievement to commemorate your achievement of an achievement! Enjoy the sense of achievement you get from this achievement!",
|
|
points: 5.5
|
|
},
|
|
timeSpent1Hour: {
|
|
title: "Causal Mondays",
|
|
conditions: "using the site for a total of 1 hour",
|
|
description: "Apparently you've spent an hour on this site. Weird. You get an achievement for it, though.",
|
|
points: 9.3
|
|
},
|
|
visitArbitraryPoints: {
|
|
title: "Arbitrary Arbitration",
|
|
conditions: "visiting the Arbitrary Points management page",
|
|
description: "You've now visited the Arbitrary Points page, from which you can see your achievements, point count and tracked metrics.",
|
|
points: 15
|
|
},
|
|
reset: {
|
|
title: "Burn It Down",
|
|
conditions: "resetting",
|
|
description: "So you wiped your Arbitrary Points data for whatever reason. Now you get this exclusive achievement!",
|
|
points: 11.4
|
|
},
|
|
pagesVisited64: {
|
|
title: "Real Dedication",
|
|
conditions: "visiting 64 pages",
|
|
points: 15.01,
|
|
description: "You've visited something between 64 pages or 1 page 64 times and are thus being rewarded for your frequent use of the site."
|
|
},
|
|
blindLuck: {
|
|
title: "Ridiculous Blind Luck",
|
|
conditions: "0.001% chance of getting this every second",
|
|
points: 66.6,
|
|
description: "Through sheer chance you have obtained this achievement, which provides more points than all the other ones. This is probably a metaphor for life."
|
|
},
|
|
offline: {
|
|
title: "Not The Dinosaur Game",
|
|
conditions: "seeing the offline page",
|
|
points: 10.1,
|
|
description: "Something broke somewhere and you're seeing this. Sadly this no longer has the Chrome dinosaur game, but you can use other stuff."
|
|
},
|
|
attemptedXSS: {
|
|
title: "1337 h4xx0r",
|
|
conditions: "attempting an XSS attack",
|
|
points: 43.01,
|
|
description: "You appear to have attempted a cross-site-scripting attack. This probably hasn't worked. If it has, please tell me as this is a problem."
|
|
},
|
|
emuwar10: {
|
|
title: "Emu Warrior",
|
|
conditions: "vanquishing 10 or more foes in Emu War",
|
|
points: 28.5,
|
|
description: "You have become a mighty Emu Warrior by defeating 10 or more monsters and/or probably things which live in Australia."
|
|
},
|
|
lorem400: {
|
|
title: "quare?",
|
|
conditions: "seeing 400 paragraphs of Lorem Ipsum",
|
|
points: 42.3,
|
|
description: "Apparently you viewed 400 paragraphs of randomly generated Lorem Ipsum. I don't know why."
|
|
},
|
|
firstComment: {
|
|
title: "That's just, like, your opinion, man",
|
|
conditions: "posting a comment",
|
|
points: 30.5,
|
|
description: "You (probably, the detection isn't 100% accurate) posted a comment! Enjoy expressing your opinion (or random meaningless message) to random internet people!"
|
|
},
|
|
aprilFools: {
|
|
title: "April Folly",
|
|
conditions: "visiting on April Fools' Day",
|
|
description: "Enjoy being... April Fooled? Good luck getting this, speedrunners.",
|
|
points: 16.0303
|
|
},
|
|
heavgame1: {
|
|
title: "Pastaphysical Futility",
|
|
conditions: "Annoying the narrator of Heavpoot's Game",
|
|
description: "Irritating the narrator with control of your reality is rarely a good choice. In this case it definitely wasn't.",
|
|
points: 34.04
|
|
},
|
|
heavgame2: {
|
|
title: "(insert meaningful secret O5 councily thing here)",
|
|
conditions: "Solving the puzzle in Heavpoot's Game",
|
|
description: `You... did something or other and somehow found the "black moon" thing and guessed/found the answer to the puzzle there, so enjoy this achievement, O6-3234234!`,
|
|
points: 41.5824
|
|
},
|
|
tttWinai1:{
|
|
title: "Beginner's Luck",
|
|
conditions: `Beat "AI" 1 on Tic-Tac-Toe`,
|
|
description: "Congratulations on beating a slightly aware random number generator.",
|
|
points: 23
|
|
},
|
|
tttWinai2:{
|
|
title: "Superior Algorithms",
|
|
conditions: `Beat "AI" 2 on Tic-Tac-Toe`,
|
|
description: "Nonsarcastic congratulations on beating the near-optimal minimax agent.",
|
|
points: 46
|
|
},
|
|
apioformGame: {
|
|
title: "Relativistic apiohydromagnetoplasmodynamic cryomemetics",
|
|
conditions: "Finish the Apioform Game tutorial",
|
|
description: "You have braved the complete lack of guidance or a description in the Apioform Game and apioformed many apioforms.",
|
|
points: 40
|
|
},
|
|
rpnv4recursion: {
|
|
title: "You are doing it right",
|
|
conditions: "Recurse to a stack depth of 100 or more on RPNCalc v4",
|
|
description: "For using RPNCalcV4 as it is meant to be used - highly, highly recursively.",
|
|
points: 18.324
|
|
},
|
|
ttt4Win: {
|
|
title: "Shape Rotator",
|
|
conditions: "Win 4D Tic-Tac-Toe",
|
|
description: "You won a game of 4D Tic-Tac-Toe against our highly advanced artificial intelligence, showing your utter comprehension of 4D space.",
|
|
points: 37.9
|
|
}
|
|
}
|
|
|
|
let [metrics, pointsData] = [{}, {}]
|
|
try {
|
|
const [oldMetrics, oldPoints] = await Promise.all([idb.openDB("arbitrary-metrics"), idb.openDB("arbitrary-points")])
|
|
const getMetrics = async () => {
|
|
const metrics = {}
|
|
const tx = oldMetrics.transaction("metrics", "readonly")
|
|
for (const key of await tx.store.getAllKeys()) {
|
|
metrics[key] = await tx.store.get(key)
|
|
}
|
|
return metrics
|
|
}
|
|
const getPointsData = async () => {
|
|
const data = {}
|
|
const tx = oldPoints.transaction("data", "readonly")
|
|
for (const key of await tx.store.getAllKeys()) {
|
|
data[key] = await tx.store.get(key)
|
|
}
|
|
return data
|
|
}
|
|
[metrics, pointsData] = await Promise.all([getMetrics(), getPointsData()])
|
|
await Promise.all([oldMetrics.close(), oldPoints.close()])
|
|
} catch(e) {
|
|
console.warn("old achievements not loaded due to", e)
|
|
}
|
|
const db = await idb.openDB("arbitrary-data", 1, {
|
|
async upgrade(db, oldVersion, newVersion, tx) {
|
|
console.log("migrating", oldVersion, newVersion)
|
|
if (!oldVersion || oldVersion < 1) {
|
|
// create metrics, KV, achievements stores
|
|
db.createObjectStore("kv")
|
|
db.createObjectStore("metrics")
|
|
db.createObjectStore("achievements", {
|
|
keyPath: "id"
|
|
})
|
|
for (const [key, value] of Object.entries(metrics)) {
|
|
await tx.objectStore("metrics").put(value, key)
|
|
}
|
|
for (const achievement of (pointsData["achievements"] || [])) {
|
|
await tx.objectStore("achievements").put(achievement)
|
|
}
|
|
}
|
|
await tx.done
|
|
},
|
|
blocked() {
|
|
console.warn("Database error (older version open)")
|
|
},
|
|
blocking() {
|
|
window.location.reload()
|
|
},
|
|
terminated() {
|
|
console.warn("Database error (unexpectedly closed)")
|
|
},
|
|
});
|
|
|
|
const achievementsContainer = e("achievements", document.body)
|
|
const displayAchievement = (title, description, conditions, points) => {
|
|
const elem = e("achievement", achievementsContainer)
|
|
elem.title = "click to dismiss"
|
|
e("title", elem, "Achievement achieved!")
|
|
e("title", elem, title)
|
|
elem.style.backgroundColor = colHash(title)
|
|
e("description", elem, description)
|
|
e("conditions", elem, `Unlocked by: ${conditions}`)
|
|
e("points", elem, `${points} points`)
|
|
// disappear on click
|
|
elem.addEventListener("click", () => {
|
|
achievementsContainer.removeChild(elem)
|
|
})
|
|
}
|
|
|
|
const fireUpdatedEvent = () => document.dispatchEvent(new Event("points-update"))
|
|
|
|
let achievementsList
|
|
|
|
const getAchievements = async tx => {
|
|
if (achievementsList) { return achievementsList }
|
|
if (!tx) { tx = db.transaction("achievements", "readonly") }
|
|
achievementsList = await tx.objectStore("achievements").getAll()
|
|
return achievementsList
|
|
}
|
|
|
|
const getPoints = async () => (await getAchievements()).map(a => a.points).reduce((x, y) => x + y, 0)
|
|
|
|
const updateMetric = async (name, fn, def) => {
|
|
const tx = db.transaction("metrics", "readwrite")
|
|
const init = await tx.store.get(name) || def
|
|
const newValue = fn(init)
|
|
await tx.store.put(newValue, name)
|
|
switch (name) {
|
|
case "achievements":
|
|
if (newValue === 1) {
|
|
await unlockAchievement("firstAchievement")
|
|
}
|
|
break
|
|
}
|
|
return newValue
|
|
}
|
|
// increment pages visited count, since this should be run when a page is visited
|
|
updateMetric("pagesVisited", x => x + 1, 0)
|
|
|
|
const visitStart = Date.now()
|
|
window.onbeforeunload = () => {
|
|
const elapsedMs = Date.now() - visitStart
|
|
updateMetric("timeSpent", x => x + (elapsedMs / 1000), 0)
|
|
}
|
|
|
|
const readAllMetrics = async () => {
|
|
const out = new Map()
|
|
const tx = db.transaction("metrics", "readonly")
|
|
for (const key of await tx.store.getAllKeys()) {
|
|
out.set(key, await tx.store.get(key))
|
|
}
|
|
return out
|
|
}
|
|
|
|
const reset = async () => {
|
|
const tx = db.transaction(["achievements", "metrics"], "readwrite")
|
|
for (const achievement of await tx.objectStore("achievements").getAllKeys()) {
|
|
await tx.objectStore("achievements").delete(achievement)
|
|
}
|
|
for (const metric of await tx.objectStore("metrics").getAllKeys()) {
|
|
await tx.objectStore("metrics").delete(metric)
|
|
}
|
|
achievementsList = []
|
|
// fireUpdatedEvent()
|
|
// called when achievement is unlocked there anyway
|
|
await unlockAchievement("reset")
|
|
}
|
|
|
|
const unlockAchievement = async id => {
|
|
const tx = db.transaction("achievements", "readwrite")
|
|
const achievementsUnlocked = await getAchievements(tx)
|
|
if (achievementsUnlocked.filter(a => a.id === id).length > 0) { return "already unlocked" }
|
|
const info = achievementInfo[id]
|
|
if (!info) { throw new Error("Achievement not recognized") }
|
|
info.points = info.points || 10
|
|
displayAchievement(info.title, info.description, info.conditions, info.points)
|
|
const item = {
|
|
id,
|
|
timestamp: Date.now(),
|
|
page: window.location.pathname,
|
|
points: info.points
|
|
}
|
|
achievementsList.push(item)
|
|
await Promise.all([
|
|
updateMetric("achievements", x => x + 1, 0),
|
|
tx.objectStore("achievements").put(item),
|
|
])
|
|
|
|
fireUpdatedEvent()
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", async () => {
|
|
const metrics = await readAllMetrics()
|
|
if (metrics.get("timeSpent") > 3600) { // one hour in seconds
|
|
unlockAchievement("timeSpent1Hour")
|
|
}
|
|
if (metrics.get("pagesVisited") > 64) {
|
|
unlockAchievement("pagesVisited64")
|
|
}
|
|
const now = new Date()
|
|
if (now.getUTCMonth() === 3 && now.getUTCDate() === 1) {
|
|
//if (now.getUTCMonth() === 2 && now.getUTCDate() === 22) {
|
|
unlockAchievement("aprilFools")
|
|
}
|
|
})
|
|
|
|
setInterval(() => {
|
|
if (Math.random() < 0.00001) {
|
|
unlockAchievement("blindLuck")
|
|
}
|
|
}, 1000)
|
|
|
|
window.addEventListener("input", e => {
|
|
if (e.target) {
|
|
const text = e.target.value || e.target.textContent
|
|
// extremely advanced XSS detection algorithm
|
|
if (text && (text.includes("<script") || text.includes("onload="))) {
|
|
unlockAchievement("attemptedXSS")
|
|
}
|
|
}
|
|
})
|
|
|
|
window.addEventListener("click", e => {
|
|
// detect clicking of comment "submit" button
|
|
if (e.target &&
|
|
e.target.value === "Submit" &&
|
|
e.target.parentElement &&
|
|
e.target.parentElement.parentElement &&
|
|
e.target.parentElement.parentElement.classList.contains("controls")) {
|
|
unlockAchievement("firstComment")
|
|
points.updateMetric("commentsPosted", function(x) { return x + 1 }, 0)
|
|
}
|
|
})
|
|
|
|
return {
|
|
reset,
|
|
updateMetric,
|
|
readAllMetrics,
|
|
getPoints,
|
|
unlockAchievement,
|
|
getAchievements,
|
|
achievementInfo
|
|
}
|
|
})()
|
|
|
|
const footnotes = document.querySelector(".footnotes")
|
|
const sidenotes = document.querySelector(".sidenotes")
|
|
if (sidenotes && footnotes) {
|
|
const codeblocks = document.querySelectorAll(".wider")
|
|
const article = document.querySelector(".content")
|
|
while (footnotes.firstChild) {
|
|
sidenotes.appendChild(footnotes.firstChild)
|
|
}
|
|
const footnoteItems = sidenotes.querySelectorAll(".footnote-item")
|
|
|
|
const sum = xs => xs.reduce((a, b) => a + b, 0)
|
|
const arrayOf = (n, x) => new Array(n).fill(x)
|
|
const BORDER = 16
|
|
const sidenotesAtSide = () => getComputedStyle(sidenotes).paddingLeft !== "0px"
|
|
let rendered = false
|
|
const relayout = forceRedraw => {
|
|
// sidenote column width is static: no need to redo positioning on resize unless no positions applied
|
|
if (sidenotesAtSide()) {
|
|
if (rendered && !forceRedraw) return
|
|
// sidenote vertical placement algorithm
|
|
const snRect = sidenotes.getBoundingClientRect()
|
|
const articleRect = article.getBoundingClientRect()
|
|
const exclusions = [[-Infinity, Math.max(articleRect.top, snRect.top)]]
|
|
for (const codeblock of codeblocks) {
|
|
const codeblockRect = codeblock.getBoundingClientRect()
|
|
if (codeblockRect.width !== 0) { // collapsed
|
|
exclusions.push([codeblockRect.top - BORDER, codeblockRect.top + codeblockRect.height + BORDER])
|
|
}
|
|
}
|
|
// convert unusable regions into list of usable regions
|
|
const inclusions = []
|
|
for (const [start, end] of exclusions) {
|
|
if (inclusions.length) inclusions[inclusions.length - 1].end = start - snRect.top
|
|
inclusions.push({ start: end - snRect.top, contents: [] })
|
|
}
|
|
inclusions[inclusions.length - 1].end = Infinity
|
|
const notes = []
|
|
// read off sidenotes to place
|
|
for (const item of footnoteItems) {
|
|
const link = article.querySelector(`#${item.id.replace(/^fn/, "fnref")}`)
|
|
const linkRect = link.getBoundingClientRect()
|
|
item.style.position = "absolute"
|
|
item.style.left = getComputedStyle(sidenotes).paddingLeft
|
|
item.style.paddingBottom = item.style.paddingTop = item.style.paddingRight = `${BORDER / 2}px`
|
|
const itemRect = item.getBoundingClientRect()
|
|
notes.push({
|
|
item,
|
|
height: itemRect.height,
|
|
target: linkRect.top - snRect.top
|
|
})
|
|
}
|
|
// preliminary placement: place in valid regions going down
|
|
for (const note of notes) {
|
|
const index = Math.max(inclusions.findLastIndex(inc => inc.start < note.target), 0)
|
|
const next = inclusions.slice(index)
|
|
.findIndex(inc => (sum(inc.contents.map(x => x.height)) + note.height) < (inc.end - inc.start))
|
|
inclusions[index + next].contents.push(note)
|
|
}
|
|
// TODO: try simple moves between regions? might be useful sometimes
|
|
// place within region and apply styles
|
|
for (const inc of inclusions) {
|
|
const regionNotes = inc.contents
|
|
if (regionNotes.length > 0) {
|
|
const variables = {}
|
|
const constraints = {}
|
|
if (inc.end !== Infinity) {
|
|
const heights = regionNotes.map(note => note.height)
|
|
constraints["sum_gaps"] = { max: inc.end - inc.start - sum(heights) }
|
|
}
|
|
regionNotes.forEach((note, i) => {
|
|
variables[`distbound_${i}`] = {
|
|
"distsum": 1,
|
|
[`distbound_${i}_offset`]: 1,
|
|
[`distbound_${i}_offset_neg`]: 1
|
|
}
|
|
|
|
const heightsum = sum(regionNotes.slice(0, i).map(x => x.height))
|
|
const baseoffset = heightsum - note.target
|
|
|
|
// WANT: distbound_i >= placement_i - target_i AND distbound_i <= target_i - placement_i
|
|
// distbound_i >= gapsum_i + heightsum_i - target_i
|
|
|
|
// distbound_i_offset = distbound_i - gapsum_i
|
|
// so distbound_i_offset >= heightsum_i - target_i
|
|
// implies distbound_i - gapsum_i >= heightsum_i - target_i
|
|
// (as required)
|
|
|
|
// distbound_i + gapsum_i >= heightsum_i - target_i
|
|
|
|
constraints[`distbound_${i}_offset`] = { min: baseoffset }
|
|
constraints[`distbound_${i}_offset_neg`] = { min: -baseoffset }
|
|
|
|
constraints[`gap_${i}`] = { min: 0 }
|
|
const G_i_var = { "sum_gaps": 1 }
|
|
for (let j = i; j <= regionNotes.length; j++) G_i_var[`distbound_${j}_offset`] = -1
|
|
for (let j = i; j < regionNotes.length; j++) G_i_var[`distbound_${j}_offset_neg`] = 1
|
|
variables[`gap_${i}`] = G_i_var
|
|
})
|
|
const model = {
|
|
direction: "minimize",
|
|
objective: "distsum",
|
|
constraints,
|
|
variables
|
|
}
|
|
const solution = solve(model, { includeZeroVariables: true })
|
|
if (solution.status !== "optimal") {
|
|
// implode
|
|
solution.variables = []
|
|
console.warn("Sidenote layout failed", solution.status)
|
|
}
|
|
const solutionVars = new Map(solution.variables)
|
|
let position = 0
|
|
regionNotes.forEach((note, i) => {
|
|
position += solutionVars.get(`gap_${i}`) || 0
|
|
note.item.style.top = position + "px"
|
|
position += note.height
|
|
})
|
|
}
|
|
}
|
|
rendered = true
|
|
} else {
|
|
for (const item of sidenotes.querySelectorAll(".footnote-item")) {
|
|
item.style.position = "static"
|
|
}
|
|
rendered = false
|
|
}
|
|
|
|
for (const item of sidenotes.querySelectorAll(".footnote-item")) {
|
|
const link = article.querySelector(`#${item.id.replace(/^fn/, "fnref")}`)
|
|
link.addEventListener("mouseover", () => {
|
|
item.classList.add("hl2")
|
|
})
|
|
link.addEventListener("mouseleave", () => {
|
|
item.classList.remove("hl2")
|
|
})
|
|
}
|
|
}
|
|
|
|
window.onresize = relayout
|
|
window.onload = relayout
|
|
document.querySelectorAll("summary").forEach(x => {
|
|
x.addEventListener("click", () => {
|
|
setTimeout(() => relayout(true), 0)
|
|
})
|
|
})
|
|
window.relayout = relayout
|
|
document.querySelectorAll("img").forEach(x => {
|
|
x.addEventListener("load", () => {
|
|
setTimeout(() => relayout(true), 0)
|
|
})
|
|
})
|
|
}
|
|
|
|
let previousHighlight
|
|
const fixDetailsSummary = () => {
|
|
const el = document.getElementById(window.location.hash.slice(1))
|
|
var parent = el
|
|
if (!el) return
|
|
if (el.classList.contains("footnote-item")) {
|
|
if (previousHighlight) {
|
|
previousHighlight.classList.remove("hl1")
|
|
}
|
|
el.classList.add("hl1")
|
|
previousHighlight = el
|
|
}
|
|
while (parent.parentElement) {
|
|
if (parent.nodeName === "DETAILS") {
|
|
parent.setAttribute("open", true)
|
|
}
|
|
parent = parent.parentElement
|
|
}
|
|
el.scrollIntoView()
|
|
}
|
|
|
|
window.addEventListener("hashchange", fixDetailsSummary)
|
|
fixDetailsSummary()
|
|
|
|
const customStyle = localStorage.getItem("user-stylesheet")
|
|
let customStyleEl = null
|
|
if (customStyle) {
|
|
customStyleEl = document.createElement("style")
|
|
customStyleEl.appendChild(document.createTextNode(customStyle))
|
|
customStyleEl.onload = () => console.log("Loaded custom styles")
|
|
customStyleEl.id = "custom-style"
|
|
document.head.appendChild(customStyleEl)
|
|
}
|
|
window.customStyleEl = customStyleEl
|
|
window.customStyle = customStyle
|
|
|
|
const nameMappings = {
|
|
"blog": "Blog",
|
|
"microblog": "Microblog",
|
|
"experiment": "Experiments",
|
|
"mycorrhiza": "Documentation"
|
|
}
|
|
const hues = {
|
|
"blog": "210",
|
|
"microblog": "290",
|
|
"mycorrhiza": "30",
|
|
"experiment": "120"
|
|
}
|
|
|
|
const closeOverlay = () => {
|
|
if (document.querySelector(".search-overlay")) {
|
|
document.querySelector(".search-overlay").remove()
|
|
}
|
|
document.querySelector("html").style.overscrollBehavior = "none"
|
|
}
|
|
|
|
// replace login navbar option with search because whatever
|
|
const loginButton = document.querySelector("nav a:last-of-type")
|
|
loginButton.href = "#"
|
|
loginButton.innerText = "Search"
|
|
loginButton.onclick = async ev => {
|
|
ev.preventDefault()
|
|
const query = (await import("/assets/js/fts_client.js")).default
|
|
const overlay = document.createElement("div")
|
|
overlay.classList.add("search-overlay")
|
|
document.body.appendChild(overlay)
|
|
document.querySelector("html").style.overscrollBehavior = "none"
|
|
const input = document.createElement("input")
|
|
input.type = "text"
|
|
input.placeholder = "Search"
|
|
let resultsEl
|
|
input.oninput = () => {
|
|
if (resultsEl) {
|
|
resultsEl.remove()
|
|
}
|
|
resultsEl = document.createElement("div")
|
|
resultsEl.classList.add("search-results")
|
|
for (const result of query(input.value)) {
|
|
const item = e("search-result", resultsEl)
|
|
const titleLine = nameMappings[result.sourceType] + " / " + (result.title ?? result.timestamp)
|
|
const link = e("search-result-link", item, titleLine, "a")
|
|
link.setAttribute("href", result.url)
|
|
if (result.title && result.timestamp) {
|
|
e("deemph", item, result.timestamp)
|
|
}
|
|
if (result.description) {
|
|
e("description", item, result.description)
|
|
}
|
|
item.style.border = `hsl(${hues[result.sourceType]}deg, 100%, 70%) solid 4px`
|
|
item.style.background = `hsl(${hues[result.sourceType]}deg, 50%, 10%)`
|
|
}
|
|
overlay.appendChild(resultsEl)
|
|
}
|
|
input.onkeydown = ev => {
|
|
if (ev.key === "Enter" || ev.key === "Backspace") {
|
|
if (input.value === "") {
|
|
// quit search mode
|
|
closeOverlay()
|
|
}
|
|
}
|
|
}
|
|
overlay.appendChild(input)
|
|
input.focus()
|
|
}
|
|
|
|
window.addEventListener("keydown", ev => {
|
|
if (ev.key === "Escape") {
|
|
closeOverlay()
|
|
}
|
|
}) |