Compare commits

..

6 Commits

Author SHA1 Message Date
luxick 841046ba36 Minor UI refinements 2026-06-10 21:55:20 +02:00
luxick fc9cc2a150 minor UI fixes 2026-06-10 18:39:37 +02:00
luxick 7dc58121f0 Add attribution 2026-06-10 18:34:40 +02:00
luxick 38a612ae71 Update texts 2026-06-10 18:25:44 +02:00
luxick ee5d0d715a Add icon 2026-06-10 18:22:03 +02:00
luxick 0e13274036 Add deployment scripts 2026-06-10 18:16:20 +02:00
12 changed files with 279 additions and 15 deletions
+1
View File
@@ -1 +1,2 @@
*.exe *.exe
dist/
+19
View File
@@ -175,5 +175,24 @@
"closed": false, "closed": false,
"createdAt": "2026-06-10T16:01:51.4082662Z", "createdAt": "2026-06-10T16:01:51.4082662Z",
"adminToken": "wBmkZL25zTkrzYgCQBjtGxWY" "adminToken": "wBmkZL25zTkrzYgCQBjtGxWY"
},
{
"id": "y4rE5zp9hW",
"title": "Ad",
"description": "awdwadw\n\nawdawdwa\nwadwda\n\n\nwadw",
"options": [
{
"id": "mkaa95Gr",
"date": "2026-06-18"
},
{
"id": "pQbnHN7b",
"date": "2026-06-20"
}
],
"votes": [],
"closed": false,
"createdAt": "2026-06-10T16:39:18.0584144Z",
"adminToken": "hKD8vupZhQN6vY2BUbXYRwfj"
} }
] ]
+46
View File
@@ -0,0 +1,46 @@
# Deploying mediator
Target: Ubuntu 22.04 VPS, nginx in front, app in `/opt/mediator`.
The app runs as an unprivileged `mediator` system user, listens only on
`127.0.0.1:8080`, and nginx proxies the public domain to it. Polls live in
`/opt/mediator/data/polls.json` — that one file is the whole backup.
## One-time setup (on the server)
Copy the deploy files over and run the setup script as root:
```sh
scp deploy/mediator.service deploy/mediator.nginx.conf deploy/setup-server.sh himalia:/tmp/
ssh himalia
cd /tmp
sudo ./setup-server.sh <your-ssh-user> <your-domain>
```
The script creates the `mediator` user, installs the systemd unit and nginx
site, and adds a sudoers rule so your user can `systemctl restart mediator`
without a password (that keeps deploys to a single password prompt).
Then get a certificate:
```sh
sudo certbot --nginx -d <your-domain>
```
## Every deploy (from your machine)
```sh
./deploy/deploy.sh # or: ./deploy/deploy.sh otherhost
```
Cross-compiles a static linux/amd64 binary, streams it to the server over
one ssh connection (one password prompt), swaps it in atomically, restarts
the service, and prints `active` on success.
## Useful commands on the server
```sh
systemctl status mediator
journalctl -u mediator -f
cp /opt/mediator/data/polls.json ~/polls-backup.json # backup
```
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Build mediator for linux/amd64 and push it to the server.
# Run from your machine (Git Bash on Windows works):
#
# ./deploy/deploy.sh [host] default host: himalia
#
# Requires the one-time server setup (setup-server.sh) to have been run.
# Asks for the ssh password once; the binary is streamed over that same
# connection, swapped in atomically, and the service restarted.
set -euo pipefail
HOST="${1:-himalia}"
APP_DIR=/opt/mediator
cd "$(dirname "$0")/.."
echo "Building linux/amd64 binary..."
mkdir -p dist
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \
go build -trimpath -ldflags "-s -w" -o dist/mediator-linux-amd64 .
echo "Uploading to $HOST and restarting mediator..."
ssh "$HOST" "set -e
cat > $APP_DIR/mediator.new
chmod 755 $APP_DIR/mediator.new
mv -f $APP_DIR/mediator.new $APP_DIR/mediator
sudo systemctl restart mediator
sleep 1
systemctl is-active mediator
" < dist/mediator-linux-amd64
echo "Deployed $(git rev-parse --short HEAD) to $HOST."
+16
View File
@@ -0,0 +1,16 @@
# nginx site for mediator. Installed by setup-server.sh to
# /etc/nginx/sites-available/mediator with the real domain substituted.
# For HTTPS run `certbot --nginx -d <domain>` afterwards; certbot rewrites
# this file to add the TLS server block and the HTTP->HTTPS redirect.
server {
listen 80;
listen [::]:80;
server_name mediator.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+30
View File
@@ -0,0 +1,30 @@
[Unit]
Description=mediator - date polls for friend groups
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=mediator
Group=mediator
ExecStart=/opt/mediator/mediator -addr 127.0.0.1:8080 -data /opt/mediator/data
Restart=on-failure
RestartSec=2
# Sandboxing: the service only needs to read its binary and write its data dir.
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/opt/mediator/data
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictAddressFamilies=AF_INET AF_INET6
CapabilityBoundingSet=
[Install]
WantedBy=multi-user.target
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# One-time server setup for mediator on Ubuntu. Run as root ON THE SERVER,
# from a directory containing mediator.service and mediator.nginx.conf:
#
# sudo ./setup-server.sh <deploy-user> <domain>
#
# <deploy-user> the account you ssh in as; it gets write access to
# /opt/mediator and passwordless `systemctl restart mediator`
# <domain> the public hostname nginx should answer on
set -euo pipefail
DEPLOY_USER="${1:?usage: setup-server.sh <deploy-user> <domain>}"
DOMAIN="${2:?usage: setup-server.sh <deploy-user> <domain>}"
APP_DIR=/opt/mediator
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
id -u "$DEPLOY_USER" >/dev/null
# Unprivileged system user the service runs as.
id -u mediator >/dev/null 2>&1 ||
useradd --system --home "$APP_DIR" --shell /usr/sbin/nologin mediator
# Deploy user owns the app dir (to replace the binary); the service user
# owns only the data dir (the single thing it writes).
mkdir -p "$APP_DIR/data"
chown "$DEPLOY_USER" "$APP_DIR"
chmod 755 "$APP_DIR"
chown mediator:mediator "$APP_DIR/data"
chmod 750 "$APP_DIR/data"
install -m 644 "$SCRIPT_DIR/mediator.service" /etc/systemd/system/mediator.service
sed "s/mediator\.example\.com/$DOMAIN/" "$SCRIPT_DIR/mediator.nginx.conf" \
> /etc/nginx/sites-available/mediator
ln -sf /etc/nginx/sites-available/mediator /etc/nginx/sites-enabled/mediator
# Let the deploy user restart the service without a sudo password,
# so deploy.sh needs exactly one (ssh) password prompt.
printf '%s ALL=(root) NOPASSWD: /usr/bin/systemctl restart mediator\n' "$DEPLOY_USER" \
> /etc/sudoers.d/mediator-deploy
chmod 440 /etc/sudoers.d/mediator-deploy
systemctl daemon-reload
systemctl enable mediator
nginx -t
systemctl reload nginx
echo "Setup done. Now push a binary from your machine: ./deploy/deploy.sh"
echo "For HTTPS: certbot --nginx -d $DOMAIN"
+11 -6
View File
@@ -3,7 +3,8 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>mediator new poll</title> <title>mediator - new poll</title>
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
<link rel="preload" href="/static/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin> <link rel="preload" href="/static/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/static/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin> <link rel="preload" href="/static/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
@@ -11,8 +12,8 @@
<body> <body>
<div class="wrap"> <div class="wrap">
<header class="site"> <header class="site">
<a class="logo" href="/"><span class="dot"></span>mediator</a> <a class="logo" href="/"><svg class="dot" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.92" aria-hidden="true"><rect x="2.5" y="2.5" width="19" height="19"/><path d="M9.1 2.5 V15.1 M2.5 15.1 H21.5 M9.1 9 H21.5 M15 15.1 V21.5"/></svg>mediator</a>
<span class="tagline">one calendar for the whole crew</span> <span class="tagline">how hard can it be to get 4 people around a table?</span>
</header> </header>
<div id="create-view"> <div id="create-view">
@@ -22,11 +23,11 @@
<div class="card"> <div class="card">
<label class="field"> <label class="field">
<span>What's the plan?</span> <span>What's the plan?</span>
<input type="text" id="title" maxlength="120" placeholder="Pizza & board games at Lena's" autocomplete="off"> <input type="text" id="title" maxlength="120" autocomplete="off">
</label> </label>
<label class="field"> <label class="field">
<span>Details <span class="hint">(optional)</span></span> <span>Details (optional)</span>
<textarea id="description" maxlength="600" placeholder="Bring a game. We'll order around 7."></textarea> <textarea id="description" maxlength="600"></textarea>
</label> </label>
</div> </div>
@@ -74,6 +75,10 @@
<a class="btn btn-ghost" href="/">Make another</a> <a class="btn btn-ghost" href="/">Make another</a>
</div> </div>
</div> </div>
<footer class="site">
made by <span class="author">luxick</span> &middot; <a href="https://git.luxick.de/luxick/mediator">source code</a>
</footer>
</div> </div>
<script src="/static/calendar.js"></script> <script src="/static/calendar.js"></script>
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#c48401" stroke-width="1.92">
<rect x="2.5" y="2.5" width="19" height="19"/>
<path d="M9.1 2.5 V15.1 M2.5 15.1 H21.5 M9.1 9 H21.5 M15 15.1 V21.5"/>
</svg>

After

Width:  |  Height:  |  Size: 262 B

+9 -4
View File
@@ -3,7 +3,8 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>mediator poll</title> <title>mediator - poll</title>
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
<link rel="preload" href="/static/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin> <link rel="preload" href="/static/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/static/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin> <link rel="preload" href="/static/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
@@ -11,8 +12,8 @@
<body> <body>
<div class="wrap"> <div class="wrap">
<header class="site"> <header class="site">
<a class="logo" href="/"><span class="dot"></span>mediator</a> <a class="logo" href="/"><svg class="dot" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.92" aria-hidden="true"><rect x="2.5" y="2.5" width="19" height="19"/><path d="M9.1 2.5 V15.1 M2.5 15.1 H21.5 M9.1 9 H21.5 M15 15.1 V21.5"/></svg>mediator</a>
<span class="tagline">one calendar for the whole crew</span> <span class="tagline">how hard can it be to get 4 people around a table?</span>
</header> </header>
<div id="not-found" hidden> <div id="not-found" hidden>
@@ -34,7 +35,7 @@
<div class="card" id="vote-card"> <div class="card" id="vote-card">
<label class="field"> <label class="field">
<span>Your name</span> <span>Your name</span>
<input type="text" id="voter-name" maxlength="60" placeholder="So the group knows who answered" autocomplete="name"> <input type="text" id="voter-name" maxlength="60" autocomplete="name">
</label> </label>
<div class="actions"> <div class="actions">
<button class="btn btn-primary" id="vote-btn">Send my answer</button> <button class="btn btn-primary" id="vote-btn">Send my answer</button>
@@ -59,6 +60,10 @@
<p class="error" id="admin-error" role="alert"></p> <p class="error" id="admin-error" role="alert"></p>
</div> </div>
</div> </div>
<footer class="site">
made by <span class="author">luxick</span> &middot; <a href="https://git.luxick.de/luxick/mediator">source code</a>
</footer>
</div> </div>
<script src="/static/calendar.js"></script> <script src="/static/calendar.js"></script>
+21
View File
@@ -146,6 +146,27 @@ function renderResults() {
row.append(when, who, count); row.append(when, who, count);
el.appendChild(row); el.appendChild(row);
}); });
const noneWork = poll.votes.filter((v) => v.optionIds.length === 0).map((v) => v.name);
if (noneWork.length > 0) {
const row = document.createElement("div");
row.className = "result-row none-row";
const when = document.createElement("span");
when.className = "when";
when.textContent = "None at all";
const who = document.createElement("span");
who.className = "who";
who.textContent = noneWork.join(", ");
const count = document.createElement("span");
count.className = "count";
count.textContent = noneWork.length;
row.append(when, who, count);
el.appendChild(row);
}
} }
$("vote-btn").addEventListener("click", async () => { $("vote-btn").addEventListener("click", async () => {
+42 -5
View File
@@ -82,10 +82,10 @@ header.site {
.logo .dot { .logo .dot {
display: inline-block; display: inline-block;
width: 0.7em; width: 0.9em;
height: 0.7em; height: 0.9em;
background: var(--primary); color: var(--secondary);
border: 1px solid var(--secondary); vertical-align: -0.1em;
margin-right: 0.4em; margin-right: 0.4em;
} }
@@ -94,6 +94,18 @@ header.site {
font-size: var(--font-sm); font-size: var(--font-sm);
} }
/* === Footer === */
footer.site {
margin-top: var(--space-5);
padding: var(--space-3) 0;
border-top: var(--border-dashed);
color: var(--text-muted);
font-size: var(--font-sm);
text-align: center;
}
footer.site .author { color: var(--secondary); }
/* === Typography === */ /* === Typography === */
h1 { h1 {
font-size: 1.75rem; font-size: 1.75rem;
@@ -106,6 +118,23 @@ h1 {
.sub { color: var(--text-muted); margin: 0 0 var(--space-5); max-width: 60ch; } .sub { color: var(--text-muted); margin: 0 0 var(--space-5); max-width: 60ch; }
#poll-desc { white-space: pre-line; }
#vote-hint {
font-size: var(--font-sm);
font-style: italic;
}
#vote-hint::before {
content: "> ";
color: var(--secondary);
font-style: normal;
}
/* Dashed rule between description and hint — only when a description is shown. */
#poll-desc:not([hidden]) + #vote-hint {
border-top: var(--border-dashed);
padding-top: var(--space-4);
}
.hint { font-weight: normal; color: var(--text-muted); font-size: var(--font-sm); } .hint { font-weight: normal; color: var(--text-muted); font-size: var(--font-sm); }
.note { font-size: var(--font-sm); color: var(--text-muted); } .note { font-size: var(--font-sm); color: var(--text-muted); }
@@ -336,7 +365,7 @@ textarea { resize: vertical; min-height: 4rem; }
bottom: 3px; bottom: 3px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
font-size: 0.6rem; font-size: 0.8rem;
line-height: 1; line-height: 1;
color: var(--link); color: var(--link);
} }
@@ -421,6 +450,9 @@ textarea { resize: vertical; min-height: 4rem; }
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.result-row.none-row { border-top: var(--border); margin-top: var(--space-2); }
.result-row.none-row .when, .result-row.none-row .count { color: var(--danger); }
.result-row.best .when, .result-row.best .count { color: var(--link); } .result-row.best .when, .result-row.best .count { color: var(--link); }
.result-row.best:hover .when, .result-row.best:hover .count { color: var(--link); } .result-row.best:hover .when, .result-row.best:hover .count { color: var(--link); }
@@ -453,6 +485,11 @@ textarea { resize: vertical; min-height: 4rem; }
::-webkit-scrollbar-thumb:hover { background: var(--primary-hover); } ::-webkit-scrollbar-thumb:hover { background: var(--primary-hover); }
/* === Responsive === */ /* === Responsive === */
@media (max-width: 540px) {
header.site { flex-wrap: wrap; }
.tagline { flex-basis: 100%; }
}
@media (max-width: 480px) { @media (max-width: 480px) {
.result-row { flex-wrap: wrap; } .result-row { flex-wrap: wrap; }
.result-row .when { width: auto; } .result-row .when { width: auto; }