diff --git a/assets/js/guestbook.js b/assets/js/guestbook.js
new file mode 100644
index 0000000..747a323
--- /dev/null
+++ b/assets/js/guestbook.js
@@ -0,0 +1,371 @@
+/**
+ * Guestbook functionality for ritual.sh
+ * Custom implementation that calls the guestbook API directly
+ */
+
+class GuestbookManager {
+ constructor() {
+ // Configuration - Update this URL when the backend is deployed
+ this.apiUrl = "https://guestbook.ritual.sh";
+ this.perPage = 20;
+ this.currentPage = 1;
+ this.totalPages = 1;
+
+ // DOM elements
+ this.form = document.getElementById("guestbook-form");
+ this.entriesList = document.getElementById("entries-list");
+ this.entriesLoading = document.getElementById("entries-loading");
+ this.entriesError = document.getElementById("entries-error");
+ this.pagination = document.getElementById("pagination");
+ this.formFeedback = document.getElementById("form-feedback");
+ this.submitBtn = document.getElementById("submit-btn");
+
+ this.init();
+ }
+
+ init() {
+ if (!this.form) return;
+
+ // Attach event listeners
+ this.form.addEventListener("submit", (e) => this.handleSubmit(e));
+
+ // Load initial entries
+ this.loadEntries();
+ }
+
+ /**
+ * Load guestbook entries from the API
+ */
+ async loadEntries(page = 1) {
+ this.currentPage = page;
+
+ // Show loading state
+ this.showLoading();
+
+ try {
+ const response = await fetch(
+ `${this.apiUrl}/entries?page=${page}&per_page=${this.perPage}`,
+ );
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ // Handle the actual API response structure
+ if (data.entries !== undefined) {
+ // API returns entries directly
+ this.renderEntries(data.entries || []);
+ this.totalPages = data.total_pages || 1;
+
+ // Create pagination object from the flat response
+ const pagination = {
+ current_page: data.page || 1,
+ total_pages: data.total_pages || 1,
+ total_entries: data.total || 0,
+ per_page: data.per_page || this.perPage
+ };
+ this.renderPagination(pagination);
+ } else if (data.success === false) {
+ // API returned an error
+ throw new Error(data.error || "Failed to load entries");
+ } else {
+ throw new Error("Unexpected API response format");
+ }
+ } catch (error) {
+ console.error("Error loading entries:", error);
+ this.showError();
+ }
+ }
+
+ /**
+ * Render entries to the DOM
+ */
+ renderEntries(entries) {
+ // Hide loading and error states
+ this.entriesLoading.style.display = "none";
+ this.entriesError.style.display = "none";
+ this.entriesList.style.display = "block";
+
+ if (entries.length === 0) {
+ this.entriesList.innerHTML = `
+
+
+
${this.escapeHtml(entry.message)}
+
+ `;
+ }
+
+ /**
+ * Render pagination controls
+ */
+ renderPagination(pagination) {
+ if (pagination.total_pages <= 1) {
+ this.pagination.style.display = "none";
+ return;
+ }
+
+ this.pagination.style.display = "flex";
+
+ const prevDisabled = pagination.current_page === 1 ? "disabled" : "";
+ const nextDisabled =
+ pagination.current_page === pagination.total_pages ? "disabled" : "";
+
+ let pagesHTML = "";
+
+ // Show page numbers (max 5)
+ const startPage = Math.max(1, pagination.current_page - 2);
+ const endPage = Math.min(pagination.total_pages, startPage + 4);
+
+ for (let i = startPage; i <= endPage; i++) {
+ const active = i === pagination.current_page ? "active" : "";
+ pagesHTML += `
+
+ `;
+ }
+
+ this.pagination.innerHTML = `
+
+
+ `;
+
+ // Attach click handlers to pagination buttons
+ this.pagination.querySelectorAll(".pagination-button").forEach((btn) => {
+ btn.addEventListener("click", (e) => {
+ const page = parseInt(e.target.dataset.page);
+ if (page && page !== pagination.current_page) {
+ this.loadEntries(page);
+ // Scroll to top of entries
+ document
+ .querySelector(".guestbook-entries-container")
+ .scrollIntoView({ behavior: "smooth" });
+ }
+ });
+ });
+ }
+
+ /**
+ * Handle form submission
+ */
+ async handleSubmit(e) {
+ e.preventDefault();
+
+ // Disable submit button
+ this.submitBtn.disabled = true;
+ this.submitBtn.querySelector(".button-text").textContent = "[ SENDING... ]";
+
+ // Clear previous feedback
+ this.formFeedback.className = "form-feedback";
+ this.formFeedback.textContent = "";
+
+ // Get form data
+ const formData = new FormData(this.form);
+ const data = {
+ name: formData.get("name"),
+ email: formData.get("email"),
+ website: formData.get("website"),
+ message: formData.get("message"),
+ };
+
+ try {
+ const response = await fetch(`${this.apiUrl}/submit`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(data),
+ });
+
+ const result = await response.json();
+
+ // Handle success response
+ if (result.success || response.ok) {
+ this.showSuccess(
+ result.message ||
+ "Entry submitted! It will appear after moderation. Thank you!",
+ );
+ this.form.reset();
+
+ // If entry was auto-approved, reload entries
+ if (result.status === "approved") {
+ setTimeout(() => this.loadEntries(1), 1000);
+ }
+ } else {
+ this.showFormError(result.error || result.message || "Failed to submit entry");
+ }
+ } catch (error) {
+ console.error("Error submitting entry:", error);
+ this.showFormError(
+ "Network error. Please check your connection and try again.",
+ );
+ } finally {
+ // Re-enable submit button
+ this.submitBtn.disabled = false;
+ this.submitBtn.querySelector(".button-text").textContent = "[ SUBMIT ]";
+ }
+ }
+
+ /**
+ * Show loading state
+ */
+ showLoading() {
+ this.entriesLoading.style.display = "block";
+ this.entriesError.style.display = "none";
+ this.entriesList.style.display = "none";
+ this.pagination.style.display = "none";
+ }
+
+ /**
+ * Show error state
+ */
+ showError() {
+ this.entriesLoading.style.display = "none";
+ this.entriesError.style.display = "block";
+ this.entriesList.style.display = "none";
+ this.pagination.style.display = "none";
+ }
+
+ /**
+ * Show form success message
+ */
+ showSuccess(message) {
+ this.formFeedback.className = "form-feedback success";
+ this.formFeedback.textContent = `SUCCESS: ${message}`;
+
+ // Auto-hide after 10 seconds
+ setTimeout(() => {
+ this.formFeedback.className = "form-feedback";
+ }, 10000);
+ }
+
+ /**
+ * Show form error message
+ */
+ showFormError(message) {
+ this.formFeedback.className = "form-feedback error";
+ this.formFeedback.textContent = `ERROR: ${message}`;
+
+ // Auto-hide after 8 seconds
+ setTimeout(() => {
+ this.formFeedback.className = "form-feedback";
+ }, 8000);
+ }
+
+ /**
+ * Format date to readable format
+ */
+ formatDate(dateString) {
+ if (!dateString) return "Unknown date";
+
+ const date = new Date(dateString);
+
+ // Check if date is valid
+ if (isNaN(date.getTime())) {
+ return "Unknown date";
+ }
+
+ const now = new Date();
+ const diffTime = Math.abs(now - date);
+ const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
+
+ if (diffDays === 0) {
+ return "Today";
+ } else if (diffDays === 1) {
+ return "Yesterday";
+ } else if (diffDays < 7) {
+ return `${diffDays} days ago`;
+ } else {
+ return date.toLocaleDateString("en-GB", {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ });
+ }
+ }
+
+ /**
+ * Format website URL for display (remove protocol)
+ */
+ formatWebsiteUrl(url) {
+ if (!url) return "";
+
+ try {
+ const urlObj = new URL(url);
+ // Return hostname + pathname, removing trailing slash
+ let display = urlObj.hostname + urlObj.pathname;
+ return this.escapeHtml(display.replace(/\/$/, ""));
+ } catch (e) {
+ // If URL parsing fails, just remove common protocols
+ return this.escapeHtml(
+ url.replace(/^https?:\/\//, "").replace(/\/$/, "")
+ );
+ }
+ }
+
+ /**
+ * Escape HTML to prevent XSS
+ */
+ escapeHtml(text) {
+ const div = document.createElement("div");
+ div.textContent = text;
+ return div.innerHTML;
+ }
+}
+
+// Initialize guestbook when DOM is ready
+if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", () => {
+ new GuestbookManager();
+ });
+} else {
+ new GuestbookManager();
+}
diff --git a/assets/sass/pages/guestbook.scss b/assets/sass/pages/guestbook.scss
new file mode 100644
index 0000000..b640a00
--- /dev/null
+++ b/assets/sass/pages/guestbook.scss
@@ -0,0 +1,336 @@
+@import "../mixins";
+
+.guestbook-page {
+ color: white;
+ margin: auto;
+
+ .content-screen {
+ position: relative !important;
+ }
+
+ > .guestbook-content {
+ width: 50%;
+ margin: auto;
+ padding: 2rem;
+ display: grid;
+ grid-template-columns: 1fr 2fr;
+ gap: 2rem;
+
+ @include media-down(lg) {
+ width: 100%;
+ grid-template-columns: 1fr;
+ }
+ > .wide-item {
+ grid-column: 1 / -1;
+ }
+
+ .guestbook-floppy {
+ margin: auto;
+ padding: 2rem;
+ width: 90%;
+ max-width: 300px;
+ transform: rotate(5deg);
+ }
+ }
+}
+
+// Terminal output styling
+.terminal-output {
+ font-family: monospace;
+ line-height: 1.6;
+
+ .info {
+ color: #0f0;
+ text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
+ }
+
+ .dim {
+ opacity: 0.7;
+ }
+
+ .warning {
+ color: #ff9900;
+ text-shadow: 0 0 5px rgba(255, 153, 0, 0.5);
+ }
+
+ .error-text {
+ color: #ff0000;
+ text-shadow: 0 0 5px rgba(255, 0, 0, 0.5);
+ }
+}
+
+// Guestbook Form
+.guestbook-form {
+ margin-top: 0.5rem;
+
+ .form-row {
+ margin-bottom: 0.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+
+ label {
+ font-family: monospace;
+ color: #0f0;
+ text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
+ font-size: 0.85rem;
+ }
+
+ input,
+ textarea {
+ background: rgba(0, 0, 0, 0.5);
+ border: 1px solid rgba(0, 255, 0, 0.3);
+ color: #0f0;
+ padding: 0.4rem;
+ font-family: monospace;
+ font-size: 0.85rem;
+ text-shadow: 0 0 3px rgba(0, 255, 0, 0.3);
+ border-radius: 2px;
+ outline: none;
+ transition: all 0.3s ease;
+
+ &::placeholder {
+ color: rgba(0, 255, 0, 0.4);
+ text-shadow: none;
+ }
+
+ &:focus {
+ border-color: rgba(0, 255, 0, 0.6);
+ box-shadow:
+ 0 0 10px rgba(0, 255, 0, 0.3),
+ inset 0 0 10px rgba(0, 255, 0, 0.1);
+ }
+ }
+
+ textarea {
+ resize: vertical;
+ min-height: 60px;
+ }
+ }
+
+ .form-feedback {
+ margin: 0.5rem 0;
+ padding: 0.5rem;
+ border-radius: 3px;
+ font-family: monospace;
+ font-size: 0.8rem;
+ display: none;
+
+ &.success {
+ display: block;
+ background: rgba(0, 255, 0, 0.1);
+ border: 1px solid rgba(0, 255, 0, 0.3);
+ color: #0f0;
+ text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
+ }
+
+ &.error {
+ display: block;
+ background: rgba(255, 0, 0, 0.1);
+ border: 1px solid rgba(255, 0, 0, 0.3);
+ color: #ff0000;
+ text-shadow: 0 0 5px rgba(255, 0, 0, 0.5);
+ }
+
+ &.pending {
+ display: block;
+ background: rgba(255, 153, 0, 0.1);
+ border: 1px solid rgba(255, 153, 0, 0.3);
+ color: #ff9900;
+ text-shadow: 0 0 5px rgba(255, 153, 0, 0.5);
+ }
+ }
+
+ .terminal-button {
+ background: rgba(0, 255, 0, 0.1);
+ border: 2px solid rgba(0, 255, 0, 0.4);
+ color: #0f0;
+ padding: 0.5rem 1rem;
+ font-family: monospace;
+ font-size: 0.9rem;
+ font-weight: bold;
+ cursor: pointer;
+ text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
+ transition: all 0.3s ease;
+ border-radius: 3px;
+ letter-spacing: 2px;
+
+ &:hover:not(:disabled) {
+ background: rgba(0, 255, 0, 0.2);
+ border-color: rgba(0, 255, 0, 0.7);
+ box-shadow:
+ 0 0 15px rgba(0, 255, 0, 0.4),
+ inset 0 0 10px rgba(0, 255, 0, 0.2);
+ text-shadow: 0 0 10px rgba(0, 255, 0, 0.8);
+ transform: translateY(-1px);
+ }
+
+ &:active:not(:disabled) {
+ transform: translateY(0);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ border-color: rgba(0, 255, 0, 0.2);
+ }
+ }
+}
+
+// Entries List
+.entries-list {
+ margin-top: 1rem;
+
+ .entry {
+ margin-bottom: 2rem;
+ padding-bottom: 1.5rem;
+ border-bottom: 1px solid rgba(0, 255, 0, 0.2);
+ font-family: monospace;
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ .entry-header {
+ margin-bottom: 0.5rem;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ align-items: baseline;
+
+ .entry-name {
+ color: #0f0;
+ font-weight: bold;
+ text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
+
+ a {
+ color: #0f0;
+ text-decoration: none;
+ border-bottom: 1px dotted rgba(0, 255, 0, 0.5);
+ transition: all 0.3s ease;
+
+ &:hover {
+ border-bottom-style: solid;
+ text-shadow: 0 0 10px rgba(0, 255, 0, 0.8);
+ }
+ }
+ }
+
+ .entry-website {
+ color: rgba(0, 255, 0, 0.6);
+ font-size: 0.85rem;
+ font-style: italic;
+ }
+
+ .entry-separator {
+ color: rgba(0, 255, 0, 0.4);
+ }
+
+ .entry-date {
+ color: rgba(0, 255, 0, 0.6);
+ font-size: 0.85rem;
+ margin-left: auto;
+ }
+ }
+
+ .entry-message {
+ color: #0f0;
+ line-height: 1.6;
+ opacity: 0.9;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ }
+ }
+}
+
+// Loading state
+.loading-state {
+ padding: 2rem;
+ text-align: center;
+ font-family: monospace;
+
+ .loading-text {
+ color: #0f0;
+ text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
+ }
+
+ .loading-dots {
+ color: #0f0;
+ animation: loading-blink 1.5s infinite;
+ }
+}
+
+@keyframes loading-blink {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.3;
+ }
+}
+
+// Error state
+.error-state {
+ padding: 2rem;
+ text-align: center;
+ font-family: monospace;
+}
+
+// Pagination
+.pagination {
+ margin-top: 2rem;
+ padding-top: 1.5rem;
+ border-top: 1px solid rgba(0, 255, 0, 0.2);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-family: monospace;
+ gap: 1rem;
+ flex-wrap: wrap;
+
+ .pagination-info {
+ color: rgba(0, 255, 0, 0.7);
+ font-size: 0.85rem;
+ }
+
+ .pagination-controls {
+ display: flex;
+ gap: 0.5rem;
+ }
+
+ .pagination-button {
+ background: rgba(0, 255, 0, 0.1);
+ border: 1px solid rgba(0, 255, 0, 0.3);
+ color: #0f0;
+ padding: 0.5rem 1rem;
+ font-family: monospace;
+ font-size: 0.85rem;
+ cursor: pointer;
+ text-shadow: 0 0 3px rgba(0, 255, 0, 0.5);
+ transition: all 0.3s ease;
+ border-radius: 2px;
+
+ &:hover:not(:disabled) {
+ background: rgba(0, 255, 0, 0.2);
+ border-color: rgba(0, 255, 0, 0.5);
+ box-shadow: 0 0 10px rgba(0, 255, 0, 0.3);
+ }
+
+ &:disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+ }
+
+ &.active {
+ background: rgba(0, 255, 0, 0.3);
+ border-color: rgba(0, 255, 0, 0.6);
+ font-weight: bold;
+ }
+ }
+}
+
+// Scrollbar customization for entries container
+.guestbook-entries-container .screen-display {
+ @include scrollbar-custom(#0f0, 5px);
+}
diff --git a/assets/sass/pages/homepage.scss b/assets/sass/pages/homepage.scss
index e06bfd3..b68cb33 100644
--- a/assets/sass/pages/homepage.scss
+++ b/assets/sass/pages/homepage.scss
@@ -171,7 +171,7 @@
grid-template-columns: 1fr 1fr;
@include media-up(lg) {
- grid-template-columns: repeat(5, 1fr);
+ grid-template-columns: repeat(6, 1fr);
position: absolute;
bottom: 5%;
width: 100%;
@@ -186,6 +186,46 @@
justify-content: center;
}
+ .nav-floppy {
+ width: 44%;
+ transform: rotate(5deg);
+ position: relative;
+
+ &:hover {
+ .nav-floppy-text {
+ opacity: 1;
+ }
+ }
+
+ .nav-floppy-text {
+ position: absolute;
+ display: block;
+ bottom: 0;
+ right: 0;
+ color: white;
+ font-size: 20px;
+ font-weight: bold;
+ z-index: 8000;
+ transform: rotate(10deg);
+ border: 1px solid #0f0;
+ padding: 2px;
+ padding-left: 5px;
+ padding-right: 5px;
+ border-radius: 5px;
+ background-color: rgba(0, 0, 0, 0.7);
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ text-align: center;
+
+ @include media-down(lg) {
+ opacity: 1;
+ //transform: rotate(0deg);
+ bottom: 0;
+ font-size: 14px;
+ }
+ }
+ }
+
.nav-cube {
width: 106.5px;
position: relative;
@@ -224,6 +264,10 @@
transition: opacity 0.3s ease;
text-align: center;
+ &:hover::after {
+ opacity: 1;
+ }
+
@include media-down(lg) {
opacity: 1;
//transform: rotate(0deg);
diff --git a/assets/sass/style.scss b/assets/sass/style.scss
index 527a2dc..8860c32 100644
--- a/assets/sass/style.scss
+++ b/assets/sass/style.scss
@@ -29,6 +29,7 @@
@import "pages/resources";
@import "pages/button-generator";
@import "pages/lavalamp-adoptable";
+@import "pages/guestbook";
@import url(https://fonts.bunny.net/css?family=abel:400|barlow-condensed:400,500|caveat:400|lato:300,300i,400,400i|neonderthaw:400);
diff --git a/content/guestbook.md b/content/guestbook.md
new file mode 100644
index 0000000..9c11eae
--- /dev/null
+++ b/content/guestbook.md
@@ -0,0 +1,11 @@
+---
+title: "Guestbook"
+author: Dan
+type: guestbook
+date: 2026-01-11
+comments: false
+---
+
+I'd love to hear from you if you've passed by my corner of the internet.
+
+If you want to say hi privately you can [email me](mailto:dan@ritual.sh) instead.
diff --git a/layouts/_default/baseof.html b/layouts/_default/baseof.html
index ea512a3..d7d98c5 100755
--- a/layouts/_default/baseof.html
+++ b/layouts/_default/baseof.html
@@ -45,5 +45,11 @@
{{ $lavalampAdoptable := resources.Get "js/adoptables/lavalamp-adoptable.js" | resources.Minify | resources.Fingerprint }}
{{ end }}
+
+
+ {{ if eq .Type "guestbook" }}
+ {{ $guestbook := resources.Get "js/guestbook.js" | resources.Minify | resources.Fingerprint }}
+
+ {{ end }}