Add Calendar integration (read-only)

This commit is contained in:
2026-09-25 11:19:28 +02:00
parent c84f233bbb
commit 4116a0c332
25 changed files with 1808 additions and 32 deletions
+45
View File
@@ -0,0 +1,45 @@
<h2 id="maintenance">Maintenance</h2>
{{if .Maintenance}}
<div class="cal-scroll"><table class="data-table panel cal-table">
<thead><tr><th>Task</th><th>Repeats</th><th>Last</th><th>Next</th></tr></thead>
<tbody>
{{range .Maintenance}}
<tr><td>{{.Summary}}</td><td class="muted">{{.Rule}}</td><td class="nowrap">{{.Last}}</td><td class="nowrap">{{.Next}}</td></tr>
{{end}}
</tbody>
</table></div>
{{else if .HasMaintenance}}
<p class="muted">No maintenance tasks within a year of today.</p>
{{else}}
<p class="muted">No maintenance calendar. Set <code>maintenance = &lt;name&gt;</code> in this folder's <code>.page-settings</code>.</p>
{{end}}
<h2 id="calendars">Calendars</h2>
{{if .Calendars}}
<div class="cal-scroll"><table class="data-table panel cal-table">
<thead><tr><th>Calendar</th><th>Role</th><th>Events</th><th>Updated</th></tr></thead>
<tbody>
{{range .Calendars}}
<tr>
<td>{{.Name}}{{if .Error}}<div class="cal-error">{{.Error}}</div>{{end}}</td>
<td class="muted">{{.Roles}}</td>
<td>{{.Events}}</td>
<td class="nowrap">{{.Updated}}</td>
</tr>
{{end}}
</tbody>
</table></div>
{{else}}
<p class="muted">No <code>.ics</code> files in this folder yet.</p>
{{end}}
{{if .IsSyncDir}}
{{if .SyncEnabled}}
<form class="cal-sync row" method="post" action="?calsync">
<button type="submit" class="btn">Sync now</button>
<span class="muted">{{if .LastRun}}Last sync: {{.LastRun}}{{else}}Not synced yet{{end}}</span>
</form>
{{if .LastError}}<p class="cal-error">{{.LastError}}</p>{{end}}
{{else}}
<p class="muted">Sync is off. Set <code>caldav-url</code>, <code>caldav-user</code> and <code>caldav-pass</code> in this folder's <code>.page-settings</code>.</p>
{{end}}
{{end}}
+16
View File
@@ -0,0 +1,16 @@
<span class="dropdown maint">
<button type="button" class="btn maint-toggle{{if not .Count}} is-idle{{end}}" data-action="maint-menu" aria-haspopup="true" aria-expanded="false" title="Maintenance (H)">{{icon "maintenance"}}<span class="maint-count">{{.Count}}</span></button>
<div class="dropdown-menu align-right maint-menu">
<div class="panel-header">Today</div>
<div class="maint-list">
{{range .Today}}<span class="maint-when">{{.When}}</span><span>{{.Summary}}</span>
{{else}}<span class="muted">Nothing due</span>{{end}}
</div>
<div class="panel-header">Next 7 days</div>
<div class="maint-list">
{{range .Upcoming}}<span class="maint-when">{{.When}}</span><span>{{.Summary}}</span>
{{else}}<span class="muted">Nothing planned</span>{{end}}
</div>
{{if .PageURL}}<a class="btn btn-block maint-all" href="{{.PageURL}}">All maintenance →</a>{{end}}
</div>
</span>
+4
View File
@@ -10,4 +10,8 @@
{{end}}
</div>
{{end}}
{{if or .Events .Weight}}<div class="diary-day-cal muted">
{{if .Events}}<p>{{.Events}}</p>{{end}}
{{if .Weight}}<p>{{.Weight}}</p>{{end}}
</div>{{end}}
{{end}}
+15 -7
View File
@@ -16,6 +16,12 @@
e.preventDefault();
if (window.location.pathname !== '/' && typeof movePage === 'function') movePage();
break;
case 'H':
var maint = document.querySelector('[data-action="maint-menu"]');
if (!maint) return;
e.preventDefault();
maint.click();
break;
case 'F':
var input = document.querySelector('.search-input');
if (!input) return;
@@ -58,12 +64,14 @@ document.addEventListener('keydown', function (e) {
});
document.addEventListener('DOMContentLoaded', function () {
var trigger = document.querySelector('[data-action="actions-menu"]');
if (!trigger) return;
wireDropdown(trigger);
var menu = trigger.parentElement.querySelector('.dropdown-menu');
if (!menu) return;
trigger.addEventListener('click', function () {
trigger.setAttribute('aria-expanded', menu.classList.contains('is-open') ? true : false);
['actions-menu', 'maint-menu'].forEach(function (action) {
var trigger = document.querySelector('[data-action="' + action + '"]');
if (!trigger) return;
wireDropdown(trigger);
var menu = trigger.parentElement.querySelector('.dropdown-menu');
if (!menu) return;
trigger.addEventListener('click', function () {
trigger.setAttribute('aria-expanded', menu.classList.contains('is-open') ? true : false);
});
});
});
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
<path d="M2 4h12M6 4V2h4v2M4 4l1 10h6l1-10M7 7v4M9 7v4"/>
</svg>

After

Width:  |  Height:  |  Size: 259 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
<path d="M11 2l3 3-8 8H3v-3zM9 4l3 3"/>
</svg>

After

Width:  |  Height:  |  Size: 241 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
<path d="M2 3h12v11H2zM2 6h12M5 1v2M11 1v2M5 10l2 2 4-4"/>
</svg>

After

Width:  |  Height:  |  Size: 260 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
<path d="M2 8h8M7 5l3 3-3 3M13 2v12"/>
</svg>

After

Width:  |  Height:  |  Size: 240 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
<path d="M8 3v10M3 8h10"/>
</svg>

After

Width:  |  Height:  |  Size: 228 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
<path d="M13 8a5 5 0 1 1-1.5-3.5M12 1v4H8"/>
</svg>

After

Width:  |  Height:  |  Size: 246 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" stroke-linecap="square">
<path d="M1 14V3h5l1 2h8v9zM8 12V8M6 10l2-2 2 2"/>
</svg>

After

Width:  |  Height:  |  Size: 252 B

+1
View File
@@ -31,6 +31,7 @@
{{end}}
<div class="header-actions row">
{{if .EditMode}}{{block "headerActions" .}}{{end}}{{else}}
{{maintenanceWidget}}
<span class="dropdown actions">
<button type="button" class="btn actions-toggle" data-action="actions-menu" aria-haspopup="true" aria-expanded="false" title="Actions">ACTIONS</button>
<div class="dropdown-menu align-right actions-menu">
+5 -5
View File
@@ -1,13 +1,13 @@
{{define "headScripts"}}<script src="/_/page/actions.js"></script>{{end}}
{{define "headerActions"}}{{if .CanEdit}}
<button class="btn btn-block" onclick="newPage()" title="New page (N)">NEW</button>
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager">REVEAL</button>
<button class="btn btn-block" onclick="newPage()" title="New page (N)"><span class="btn-icon">{{icon "new"}}</span><span class="btn-label">NEW</span></button>
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager"><span class="btn-icon">{{icon "reveal"}}</span><span class="btn-label">REVEAL</span></button>
{{if not .IsRoot}}
<button class="btn btn-block" onclick="movePage()" title="Move page (M)">MOVE</button>
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page">DELETE</button>
<button class="btn btn-block" onclick="movePage()" title="Move page (M)"><span class="btn-icon">{{icon "move"}}</span><span class="btn-label">MOVE</span></button>
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page"><span class="btn-icon">{{icon "delete"}}</span><span class="btn-label">DELETE</span></button>
{{end}}
<a class="btn btn-block" href="?edit" title="Edit page (E)">EDIT</a>
<a class="btn btn-block" href="?edit" title="Edit page (E)"><span class="btn-icon">{{icon "edit"}}</span><span class="btn-label">EDIT</span></a>
{{end}}{{end}}
{{define "content"}}
+1 -1
View File
@@ -43,7 +43,7 @@
{{end}}
{{define "headerActions"}}
<button class="btn btn-block" onclick="rebuildIndex()" title="Rebuild search index">REBUILD INDEX</button>
<button class="btn btn-block" onclick="rebuildIndex()" title="Rebuild search index"><span class="btn-icon">{{icon "rebuild"}}</span><span class="btn-label">REBUILD INDEX</span></button>
{{end}}
{{define "footerExtras"}}
+38 -1
View File
@@ -389,6 +389,10 @@ main > h2 {
.dropdown-menu.scrollable { max-height: 23rem; overflow-y: auto; }
.actions-special > .btn {text-align: left;}
/* Header actions carry an icon and a label: the mobile dropdown shows the
label, the inline desktop bar (see responsive block) the icon. */
.btn-icon { display: none; }
.btn-icon svg { display: block; }
/* === Suggestion dropdown (header search + editor link picker) ===
Anchored to a position:relative host. Mirrors .dropdown-menu visuals with
@@ -797,6 +801,37 @@ aside.sidebar:empty { display: none; }
.diary-cal-grid td.cal-current,
.diary-cal-grid td.cal-current a { color: var(--primary-hover); }
.btn-block.cal-current { color: var(--primary-hover); }
/* Calendar events and weight under a diary day heading. */
.diary-day-cal { margin: var(--space-3) 0 0; }
.diary-day-cal p { margin: 0; }
/* === Calendars ===
Header maintenance chip + dropdown, and the calendars folder page. */
.maint-toggle { display: inline-flex; align-items: center; }
.maint-count { margin-left: 0.3rem; }
.maint-toggle.is-idle { color: var(--text-muted); }
.maint-menu {
min-width: 20rem;
max-width: calc(100vw - 2rem);
padding: var(--space-2) var(--space-3);
white-space: normal;
}
/* Two columns so dates line up; an empty when-cell keeps the grid intact. */
.maint-list {
display: grid;
grid-template-columns: max-content 1fr;
column-gap: var(--space-2);
row-gap: 0.15rem;
font-size: var(--font-sm);
}
.maint-list > .muted { grid-column: 1 / -1; }
.maint-list + .panel-header { margin-top: var(--space-3); }
.maint-when { color: var(--text-muted); white-space: nowrap; }
.maint-all { margin-top: var(--space-3); padding: 0.3rem 0 0; border-top: var(--border-dashed); }
.cal-scroll { overflow-x: auto; }
.cal-table .nowrap { white-space: nowrap; }
.cal-error { color: var(--danger); font-size: var(--font-sm); }
.cal-sync { margin-top: var(--space-3); }
/* === Fitness dashboard ===
Server-rendered inline SVG charts. Geometry comes precomputed from Go;
@@ -840,7 +875,9 @@ aside.sidebar:empty { display: none; }
@media (min-width: 1101px) {
.actions-toggle, .actions-special {display: none;}
.actions, .actions-menu, .actions.dropdown-menu.is-open {display: contents;}
.actions-menu .btn-block { width: auto; padding: 0;}
.actions-menu .btn-block { width: auto; padding: 0; align-items: center; }
.actions-menu .btn-icon { display: inline-block; }
.actions-menu .btn-label { display: none; }
}
@media (max-width: 600px) {