Initial commit
This commit is contained in:
commit
a55ccd4335
14 changed files with 686 additions and 0 deletions
2
.dockerignore
Normal file
2
.dockerignore
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
./database/
|
||||||
|
node_modules/
|
3
.editorconfig
Normal file
3
.editorconfig
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
[*.{ts,js,tsx,jsx}]
|
||||||
|
indent_style = tab
|
||||||
|
tab_width = 2
|
181
.gitignore
vendored
Normal file
181
.gitignore
vendored
Normal file
|
@ -0,0 +1,181 @@
|
||||||
|
# Application specific
|
||||||
|
database/database.json
|
||||||
|
|
||||||
|
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
npm-debug.log_
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
|
||||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
|
||||||
|
pids
|
||||||
|
_.pid
|
||||||
|
_.seed
|
||||||
|
\*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
|
||||||
|
coverage
|
||||||
|
\*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
|
||||||
|
\*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
|
||||||
|
\*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.\*
|
||||||
|
|
||||||
|
# IntelliJ based IDEs
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Finder (MacOS) folder config
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Bun
|
||||||
|
bun.lockb
|
10
Dockerfile
Normal file
10
Dockerfile
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
FROM oven/bun:1
|
||||||
|
|
||||||
|
ENV LISTEN_PORT 7000
|
||||||
|
|
||||||
|
WORKDIR .
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN bun install
|
||||||
|
CMD ["bun", "start"]
|
||||||
|
EXPOSE ${LISTEN_PORT}
|
3
README.md
Normal file
3
README.md
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# Status Page
|
||||||
|
This is a simple way to create customizable status pages.
|
||||||
|
|
87
database/example.json5
Normal file
87
database/example.json5
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
// NOTE: JSON5 is not supported! Change the extension to `json` and remove the comments.
|
||||||
|
// NOTE: This example _may be_ obsolete. Check [src/db.ts] for the full schema.
|
||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"title": "Example"
|
||||||
|
},
|
||||||
|
"statuses": {
|
||||||
|
"working": {
|
||||||
|
"displayName": "Working",
|
||||||
|
"color": "#26a269",
|
||||||
|
"description": "The service is up and running"
|
||||||
|
},
|
||||||
|
"unstable": {
|
||||||
|
"displayName": "Unstable",
|
||||||
|
"color": "#e5a50a",
|
||||||
|
"description": "The service may have some short-lived problems"
|
||||||
|
},
|
||||||
|
"degraded": {
|
||||||
|
"displayName": "Degraded",
|
||||||
|
"color": "#c64600",
|
||||||
|
"description": "There are known issues being resolved right now"
|
||||||
|
},
|
||||||
|
"down": {
|
||||||
|
"display": "Down",
|
||||||
|
"color": "#a51d2d",
|
||||||
|
"description": "The service is down"
|
||||||
|
},
|
||||||
|
"deprecated": {
|
||||||
|
"display": "Deprecated",
|
||||||
|
"color": "#613583",
|
||||||
|
"description": "Status of this service is not tracked and it may be turned off at any time"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notices": [
|
||||||
|
{
|
||||||
|
"title": "Maintenance of the CDN",
|
||||||
|
"expectedStatus": "unstable", // Optional
|
||||||
|
"affectedServices": ["cdn", "invidious"], // Optional
|
||||||
|
"expectedDuration": { // Optional
|
||||||
|
"from": 1698408000000, // Result of Date.UTC(...)
|
||||||
|
"to": 1698494400000
|
||||||
|
},
|
||||||
|
"description": "The CDN is going to be in maintenance for some time"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"cdn": {
|
||||||
|
"displayName": "CDN",
|
||||||
|
"link": "https://cdn.sofiaritz.com", // The link is optional
|
||||||
|
"description": "CDN used to host things like GFonts",
|
||||||
|
"status": "working"
|
||||||
|
},
|
||||||
|
"invidious": {
|
||||||
|
"displayName": "Invidious",
|
||||||
|
"description": "Invite-only Invidious instance",
|
||||||
|
"status": "unstable",
|
||||||
|
"updates": [
|
||||||
|
{
|
||||||
|
"time": 1698348215919,
|
||||||
|
"description": "**Monitoring** ‐ A fix has been implemented and the result are being monitored.",
|
||||||
|
"author": "Sofía Aritz" // The author is optional
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"time": 169834801889, // The result of `Date.now()`
|
||||||
|
"description": "**Investigating** ‐ Log-in is not working properly."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contact": [
|
||||||
|
{
|
||||||
|
"name": "Sofía Aritz",
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"name": "E-Mail",
|
||||||
|
"href": "mailto:sofi@sofiaritz.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Matrix",
|
||||||
|
"href": "https://matrix.to/#/@sofiaritz:matrix.org"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"gravatarEmail": "sofi@sofiaritz.com", // This is optional
|
||||||
|
"additionalInfo": "My PGP key is available at [sofiaritz.com/keys/pub.asc](https://sofiaritz.com/keys/pub.asc)" // This is optional
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
version: "3"
|
||||||
|
|
||||||
|
services:
|
||||||
|
status-page:
|
||||||
|
image: "status-page:latest"
|
||||||
|
volumes:
|
||||||
|
- ./database:/home/bun/app/database:ro
|
||||||
|
ports:
|
||||||
|
- "7000:7000"
|
23
package.json
Normal file
23
package.json
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "status-page",
|
||||||
|
"module": "src/index.ts",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "bun run src/index.ts",
|
||||||
|
"watch": "bun --watch src/index.ts",
|
||||||
|
"docker:build": "docker build -t status-page ."
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"bun-types": "latest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@elysiajs/html": "^0.7.3",
|
||||||
|
"@elysiajs/static": "^0.7.1",
|
||||||
|
"elysia": "^0.7.21",
|
||||||
|
"handlebars": "^4.7.8",
|
||||||
|
"lowdb": "^6.1.1"
|
||||||
|
}
|
||||||
|
}
|
103
public/style.css
Normal file
103
public/style.css
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
:root {
|
||||||
|
font-family: Inter, "Segoe UI", system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
background-color: #242424;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6, p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #646cff;
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: #535bf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
main > div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notices {
|
||||||
|
border: #fafafa solid 1px;
|
||||||
|
padding: .5rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expected-status, .affected-services, .expected-duration {
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.services {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service, .status {
|
||||||
|
border: #fafafa solid 1px;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact, .statuses {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.individual-contact {
|
||||||
|
border: #fafafa solid 1px;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-metadata, .contact-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-metadata img {
|
||||||
|
border-radius: 99999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 1000px) {
|
||||||
|
.services {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
2
src/consts.ts
Normal file
2
src/consts.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export const UPDATE_INTERVAL = Number(Bun.env["UPDATE_INTERVAL"]) || 1000
|
||||||
|
export const LISTEN_PORT = Number(Bun.env["LISTEN_PORT"]) || 7000
|
74
src/db.ts
Normal file
74
src/db.ts
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
import { JSONPreset } from "lowdb/node"
|
||||||
|
import {UPDATE_INTERVAL} from "./consts.ts";
|
||||||
|
|
||||||
|
const defaultData: Database = {
|
||||||
|
metadata: {
|
||||||
|
title: "Default Status Page"
|
||||||
|
},
|
||||||
|
statuses: {
|
||||||
|
defaultData: {
|
||||||
|
displayName: "Default Data",
|
||||||
|
color: "#c64600",
|
||||||
|
description: "This status is part of the default schema"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
example: {
|
||||||
|
displayName: "Example",
|
||||||
|
description: "This service is an example",
|
||||||
|
status: "defaultData"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contact: [{
|
||||||
|
name: "Example maintainer",
|
||||||
|
links: [{
|
||||||
|
name: "Example contact",
|
||||||
|
href: "mailto:example@example.com"
|
||||||
|
}],
|
||||||
|
additionalInfo: "This is an example contact info"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
export const database = await JSONPreset<Database>("database/database.json", defaultData)
|
||||||
|
|
||||||
|
setInterval(() => database.read(), UPDATE_INTERVAL)
|
||||||
|
|
||||||
|
export type Database = {
|
||||||
|
metadata: {
|
||||||
|
title: string,
|
||||||
|
},
|
||||||
|
statuses: {[key: string]: {
|
||||||
|
displayName: string,
|
||||||
|
color: string,
|
||||||
|
description: string,
|
||||||
|
}},
|
||||||
|
notices?: {
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
expectedStatus?: keyof Database["statuses"],
|
||||||
|
affectedServices?: (keyof Database["services"])[],
|
||||||
|
expectedDuration?: {
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
},
|
||||||
|
}[],
|
||||||
|
services: {[key: string]: {
|
||||||
|
displayName: string,
|
||||||
|
description: string,
|
||||||
|
status: keyof Database["statuses"],
|
||||||
|
link?: string,
|
||||||
|
updates?: {
|
||||||
|
time: number,
|
||||||
|
description: string,
|
||||||
|
author?: string,
|
||||||
|
}[],
|
||||||
|
}},
|
||||||
|
contact: {
|
||||||
|
name: string,
|
||||||
|
links: {
|
||||||
|
name: string,
|
||||||
|
href: string,
|
||||||
|
}[],
|
||||||
|
gravatarEmail?: string,
|
||||||
|
additionalInfo?: string,
|
||||||
|
}[],
|
||||||
|
}
|
46
src/index.ts
Normal file
46
src/index.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import {database} from "./db.ts";
|
||||||
|
import Elysia from "elysia";
|
||||||
|
import Handlebars from "handlebars";
|
||||||
|
import {readFile} from "fs/promises";
|
||||||
|
import {LISTEN_PORT} from "./consts.ts";
|
||||||
|
import {html} from "@elysiajs/html";
|
||||||
|
import staticPlugin from "@elysiajs/static";
|
||||||
|
import {hash} from "bun";
|
||||||
|
|
||||||
|
Handlebars.registerHelper("status", (status, options) => {
|
||||||
|
return options.fn({
|
||||||
|
status,
|
||||||
|
...options.data.root["statuses"][status]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Handlebars.registerHelper("inlineStatus", (statusID, options) => {
|
||||||
|
let status = options.data.root["statuses"][statusID]
|
||||||
|
return new Handlebars.SafeString(`<a href="#status-${statusID}" style="color: ${status.color}" class="inline-status status-${statusID}">${status.displayName}</a>`)
|
||||||
|
})
|
||||||
|
|
||||||
|
Handlebars.registerHelper("inlineService", (serviceID, options) => {
|
||||||
|
let service = options.data.root["services"][serviceID]
|
||||||
|
return new Handlebars.SafeString(`<span class="inline-service service-${serviceID}">${service.displayName}</span>`)
|
||||||
|
})
|
||||||
|
|
||||||
|
Handlebars.registerHelper("epochUTC", (time) => {
|
||||||
|
return new Handlebars.SafeString(`<time datetime="${(new Date(time)).toISOString()}">UTC: ${(new Date(time)).toLocaleString()}</time>`)
|
||||||
|
})
|
||||||
|
|
||||||
|
Handlebars.registerHelper("gravatar", (email) => {
|
||||||
|
let hasher = new Bun.CryptoHasher("sha256")
|
||||||
|
hasher.update(email.trim().toLowerCase())
|
||||||
|
let src = "https://gravatar.com/avatar/" + hasher.digest("hex")
|
||||||
|
|
||||||
|
return new Handlebars.SafeString(`<img height="40px" src=${src}>`)
|
||||||
|
})
|
||||||
|
|
||||||
|
let template = Handlebars.compile((await readFile("templates/index.hbs")).toString())
|
||||||
|
new Elysia()
|
||||||
|
.use(html())
|
||||||
|
.use(staticPlugin())
|
||||||
|
.get("/", () => template(database.data))
|
||||||
|
.listen(LISTEN_PORT)
|
||||||
|
|
||||||
|
console.log(`Status Page is running on port ${LISTEN_PORT}`)
|
121
templates/index.hbs
Normal file
121
templates/index.hbs
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport"
|
||||||
|
content="width=device-width, user-scalable=yes, initial-scale=1.0">
|
||||||
|
<link href="/public/style.css" rel="stylesheet">
|
||||||
|
<title>{{metadata.title}}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
{{#if notices}}
|
||||||
|
<div>
|
||||||
|
<h2>Notices</h2>
|
||||||
|
<div class="notices">
|
||||||
|
{{#each notices}}
|
||||||
|
<div class="notice">
|
||||||
|
<h3 class="notice-title">{{title}}</h3>
|
||||||
|
{{#if expectedStatus}}
|
||||||
|
<div class="expected-status">
|
||||||
|
Expected status: {{inlineStatus expectedStatus}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
{{#if affectedServices.length}}
|
||||||
|
<div class="affected-services">
|
||||||
|
Affected services:
|
||||||
|
{{#each affectedServices}}
|
||||||
|
{{inlineService this}} ⊣
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
{{#if expectedDuration}}
|
||||||
|
<div class="expected-duration">
|
||||||
|
Expected duration
|
||||||
|
<ul>
|
||||||
|
<li><span class="from">From: <b>{{epochUTC expectedDuration.from}}</b></span></li>
|
||||||
|
<li><span class="to">To: <b>{{epochUTC expectedDuration.to}}</b></span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
<p class="notice-description">{{{description}}}</p>
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
<div>
|
||||||
|
<h2>Services</h2>
|
||||||
|
<div class="services">
|
||||||
|
{{#each services}}
|
||||||
|
<div class="service">
|
||||||
|
<h3 class="service-title">{{displayName}}</h3>
|
||||||
|
<div class="service-metadata">
|
||||||
|
{{#if link}}
|
||||||
|
<a href={{link}}>{{link}}</a> ‐
|
||||||
|
{{/if}}
|
||||||
|
{{inlineStatus status}}
|
||||||
|
</div>
|
||||||
|
<p class="service-description">{{{description}}}</p>
|
||||||
|
{{#if updates.length}}
|
||||||
|
<hr>
|
||||||
|
<div class="service-updates">
|
||||||
|
{{#each updates}}
|
||||||
|
<details>
|
||||||
|
<summary>Updates</summary>
|
||||||
|
<div class="service-update">
|
||||||
|
<div class="update-metadata">
|
||||||
|
<b>{{epochUTC time}}</b>
|
||||||
|
{{#if author}}
|
||||||
|
‐ <span class="update-author">{{author}}</span>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
<p class="update-description">{{{description}}}</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Contact</h2>
|
||||||
|
<div class="contact">
|
||||||
|
{{#each contact}}
|
||||||
|
<div class="individual-contact">
|
||||||
|
<div class="contact-metadata">
|
||||||
|
{{#if gravatarEmail}}
|
||||||
|
{{gravatar gravatarEmail}}
|
||||||
|
{{/if}}
|
||||||
|
<h3 class="contact-name">{{name}}</h3>
|
||||||
|
</div>
|
||||||
|
<p class="contact-addt-info">{{{additionalInfo}}}</p>
|
||||||
|
<div class="contact-links">
|
||||||
|
{{#each links}}
|
||||||
|
<a class="contact-link" href={{href}}>{{name}}</a>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Statuses</h2>
|
||||||
|
<div class="statuses">
|
||||||
|
{{#each statuses}}
|
||||||
|
<div id="status-{{@key}}" class="status">
|
||||||
|
<h3 style="color: {{color}}">{{displayName}}</h3>
|
||||||
|
<p>{{description}}</p>
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
Array.from(document.getElementsByTagName("time")).forEach((el) =>
|
||||||
|
el.innerText = (new Date(el.getAttribute("datetime"))).toLocaleString(navigator.language || "en-GB", { timeStyle: "short", dateStyle: "short" }))
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"module": "esnext",
|
||||||
|
"target": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"composite": true,
|
||||||
|
"strict": true,
|
||||||
|
"downlevelIteration": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"types": [
|
||||||
|
"bun-types" // add Bun global
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue