Okay Wish I could say I know exactly. Will do better next time promise lol
This commit is contained in:
437
mkdocs/docs/assets/css/image-gallery.css
Normal file
437
mkdocs/docs/assets/css/image-gallery.css
Normal file
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* Image Gallery Styles for MkDocs
|
||||
*
|
||||
* Ensures photo blocks from landing pages render properly in documentation.
|
||||
* Mirrors video-player.css structure with green/blue photo theme.
|
||||
*/
|
||||
|
||||
/* Photo block container */
|
||||
.photo-block {
|
||||
margin: 2rem auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.photo-block img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .photo-block img {
|
||||
background: #1b2838;
|
||||
}
|
||||
|
||||
/* Photo caption */
|
||||
.photo-caption {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .photo-caption {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
/* Photo card block */
|
||||
.photo-card-block {
|
||||
margin: 2rem auto;
|
||||
max-width: 480px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.photo-card-block:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.photo-card-block a {
|
||||
text-decoration: none !important;
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
/* Photo card gallery button */
|
||||
.photo-card-gallery-btn:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
/* Photo album block */
|
||||
.photo-album-block {
|
||||
margin: 2rem auto;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.photo-album-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Photo album grid */
|
||||
.photo-album-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.photo-album-grid.cols-2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.photo-album-grid.cols-3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.photo-album-grid.cols-4 {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
/* Photo album cell */
|
||||
.photo-album-cell {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
background: #f0f0f0;
|
||||
aspect-ratio: 1;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.photo-album-cell:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .photo-album-cell {
|
||||
background: #1b2838;
|
||||
}
|
||||
|
||||
.photo-album-cell img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.photo-album-cell a {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Album "View full album" link */
|
||||
.photo-album-footer {
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.photo-album-footer a {
|
||||
color: #43cea2;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.photo-album-footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .photo-album-footer a {
|
||||
color: #43cea2;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.photo-loading {
|
||||
padding: 2.5rem;
|
||||
text-align: center;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .photo-loading {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
/* Error state */
|
||||
.photo-error {
|
||||
padding: 2.5rem;
|
||||
text-align: center;
|
||||
background: #fff3f3;
|
||||
border: 1px solid #ffccc7;
|
||||
border-radius: 8px;
|
||||
color: #cf1322;
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .photo-error {
|
||||
background: rgba(207, 19, 34, 0.1);
|
||||
border-color: rgba(207, 19, 34, 0.3);
|
||||
}
|
||||
|
||||
.photo-error svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.photo-error p {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Photo placeholder (shown during GrapesJS export before hydration) */
|
||||
.photo-placeholder {
|
||||
aspect-ratio: 3/2;
|
||||
background: linear-gradient(135deg, #43cea2 0%, #185a9d 100%);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: #fff;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.photo-placeholder svg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.photo-placeholder p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.photo-block,
|
||||
.photo-card-block,
|
||||
.photo-album-block {
|
||||
margin: 1.5rem auto;
|
||||
}
|
||||
|
||||
.photo-block img {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.photo-card-block {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.photo-album-grid.cols-4 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.photo-album-grid.cols-3 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.photo-album-grid.cols-4,
|
||||
.photo-album-grid.cols-3 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Photo Album Carousel ─────────────────────────────────────────── */
|
||||
|
||||
.photo-album-carousel {
|
||||
margin: 2rem auto;
|
||||
max-width: 900px;
|
||||
position: relative;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.carousel-viewport {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .carousel-viewport {
|
||||
background: #1b2838;
|
||||
}
|
||||
|
||||
.carousel-track {
|
||||
display: flex;
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
|
||||
.carousel-slide {
|
||||
flex: 0 0 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.carousel-slide a {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.carousel-slide img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 560px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* Navigation arrows */
|
||||
.carousel-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s, background 0.2s;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.carousel-viewport:hover .carousel-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.carousel-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
.carousel-btn-prev {
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
.carousel-btn-next {
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
/* Dot indicators */
|
||||
.carousel-dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 0 4px;
|
||||
}
|
||||
|
||||
.carousel-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.carousel-dot:hover {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.carousel-dot.active {
|
||||
background: #43cea2;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .carousel-dot {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .carousel-dot:hover {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .carousel-dot.active {
|
||||
background: #43cea2;
|
||||
}
|
||||
|
||||
/* Responsive: arrows always visible on mobile (no hover) */
|
||||
@media (max-width: 768px) {
|
||||
.carousel-btn {
|
||||
opacity: 1;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.carousel-btn-prev {
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
.carousel-btn-next {
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.carousel-slide img {
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.photo-album-carousel {
|
||||
margin: 1.5rem auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.carousel-slide img {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.carousel-dots {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.carousel-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure photos respect content width in Material theme */
|
||||
.md-content .photo-block,
|
||||
.md-content .photo-card-block,
|
||||
.md-content .photo-album-block,
|
||||
.md-content .photo-album-carousel {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Accessibility: Focus styles */
|
||||
.photo-album-cell a:focus,
|
||||
.photo-card-block a:focus {
|
||||
outline: 2px solid var(--md-primary-fg-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.carousel-btn:focus-visible {
|
||||
outline: 2px solid var(--md-primary-fg-color);
|
||||
outline-offset: 2px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.carousel-dot:focus-visible {
|
||||
outline: 2px solid var(--md-primary-fg-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.carousel-slide a:focus-visible {
|
||||
outline: 2px solid var(--md-primary-fg-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
611
mkdocs/docs/assets/js/image-gallery.js
Normal file
611
mkdocs/docs/assets/js/image-gallery.js
Normal file
@@ -0,0 +1,611 @@
|
||||
/**
|
||||
* Image Gallery Hydration for MkDocs
|
||||
*
|
||||
* Automatically converts .photo-block, .photo-card-block, and .photo-album-block
|
||||
* placeholders into actual images/galleries when landing pages with photo blocks
|
||||
* are exported to MkDocs.
|
||||
*
|
||||
* Uses the same MEDIA_API_URL and PUBLIC_URL globals as video-player.js
|
||||
* (injected by hooks/env_config_hook.py).
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Configuration — same globals as video-player.js
|
||||
var MEDIA_API_URL = window.MEDIA_API_URL || 'http://localhost:4100';
|
||||
var PUBLIC_URL = window.PUBLIC_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
function escapeHtml(unsafe) {
|
||||
return String(unsafe)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format view count (e.g. 1200 -> "1.2K views")
|
||||
*/
|
||||
function formatViewCount(count) {
|
||||
if (!count || count <= 0) return '0 views';
|
||||
if (count === 1) return '1 view';
|
||||
if (count < 1000) return count + ' views';
|
||||
if (count < 1000000) return (count / 1000).toFixed(1).replace(/\.0$/, '') + 'K views';
|
||||
return (count / 1000000).toFixed(1).replace(/\.0$/, '') + 'M views';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get gallery URL for a photo
|
||||
*/
|
||||
function getGalleryUrl(photoId) {
|
||||
return PUBLIC_URL + '/gallery?expanded=photo-' + photoId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error state on an element
|
||||
*/
|
||||
function showError(el, message) {
|
||||
el.innerHTML =
|
||||
'<div class="photo-error">' +
|
||||
'<svg fill="currentColor" viewBox="0 0 20 20">' +
|
||||
'<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />' +
|
||||
'</svg>' +
|
||||
'<p>' + escapeHtml(message) + '</p>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch photo metadata from Media API
|
||||
*/
|
||||
async function fetchPhotoMetadata(photoId) {
|
||||
try {
|
||||
var response = await fetch(MEDIA_API_URL + '/api/public/photos/' + photoId);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch photo ' + photoId + ': ' + response.statusText);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching photo ' + photoId + ' metadata:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch album data from Media API
|
||||
*/
|
||||
async function fetchAlbumData(albumId) {
|
||||
try {
|
||||
var response = await fetch(MEDIA_API_URL + '/api/public/albums/' + albumId);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch album ' + albumId + ': ' + response.statusText);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching album ' + albumId + ':', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Photo Block ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render a single photo block.
|
||||
* Replaces placeholder with an <img> tag, optional caption, and gallery link.
|
||||
*/
|
||||
async function renderPhotoBlock(el) {
|
||||
var photoId = parseInt(el.dataset.photoId, 10);
|
||||
var size = el.dataset.size || 'large';
|
||||
var caption = el.dataset.caption || '';
|
||||
var linkToGallery = el.dataset.linkToGallery !== 'false';
|
||||
var alignment = el.dataset.alignment || 'center';
|
||||
|
||||
if (!photoId || isNaN(photoId)) {
|
||||
showError(el, 'Invalid photo ID');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
el.innerHTML = '<div class="photo-loading">Loading photo...</div>';
|
||||
|
||||
// Fetch metadata for alt text and title
|
||||
var metadata = await fetchPhotoMetadata(photoId);
|
||||
if (!metadata) {
|
||||
showError(el, 'Photo not found (ID: ' + photoId + ')');
|
||||
return;
|
||||
}
|
||||
|
||||
var imgUrl = MEDIA_API_URL + '/api/public/photos/' + photoId + '/image?size=' + size;
|
||||
var altText = escapeHtml(metadata.title || 'Photo ' + photoId);
|
||||
|
||||
// Build image element
|
||||
var img = document.createElement('img');
|
||||
img.src = imgUrl;
|
||||
img.alt = altText;
|
||||
img.title = altText;
|
||||
img.style.width = '100%';
|
||||
img.style.height = 'auto';
|
||||
img.style.borderRadius = '8px';
|
||||
img.style.display = 'block';
|
||||
|
||||
// Clear and rebuild
|
||||
el.innerHTML = '';
|
||||
el.style.textAlign = alignment;
|
||||
|
||||
if (linkToGallery) {
|
||||
var link = document.createElement('a');
|
||||
link.href = getGalleryUrl(photoId);
|
||||
link.target = '_blank';
|
||||
link.appendChild(img);
|
||||
el.appendChild(link);
|
||||
} else {
|
||||
el.appendChild(img);
|
||||
}
|
||||
|
||||
// Add caption if provided (from data attribute or metadata)
|
||||
var captionText = caption || metadata.description || '';
|
||||
if (captionText) {
|
||||
var captionEl = document.createElement('div');
|
||||
captionEl.className = 'photo-caption';
|
||||
captionEl.textContent = captionText;
|
||||
el.appendChild(captionEl);
|
||||
}
|
||||
|
||||
console.log('Rendered photo block for photo ' + photoId);
|
||||
}
|
||||
|
||||
// ─── Photo Card ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hydrate a photo card block:
|
||||
* 1. Fix thumbnail URL using MEDIA_API_URL
|
||||
* 2. Fetch live metadata (view count)
|
||||
* 3. Mark as hydrated
|
||||
*/
|
||||
async function hydratePhotoCard(cardEl) {
|
||||
var photoId = parseInt(cardEl.dataset.photoId, 10);
|
||||
if (!photoId || isNaN(photoId)) return;
|
||||
|
||||
// 1. Fix thumbnail URL
|
||||
var img = cardEl.querySelector('img');
|
||||
if (img) {
|
||||
var correctThumbUrl = MEDIA_API_URL + '/api/public/photos/' + photoId + '/thumbnail?v=' + Date.now();
|
||||
img.src = correctThumbUrl;
|
||||
img.onerror = function() {
|
||||
img.style.display = 'none';
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Fetch live metadata to update views
|
||||
try {
|
||||
var metadata = await fetchPhotoMetadata(photoId);
|
||||
if (metadata) {
|
||||
// Update view count if rendered in card
|
||||
var viewsEl = cardEl.querySelector('[data-role="views"]');
|
||||
if (viewsEl && metadata.viewCount !== undefined) {
|
||||
viewsEl.textContent = formatViewCount(metadata.viewCount);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Non-critical
|
||||
}
|
||||
|
||||
// 3. Fix "View →" link to use correct gallery URL
|
||||
var link = cardEl.querySelector('a');
|
||||
if (link) {
|
||||
link.href = getGalleryUrl(photoId);
|
||||
link.target = '_blank';
|
||||
}
|
||||
|
||||
// 4. Replace "View →" text with gallery button
|
||||
var infoBar = cardEl.querySelector('a > div:last-child');
|
||||
if (infoBar) {
|
||||
var flexRow = infoBar.querySelector('div:last-child');
|
||||
if (flexRow) {
|
||||
var spans = flexRow.querySelectorAll('span');
|
||||
var viewSpan = null;
|
||||
for (var i = 0; i < spans.length; i++) {
|
||||
if (spans[i].textContent.indexOf('View') !== -1) {
|
||||
viewSpan = spans[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (viewSpan) {
|
||||
var galleryLink = document.createElement('a');
|
||||
galleryLink.href = getGalleryUrl(photoId);
|
||||
galleryLink.target = '_blank';
|
||||
galleryLink.innerHTML = '📷 Gallery';
|
||||
galleryLink.className = 'photo-card-gallery-btn';
|
||||
galleryLink.style.cssText = 'background:#43cea2;border:none;color:#fff;font-size:12px;font-weight:600;padding:5px 14px;border-radius:4px;cursor:pointer;transition:background 0.15s;text-decoration:none;display:inline-block;';
|
||||
galleryLink.title = 'Open in gallery';
|
||||
galleryLink.onmouseenter = function() { galleryLink.style.background = '#5dd8b0'; };
|
||||
galleryLink.onmouseleave = function() { galleryLink.style.background = '#43cea2'; };
|
||||
galleryLink.addEventListener('click', function(ev) {
|
||||
ev.stopPropagation();
|
||||
});
|
||||
viewSpan.replaceWith(galleryLink);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as hydrated
|
||||
cardEl.dataset.hydrated = 'true';
|
||||
console.log('Hydrated photo card for photo ' + photoId);
|
||||
}
|
||||
|
||||
// ─── Photo Album ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render a photo album block.
|
||||
* Fetches album data and builds a responsive grid of thumbnails.
|
||||
*/
|
||||
async function renderAlbumBlock(el) {
|
||||
var albumId = parseInt(el.dataset.albumId, 10);
|
||||
var columns = parseInt(el.dataset.columns, 10) || 3;
|
||||
var maxPhotos = parseInt(el.dataset.maxPhotos, 10) || 12;
|
||||
var showTitle = el.dataset.showTitle !== 'false';
|
||||
|
||||
if (!albumId || isNaN(albumId)) {
|
||||
showError(el, 'Invalid album ID');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
el.innerHTML = '<div class="photo-loading">Loading album...</div>';
|
||||
|
||||
// Fetch album data
|
||||
var album = await fetchAlbumData(albumId);
|
||||
if (!album) {
|
||||
showError(el, 'Album not found (ID: ' + albumId + ')');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build album content
|
||||
el.innerHTML = '';
|
||||
|
||||
// Album title
|
||||
if (showTitle && album.title) {
|
||||
var titleEl = document.createElement('h3');
|
||||
titleEl.className = 'photo-album-title';
|
||||
titleEl.textContent = album.title;
|
||||
el.appendChild(titleEl);
|
||||
}
|
||||
|
||||
// Photo grid
|
||||
var photos = (album.photos || []).slice(0, maxPhotos);
|
||||
if (photos.length === 0) {
|
||||
el.innerHTML += '<div class="photo-loading">This album has no photos yet.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var grid = document.createElement('div');
|
||||
grid.className = 'photo-album-grid cols-' + columns;
|
||||
|
||||
for (var i = 0; i < photos.length; i++) {
|
||||
var photo = photos[i];
|
||||
var cell = document.createElement('div');
|
||||
cell.className = 'photo-album-cell';
|
||||
|
||||
var cellLink = document.createElement('a');
|
||||
cellLink.href = getGalleryUrl(photo.id);
|
||||
cellLink.target = '_blank';
|
||||
cellLink.title = photo.title || 'Photo ' + photo.id;
|
||||
|
||||
var cellImg = document.createElement('img');
|
||||
cellImg.src = MEDIA_API_URL + '/api/public/photos/' + photo.id + '/thumbnail';
|
||||
cellImg.alt = photo.title || 'Photo ' + photo.id;
|
||||
cellImg.loading = 'lazy';
|
||||
|
||||
cellLink.appendChild(cellImg);
|
||||
cell.appendChild(cellLink);
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
el.appendChild(grid);
|
||||
|
||||
// "View full album" footer
|
||||
var footer = document.createElement('div');
|
||||
footer.className = 'photo-album-footer';
|
||||
var footerLink = document.createElement('a');
|
||||
footerLink.href = PUBLIC_URL + '/gallery?album=' + albumId;
|
||||
footerLink.target = '_blank';
|
||||
footerLink.textContent = 'View full album \u2192';
|
||||
footer.appendChild(footerLink);
|
||||
el.appendChild(footer);
|
||||
|
||||
console.log('Rendered album block for album ' + albumId + ' (' + photos.length + ' photos)');
|
||||
}
|
||||
|
||||
// ─── Photo Album Carousel ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render a photo album carousel.
|
||||
* Fetches album data and builds a swipeable slideshow with navigation.
|
||||
*/
|
||||
async function renderCarouselBlock(el) {
|
||||
var albumId = parseInt(el.dataset.albumId, 10);
|
||||
var maxPhotos = parseInt(el.dataset.maxPhotos, 10) || 20;
|
||||
var showTitle = el.dataset.showTitle !== 'false';
|
||||
var autoPlayEnabled = el.dataset.autoPlay === 'true';
|
||||
|
||||
if (!albumId || isNaN(albumId)) {
|
||||
showError(el, 'Invalid album ID');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
el.innerHTML = '<div class="photo-loading">Loading carousel...</div>';
|
||||
|
||||
// Fetch album data
|
||||
var album = await fetchAlbumData(albumId);
|
||||
if (!album) {
|
||||
showError(el, 'Album not found (ID: ' + albumId + ')');
|
||||
return;
|
||||
}
|
||||
|
||||
var photos = (album.photos || []).slice(0, maxPhotos);
|
||||
if (photos.length === 0) {
|
||||
el.innerHTML = '<div class="photo-loading">This album has no photos yet.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Build carousel
|
||||
el.innerHTML = '';
|
||||
el.classList.add('carousel-initialized');
|
||||
|
||||
// Album title
|
||||
if (showTitle && album.title) {
|
||||
var titleEl = document.createElement('h3');
|
||||
titleEl.className = 'photo-album-title';
|
||||
titleEl.textContent = album.title;
|
||||
el.appendChild(titleEl);
|
||||
}
|
||||
|
||||
// Viewport + track
|
||||
var viewport = document.createElement('div');
|
||||
viewport.className = 'carousel-viewport';
|
||||
|
||||
var track = document.createElement('div');
|
||||
track.className = 'carousel-track';
|
||||
|
||||
for (var i = 0; i < photos.length; i++) {
|
||||
var photo = photos[i];
|
||||
var slide = document.createElement('div');
|
||||
slide.className = 'carousel-slide';
|
||||
|
||||
var slideLink = document.createElement('a');
|
||||
slideLink.href = getGalleryUrl(photo.id);
|
||||
slideLink.target = '_blank';
|
||||
slideLink.title = photo.title || 'Photo ' + photo.id;
|
||||
|
||||
var slideImg = document.createElement('img');
|
||||
slideImg.src = MEDIA_API_URL + '/api/public/photos/' + photo.id + '/image?size=large';
|
||||
slideImg.alt = photo.title || 'Photo ' + photo.id;
|
||||
slideImg.loading = i === 0 ? 'eager' : 'lazy';
|
||||
|
||||
slideLink.appendChild(slideImg);
|
||||
slide.appendChild(slideLink);
|
||||
track.appendChild(slide);
|
||||
}
|
||||
|
||||
viewport.appendChild(track);
|
||||
el.appendChild(viewport);
|
||||
|
||||
// Navigation buttons (only if > 1 photo)
|
||||
if (photos.length > 1) {
|
||||
var prevBtn = document.createElement('button');
|
||||
prevBtn.className = 'carousel-btn carousel-btn-prev';
|
||||
prevBtn.setAttribute('aria-label', 'Previous photo');
|
||||
prevBtn.innerHTML = '❮';
|
||||
viewport.appendChild(prevBtn);
|
||||
|
||||
var nextBtn = document.createElement('button');
|
||||
nextBtn.className = 'carousel-btn carousel-btn-next';
|
||||
nextBtn.setAttribute('aria-label', 'Next photo');
|
||||
nextBtn.innerHTML = '❯';
|
||||
viewport.appendChild(nextBtn);
|
||||
|
||||
// Dot indicators
|
||||
var dotsWrap = document.createElement('div');
|
||||
dotsWrap.className = 'carousel-dots';
|
||||
for (var d = 0; d < photos.length; d++) {
|
||||
var dot = document.createElement('button');
|
||||
dot.className = 'carousel-dot' + (d === 0 ? ' active' : '');
|
||||
dot.setAttribute('aria-label', 'Go to photo ' + (d + 1));
|
||||
dot.dataset.index = String(d);
|
||||
dotsWrap.appendChild(dot);
|
||||
}
|
||||
el.appendChild(dotsWrap);
|
||||
|
||||
// Carousel state
|
||||
var currentIndex = 0;
|
||||
var totalSlides = photos.length;
|
||||
var autoPlayTimer = null;
|
||||
|
||||
function goTo(idx) {
|
||||
if (idx < 0) idx = totalSlides - 1;
|
||||
if (idx >= totalSlides) idx = 0;
|
||||
currentIndex = idx;
|
||||
track.style.transform = 'translateX(-' + (currentIndex * 100) + '%)';
|
||||
// Update dots
|
||||
var dots = dotsWrap.querySelectorAll('.carousel-dot');
|
||||
for (var j = 0; j < dots.length; j++) {
|
||||
dots[j].classList.toggle('active', j === currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
prevBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
goTo(currentIndex - 1);
|
||||
resetAutoPlay();
|
||||
});
|
||||
|
||||
nextBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
goTo(currentIndex + 1);
|
||||
resetAutoPlay();
|
||||
});
|
||||
|
||||
dotsWrap.addEventListener('click', function(e) {
|
||||
var target = e.target;
|
||||
if (target.classList.contains('carousel-dot')) {
|
||||
goTo(parseInt(target.dataset.index, 10));
|
||||
resetAutoPlay();
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
el.setAttribute('tabindex', '0');
|
||||
el.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
goTo(currentIndex - 1);
|
||||
resetAutoPlay();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
goTo(currentIndex + 1);
|
||||
resetAutoPlay();
|
||||
}
|
||||
});
|
||||
|
||||
// Touch/swipe support
|
||||
var touchStartX = 0;
|
||||
var touchDeltaX = 0;
|
||||
viewport.addEventListener('touchstart', function(e) {
|
||||
touchStartX = e.touches[0].clientX;
|
||||
touchDeltaX = 0;
|
||||
}, { passive: true });
|
||||
viewport.addEventListener('touchmove', function(e) {
|
||||
touchDeltaX = e.touches[0].clientX - touchStartX;
|
||||
}, { passive: true });
|
||||
viewport.addEventListener('touchend', function(e) {
|
||||
if (Math.abs(touchDeltaX) > 50) {
|
||||
e.preventDefault(); // suppress follow-up click from swipe
|
||||
if (touchDeltaX < 0) goTo(currentIndex + 1);
|
||||
else goTo(currentIndex - 1);
|
||||
resetAutoPlay();
|
||||
}
|
||||
touchDeltaX = 0;
|
||||
});
|
||||
|
||||
// Auto-play
|
||||
function startAutoPlay() {
|
||||
if (!autoPlayEnabled) return;
|
||||
stopAutoPlay();
|
||||
autoPlayTimer = setInterval(function() {
|
||||
goTo(currentIndex + 1);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function stopAutoPlay() {
|
||||
if (autoPlayTimer) {
|
||||
clearInterval(autoPlayTimer);
|
||||
autoPlayTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function resetAutoPlay() {
|
||||
stopAutoPlay();
|
||||
startAutoPlay();
|
||||
}
|
||||
|
||||
// Pause auto-play on hover
|
||||
el.addEventListener('mouseenter', stopAutoPlay);
|
||||
el.addEventListener('mouseleave', startAutoPlay);
|
||||
|
||||
startAutoPlay();
|
||||
}
|
||||
|
||||
// "View full album" footer
|
||||
var footer = document.createElement('div');
|
||||
footer.className = 'photo-album-footer';
|
||||
var footerLink = document.createElement('a');
|
||||
footerLink.href = PUBLIC_URL + '/gallery?album=' + albumId;
|
||||
footerLink.target = '_blank';
|
||||
footerLink.textContent = 'View full album \u2192';
|
||||
footer.appendChild(footerLink);
|
||||
el.appendChild(footer);
|
||||
|
||||
console.log('Rendered carousel for album ' + albumId + ' (' + photos.length + ' photos)');
|
||||
}
|
||||
|
||||
// ─── Initialization ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Initialize all photo carousel blocks on the page
|
||||
*/
|
||||
function initPhotoCarousels() {
|
||||
var carousels = document.querySelectorAll('.photo-album-carousel');
|
||||
console.log('Found ' + carousels.length + ' photo carousel(s) to hydrate');
|
||||
carousels.forEach(function(carousel) {
|
||||
if (carousel.classList.contains('carousel-initialized')) return; // Already rendered
|
||||
renderCarouselBlock(carousel);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all photo blocks on the page
|
||||
*/
|
||||
function initPhotoBlocks() {
|
||||
var blocks = document.querySelectorAll('.photo-block');
|
||||
console.log('Found ' + blocks.length + ' photo block(s) to hydrate');
|
||||
blocks.forEach(function(block) {
|
||||
if (block.querySelector('img')) return; // Already rendered
|
||||
renderPhotoBlock(block);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all photo card blocks on the page
|
||||
*/
|
||||
function initPhotoCards() {
|
||||
var cards = document.querySelectorAll('.photo-card-block');
|
||||
console.log('Found ' + cards.length + ' photo card(s) to hydrate');
|
||||
cards.forEach(function(card) {
|
||||
if (card.dataset.hydrated === 'true') return;
|
||||
hydratePhotoCard(card);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all photo album blocks on the page
|
||||
*/
|
||||
function initPhotoAlbums() {
|
||||
var albums = document.querySelectorAll('.photo-album-block');
|
||||
console.log('Found ' + albums.length + ' photo album(s) to hydrate');
|
||||
albums.forEach(function(album) {
|
||||
if (album.querySelector('.photo-album-grid')) return; // Already rendered
|
||||
renderAlbumBlock(album);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all photo elements (blocks + cards + albums + carousels)
|
||||
*/
|
||||
function initAll() {
|
||||
initPhotoBlocks();
|
||||
initPhotoCards();
|
||||
initPhotoAlbums();
|
||||
initPhotoCarousels();
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initAll);
|
||||
} else {
|
||||
initAll();
|
||||
}
|
||||
|
||||
// Re-initialize on MkDocs SPA navigation
|
||||
if (typeof window.document$ !== 'undefined') {
|
||||
window.document$.subscribe(function() {
|
||||
console.log('MkDocs navigation detected, re-initializing photo elements');
|
||||
setTimeout(initAll, 100);
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -7,10 +7,10 @@
|
||||
"stars_count": 0,
|
||||
"forks_count": 0,
|
||||
"open_issues_count": 23,
|
||||
"updated_at": "2026-02-18T10:02:02-07:00",
|
||||
"updated_at": "2026-02-21T11:52:28-07:00",
|
||||
"created_at": "2025-05-28T14:54:59-06:00",
|
||||
"clone_url": "https://gitea.bnkops.com/admin/changemaker.lite.git",
|
||||
"ssh_url": "git@gitea.bnkops.com:admin/changemaker.lite.git",
|
||||
"default_branch": "main",
|
||||
"last_build_update": "2026-02-18T10:02:02-07:00"
|
||||
"last_build_update": "2026-02-21T11:52:28-07:00"
|
||||
}
|
||||
@@ -4,13 +4,13 @@
|
||||
"description": "Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.",
|
||||
"html_url": "https://github.com/anthropics/claude-code",
|
||||
"language": "Shell",
|
||||
"stars_count": 67570,
|
||||
"forks_count": 5278,
|
||||
"open_issues_count": 6297,
|
||||
"updated_at": "2026-02-18T18:41:22Z",
|
||||
"stars_count": 70476,
|
||||
"forks_count": 5568,
|
||||
"open_issues_count": 6924,
|
||||
"updated_at": "2026-02-26T14:25:29Z",
|
||||
"created_at": "2025-02-22T17:41:21Z",
|
||||
"clone_url": "https://github.com/anthropics/claude-code.git",
|
||||
"ssh_url": "git@github.com:anthropics/claude-code.git",
|
||||
"default_branch": "main",
|
||||
"last_build_update": "2026-02-17T18:53:52Z"
|
||||
"last_build_update": "2026-02-26T12:05:37Z"
|
||||
}
|
||||
@@ -4,13 +4,13 @@
|
||||
"description": "VS Code in the browser",
|
||||
"html_url": "https://github.com/coder/code-server",
|
||||
"language": "TypeScript",
|
||||
"stars_count": 76286,
|
||||
"forks_count": 6513,
|
||||
"open_issues_count": 177,
|
||||
"updated_at": "2026-02-18T17:58:26Z",
|
||||
"stars_count": 76383,
|
||||
"forks_count": 6527,
|
||||
"open_issues_count": 184,
|
||||
"updated_at": "2026-02-26T13:37:54Z",
|
||||
"created_at": "2019-02-27T16:50:41Z",
|
||||
"clone_url": "https://github.com/coder/code-server.git",
|
||||
"ssh_url": "git@github.com:coder/code-server.git",
|
||||
"default_branch": "main",
|
||||
"last_build_update": "2026-02-14T12:44:57Z"
|
||||
"last_build_update": "2026-02-25T22:36:16Z"
|
||||
}
|
||||
@@ -4,13 +4,13 @@
|
||||
"description": "A highly customizable homepage (or startpage / application dashboard) with Docker and service API integrations.",
|
||||
"html_url": "https://github.com/gethomepage/homepage",
|
||||
"language": "JavaScript",
|
||||
"stars_count": 28459,
|
||||
"forks_count": 1789,
|
||||
"stars_count": 28611,
|
||||
"forks_count": 1795,
|
||||
"open_issues_count": 1,
|
||||
"updated_at": "2026-02-18T17:08:54Z",
|
||||
"updated_at": "2026-02-26T12:19:48Z",
|
||||
"created_at": "2022-08-24T07:29:42Z",
|
||||
"clone_url": "https://github.com/gethomepage/homepage.git",
|
||||
"ssh_url": "git@github.com:gethomepage/homepage.git",
|
||||
"default_branch": "dev",
|
||||
"last_build_update": "2026-02-18T12:23:05Z"
|
||||
"last_build_update": "2026-02-26T12:25:33Z"
|
||||
}
|
||||
@@ -4,13 +4,13 @@
|
||||
"description": "Git with a cup of tea! Painless self-hosted all-in-one software development service, including Git hosting, code review, team collaboration, package registry and CI/CD",
|
||||
"html_url": "https://github.com/go-gitea/gitea",
|
||||
"language": "Go",
|
||||
"stars_count": 53764,
|
||||
"forks_count": 6394,
|
||||
"open_issues_count": 2832,
|
||||
"updated_at": "2026-02-18T18:54:21Z",
|
||||
"stars_count": 53910,
|
||||
"forks_count": 6399,
|
||||
"open_issues_count": 2843,
|
||||
"updated_at": "2026-02-26T13:59:20Z",
|
||||
"created_at": "2016-11-01T02:13:26Z",
|
||||
"clone_url": "https://github.com/go-gitea/gitea.git",
|
||||
"ssh_url": "git@github.com:go-gitea/gitea.git",
|
||||
"default_branch": "main",
|
||||
"last_build_update": "2026-02-18T07:31:28Z"
|
||||
"last_build_update": "2026-02-26T11:57:03Z"
|
||||
}
|
||||
@@ -4,13 +4,13 @@
|
||||
"description": "High performance, self-hosted, newsletter and mailing list manager with a modern dashboard. Single binary app.",
|
||||
"html_url": "https://github.com/knadh/listmonk",
|
||||
"language": "Go",
|
||||
"stars_count": 19078,
|
||||
"forks_count": 1928,
|
||||
"stars_count": 19131,
|
||||
"forks_count": 1936,
|
||||
"open_issues_count": 113,
|
||||
"updated_at": "2026-02-18T10:42:44Z",
|
||||
"updated_at": "2026-02-26T12:29:54Z",
|
||||
"created_at": "2019-06-26T05:08:39Z",
|
||||
"clone_url": "https://github.com/knadh/listmonk.git",
|
||||
"ssh_url": "git@github.com:knadh/listmonk.git",
|
||||
"default_branch": "master",
|
||||
"last_build_update": "2026-02-17T05:10:24Z"
|
||||
"last_build_update": "2026-02-26T13:26:07Z"
|
||||
}
|
||||
@@ -4,10 +4,10 @@
|
||||
"description": "Create & scan cute qr codes easily \ud83d\udc7e",
|
||||
"html_url": "https://github.com/lyqht/mini-qr",
|
||||
"language": "Vue",
|
||||
"stars_count": 1852,
|
||||
"forks_count": 236,
|
||||
"open_issues_count": 21,
|
||||
"updated_at": "2026-02-17T22:05:48Z",
|
||||
"stars_count": 1878,
|
||||
"forks_count": 240,
|
||||
"open_issues_count": 24,
|
||||
"updated_at": "2026-02-26T06:04:32Z",
|
||||
"created_at": "2023-04-21T14:20:14Z",
|
||||
"clone_url": "https://github.com/lyqht/mini-qr.git",
|
||||
"ssh_url": "git@github.com:lyqht/mini-qr.git",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"description": "Fair-code workflow automation platform with native AI capabilities. Combine visual building with custom code, self-host or cloud, 400+ integrations.",
|
||||
"html_url": "https://github.com/n8n-io/n8n",
|
||||
"language": "TypeScript",
|
||||
"stars_count": 175133,
|
||||
"forks_count": 54959,
|
||||
"open_issues_count": 1379,
|
||||
"updated_at": "2026-02-18T18:44:06Z",
|
||||
"stars_count": 176496,
|
||||
"forks_count": 55212,
|
||||
"open_issues_count": 1408,
|
||||
"updated_at": "2026-02-26T14:25:19Z",
|
||||
"created_at": "2019-06-22T09:24:21Z",
|
||||
"clone_url": "https://github.com/n8n-io/n8n.git",
|
||||
"ssh_url": "git@github.com:n8n-io/n8n.git",
|
||||
"default_branch": "master",
|
||||
"last_build_update": "2026-02-18T18:45:01Z"
|
||||
"last_build_update": "2026-02-26T14:25:24Z"
|
||||
}
|
||||
@@ -4,13 +4,13 @@
|
||||
"description": "\ud83d\udd25 \ud83d\udd25 \ud83d\udd25 A Free & Self-hostable Airtable Alternative",
|
||||
"html_url": "https://github.com/nocodb/nocodb",
|
||||
"language": "TypeScript",
|
||||
"stars_count": 62055,
|
||||
"forks_count": 4637,
|
||||
"open_issues_count": 595,
|
||||
"updated_at": "2026-02-18T18:47:12Z",
|
||||
"stars_count": 62217,
|
||||
"forks_count": 4647,
|
||||
"open_issues_count": 602,
|
||||
"updated_at": "2026-02-25T22:03:40Z",
|
||||
"created_at": "2017-10-29T18:51:48Z",
|
||||
"clone_url": "https://github.com/nocodb/nocodb.git",
|
||||
"ssh_url": "git@github.com:nocodb/nocodb.git",
|
||||
"default_branch": "develop",
|
||||
"last_build_update": "2026-02-18T17:46:29Z"
|
||||
"last_build_update": "2026-02-25T19:01:33Z"
|
||||
}
|
||||
@@ -4,13 +4,13 @@
|
||||
"description": "Get up and running with Kimi-K2.5, GLM-5, MiniMax, DeepSeek, gpt-oss, Qwen, Gemma and other models.",
|
||||
"html_url": "https://github.com/ollama/ollama",
|
||||
"language": "Go",
|
||||
"stars_count": 162853,
|
||||
"forks_count": 14611,
|
||||
"open_issues_count": 2427,
|
||||
"updated_at": "2026-02-18T18:55:27Z",
|
||||
"stars_count": 163469,
|
||||
"forks_count": 14678,
|
||||
"open_issues_count": 2486,
|
||||
"updated_at": "2026-02-26T14:21:37Z",
|
||||
"created_at": "2023-06-26T19:39:32Z",
|
||||
"clone_url": "https://github.com/ollama/ollama.git",
|
||||
"ssh_url": "git@github.com:ollama/ollama.git",
|
||||
"default_branch": "main",
|
||||
"last_build_update": "2026-02-18T02:18:06Z"
|
||||
"last_build_update": "2026-02-26T03:28:54Z"
|
||||
}
|
||||
@@ -4,13 +4,13 @@
|
||||
"description": "Documentation that simply works",
|
||||
"html_url": "https://github.com/squidfunk/mkdocs-material",
|
||||
"language": "Python",
|
||||
"stars_count": 26074,
|
||||
"forks_count": 4044,
|
||||
"stars_count": 26122,
|
||||
"forks_count": 4047,
|
||||
"open_issues_count": 2,
|
||||
"updated_at": "2026-02-18T16:48:45Z",
|
||||
"updated_at": "2026-02-26T12:52:29Z",
|
||||
"created_at": "2016-01-28T22:09:23Z",
|
||||
"clone_url": "https://github.com/squidfunk/mkdocs-material.git",
|
||||
"ssh_url": "git@github.com:squidfunk/mkdocs-material.git",
|
||||
"default_branch": "master",
|
||||
"last_build_update": "2026-02-18T15:52:24Z"
|
||||
"last_build_update": "2026-02-24T12:06:19Z"
|
||||
}
|
||||
26
mkdocs/docs/comments/callback.md
Normal file
26
mkdocs/docs/comments/callback.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
title: Signing in...
|
||||
comments: false
|
||||
template: main.html
|
||||
hide:
|
||||
- navigation
|
||||
- toc
|
||||
- footer
|
||||
---
|
||||
|
||||
<div id="oauth-callback-status" style="text-align: center; padding: 3rem 1rem;">
|
||||
<p style="font-size: 1.1rem;">Completing sign in...</p>
|
||||
<p style="color: var(--md-default-fg-color--light); font-size: 0.875rem;">You will be redirected back to the page you were on.</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// The docs-comments.js widget handles the OAuth callback automatically.
|
||||
// This page just provides a loading state while the code exchange happens.
|
||||
// If JS is disabled or the exchange fails, show a fallback message.
|
||||
setTimeout(function() {
|
||||
var el = document.getElementById('oauth-callback-status');
|
||||
if (el && !sessionStorage.getItem('docs-comment-gitea-token')) {
|
||||
el.innerHTML = '<p>Sign in failed or timed out.</p><p><a href="/">Return to documentation</a></p>';
|
||||
}
|
||||
}, 10000);
|
||||
</script>
|
||||
90
mkdocs/docs/docs/admin/advocacy/campaigns.md
Normal file
90
mkdocs/docs/docs/admin/advocacy/campaigns.md
Normal file
@@ -0,0 +1,90 @@
|
||||
---
|
||||
title: Advocacy Campaigns
|
||||
description: Help supporters contact elected representatives through email campaigns with postal code lookup and response tracking.
|
||||
icon: material/email-fast
|
||||
---
|
||||
|
||||
# Advocacy Campaigns
|
||||
|
||||
Help supporters contact their elected representatives through email campaigns.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **An admin creates a campaign** — writes the email subject and body, selects which government levels to target (federal, provincial, municipal, school board), and publishes it.
|
||||
2. **A supporter visits the campaign page** — enters their postal code to look up their representatives.
|
||||
3. **The supporter sends the email** — either directly through the platform ("Send Now") or by opening it in their own email app (Gmail, Outlook, etc.).
|
||||
4. **Responses get tracked** — supporters and admins can share representative responses on the Response Wall, with upvoting and moderation.
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Postal code lookup** — powered by the Represent API, returns representatives at all government levels
|
||||
- **Two send methods** — server-sent SMTP (tracked) or mailto link (opens user's email app)
|
||||
- **Email editing** — optionally let supporters personalize the email before sending
|
||||
- **Response Wall** — public wall where people share how their representatives responded, with moderation and verification
|
||||
- **Campaign stats** — track emails sent, responses received, and upvotes
|
||||
- **Featured campaigns** — highlight important campaigns on the public listing page
|
||||
|
||||
---
|
||||
|
||||
## User-Submitted Campaigns
|
||||
|
||||
Registered (non-temporary) users can create their own advocacy campaigns and submit them for admin review.
|
||||
|
||||
- **Public submission route** — users visit `/campaigns/create` to draft a campaign through a guided wizard
|
||||
- **3-step wizard** — the submission flow walks users through campaign details (title, description, government levels), email template (subject and body), and a final review step before submitting
|
||||
- **My campaigns dashboard** — users can view and manage their submitted campaigns at `/campaigns/mine`, including checking moderation status and editing campaigns that have been sent back for changes
|
||||
- **Restricted fields** — user-submitted campaigns have limited options compared to admin-created ones (no SMTP sending, no highlight, no custom recipients); only the mailto link fallback is enabled by default
|
||||
- **Auto-moderation status** — newly submitted campaigns start in `PENDING_REVIEW` status and remain in `DRAFT` until an admin approves them
|
||||
- **Edit restrictions** — users can only edit their own campaigns, and only when the moderation status is `PENDING_REVIEW` or `CHANGES_REQUESTED`; editing automatically resets the status back to `PENDING_REVIEW`
|
||||
- **Rate limiting** — campaign submissions are rate-limited to 5 per hour per IP to prevent abuse
|
||||
- **XSS protection** — all user-supplied text (title, description, email subject, email body) is HTML-escaped before storage
|
||||
|
||||
---
|
||||
|
||||
## Campaign Moderation
|
||||
|
||||
Admins review user-submitted campaigns before they go live.
|
||||
|
||||
- **Moderation queue** — accessible at `/app/campaign-moderation`, showing all user-generated campaigns filtered by moderation status (pending, approved, rejected, changes requested)
|
||||
- **Moderation actions** — for each campaign in the queue, admins can:
|
||||
- **Approve** — sets the moderation status to `APPROVED` and the campaign status to `ACTIVE`, making it publicly visible
|
||||
- **Reject** — marks the campaign as `REJECTED` with an optional reason visible to the submitter
|
||||
- **Request changes** — sets the status to `CHANGES_REQUESTED` with feedback, allowing the user to revise and resubmit
|
||||
- **Moderation stats** — the queue page displays counters for total user-generated campaigns, pending reviews, approved, rejected, and changes-requested counts
|
||||
- **Reviewer tracking** — each moderation action records the reviewer's user ID and timestamp
|
||||
- **Search and filter** — the moderation queue supports searching by campaign title, submitter name, or email, and filtering by moderation status
|
||||
|
||||
---
|
||||
|
||||
## Campaign Analytics
|
||||
|
||||
The Campaign Effectiveness dashboard provides cross-campaign performance analytics at `/app/influence/effectiveness`.
|
||||
|
||||
- **Performance tab** — per-campaign KPIs including total emails sent, email delivery status breakdown, response counts, response rates, and call counts; top campaigns visualized as a horizontal bar chart
|
||||
- **Representatives tab** — tracks individual representative responsiveness across all campaigns; shows emails received, responses given, verified response count, and response rate per representative; sortable by response count, response rate, or name; includes government level distribution
|
||||
- **Geography tab** — engagement breakdown by geographic area; group results by postal code, city, or province; enriched with city/province data from the postal code cache
|
||||
- **Funnel tab** — conversion funnel visualization showing progression from emails sent to unique participants to responses received to verified responses, plus calls made; includes percentage-of-first and stage-to-stage dropoff rates
|
||||
- **Trends tab** — time-series activity chart showing daily or weekly email and response volumes; default view covers the last 30 days; merged email and response series for side-by-side comparison
|
||||
- **Global filters** — all tabs share campaign and date range filters; select a specific campaign or view aggregate data across all campaigns
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/campaigns` — create, edit, and manage campaigns
|
||||
- `/app/campaign-moderation` — review and moderate user-submitted campaigns
|
||||
- `/app/influence/effectiveness` — campaign effectiveness analytics dashboard
|
||||
- `/app/responses` — moderate submitted responses
|
||||
- `/app/email-queue` — monitor outgoing email delivery
|
||||
|
||||
## Public Routes
|
||||
|
||||
- `/campaigns` — browse active campaigns
|
||||
- `/campaigns/create` — submit a new user-generated campaign (requires login)
|
||||
- `/campaigns/mine` — view and manage your submitted campaigns (requires login)
|
||||
- `/campaign/:slug` — take action on a specific campaign
|
||||
- `/campaign/:slug/responses` — view the response wall
|
||||
25
mkdocs/docs/docs/admin/advocacy/email-queue.md
Normal file
25
mkdocs/docs/docs/admin/advocacy/email-queue.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: Email Queue
|
||||
description: Monitor and manage the BullMQ advocacy email delivery queue.
|
||||
icon: material/email-sync
|
||||
---
|
||||
|
||||
# Email Queue
|
||||
|
||||
Monitor outgoing advocacy emails processed through the BullMQ queue.
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Queue dashboard** — view pending, active, completed, and failed jobs at `/app/email-queue`
|
||||
- **Job details** — inspect individual email jobs with recipient, subject, status, and timestamps
|
||||
- **Retry failed jobs** — re-queue emails that failed due to SMTP errors or timeouts
|
||||
- **Clear completed** — bulk-remove completed jobs to keep the queue clean
|
||||
- **Stats** — total sent, delivery rate, and average processing time
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/email-queue` — email queue monitoring and management
|
||||
18
mkdocs/docs/docs/admin/advocacy/index.md
Normal file
18
mkdocs/docs/docs/admin/advocacy/index.md
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
title: Advocacy
|
||||
description: Manage email campaigns, moderate responses, and monitor email delivery.
|
||||
icon: material/email-fast
|
||||
---
|
||||
|
||||
# Advocacy
|
||||
|
||||
The advocacy module helps supporters contact their elected representatives through email campaigns.
|
||||
|
||||
---
|
||||
|
||||
## In This Section
|
||||
|
||||
- **[Campaigns](campaigns.md)** — create and publish advocacy campaigns with postal code lookup and response tracking
|
||||
- **[Responses](responses.md)** — moderate the public response wall where supporters share representative replies
|
||||
- **[Representatives](representatives.md)** — manage the representative lookup cache powered by the Represent API
|
||||
- **[Email Queue](email-queue.md)** — monitor outgoing advocacy emails, retry failures, and view delivery stats
|
||||
24
mkdocs/docs/docs/admin/advocacy/representatives.md
Normal file
24
mkdocs/docs/docs/admin/advocacy/representatives.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: Representatives
|
||||
description: Represent API integration for postal code to representative lookup with caching.
|
||||
icon: material/account-tie
|
||||
---
|
||||
|
||||
# Representatives
|
||||
|
||||
The platform uses the [Represent API](https://represent.opennorth.ca/) to look up elected representatives by postal code across all government levels.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
- **Postal code lookup** — enter a Canadian postal code to retrieve federal, provincial, municipal, and school board representatives
|
||||
- **Redis cache** — lookup results are cached to reduce API calls and improve response times
|
||||
- **Cache management** — view cache status and clear entries from `/app/representatives`
|
||||
- **Government levels** — campaigns can target specific levels (federal, provincial, municipal, school board)
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/representatives` — representative cache management and lookup testing
|
||||
25
mkdocs/docs/docs/admin/advocacy/responses.md
Normal file
25
mkdocs/docs/docs/admin/advocacy/responses.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: Response Moderation
|
||||
description: Moderate the public response wall where supporters share how representatives replied.
|
||||
icon: material/message-reply-text
|
||||
---
|
||||
|
||||
# Response Moderation
|
||||
|
||||
Review and moderate representative responses submitted by supporters on the public response wall.
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Moderation queue** — review submissions at `/app/responses` with filtering by campaign and status
|
||||
- **Verification** — mark responses as verified to display a trust badge on the public wall
|
||||
- **Upvoting** — supporters can upvote responses; counts are visible on the public wall
|
||||
- **Approve / reject** — control which responses appear publicly
|
||||
- **Response stats** — track response counts per campaign and per representative
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/responses` — response moderation dashboard
|
||||
67
mkdocs/docs/docs/admin/broadcast/email-templates.md
Normal file
67
mkdocs/docs/docs/admin/broadcast/email-templates.md
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: Email Templates
|
||||
description: Create reusable email templates with variable substitution for campaign communications.
|
||||
icon: material/email-edit
|
||||
---
|
||||
|
||||
# Email Templates
|
||||
|
||||
Create reusable email templates with variable substitution for campaign communications. Templates are used by advocacy campaigns, shift confirmations, volunteer re-engagement emails, and other automated communications.
|
||||
|
||||
---
|
||||
|
||||
## Template Categories
|
||||
|
||||
Each template belongs to a category that determines where it can be used:
|
||||
|
||||
- **INFLUENCE** -- advocacy campaign emails sent to representatives
|
||||
- **MAP** -- shift confirmation, volunteer thank-you, and canvassing-related emails
|
||||
- **SYSTEM** -- account verification, password reset, and platform notifications
|
||||
- **PAYMENT** -- donation receipts, subscription confirmations, and purchase orders
|
||||
|
||||
---
|
||||
|
||||
## Variable System
|
||||
|
||||
Templates use Handlebars-style `{{VARIABLE_NAME}}` placeholders that are replaced at send time. Variables must use uppercase letters and underscores (e.g., `{{RECIPIENT_NAME}}`, `{{CAMPAIGN_TITLE}}`).
|
||||
|
||||
- **Text variables** -- simple string substitution for names, dates, URLs, and other text
|
||||
- **Video variables** -- embed a media library video by referencing its ID
|
||||
- **Conditional blocks** -- show or hide content with `{{#if VARIABLE}}...{{/if}}` syntax
|
||||
- **Required vs optional** -- each variable can be marked as required, with sample values for test emails
|
||||
|
||||
The template validator automatically extracts all variables from the HTML, text, and subject line content and checks for unmatched conditional blocks.
|
||||
|
||||
---
|
||||
|
||||
## Version History
|
||||
|
||||
Every change to a template's subject line, HTML content, or text content creates a new version. The full version history is preserved, and any previous version can be restored:
|
||||
|
||||
- **Version browsing** -- view the subject, HTML, and text content of any past version
|
||||
- **Rollback** -- restore a previous version (creates a new version entry, preserving the audit trail)
|
||||
- **Change notes** -- each version includes a description of what changed
|
||||
- **Author tracking** -- versions record which admin made each change
|
||||
|
||||
---
|
||||
|
||||
## Test Emails
|
||||
|
||||
Before activating a template, send a test email to verify rendering:
|
||||
|
||||
- **Variable substitution** -- provide test data for each variable to preview the final output
|
||||
- **Recipient selection** -- send the test to any email address
|
||||
- **Test log** -- all test sends are logged with success/failure status and message IDs
|
||||
- **Rate limited** -- 10 test emails per 15 minutes per user to prevent abuse
|
||||
|
||||
---
|
||||
|
||||
## Template Caching
|
||||
|
||||
Rendered templates are cached in memory for performance. The cache is automatically cleared when a template is created, updated, or deleted. Admins can also manually clear the cache from the admin interface.
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/email-templates` -- create and manage email templates with a visual editor
|
||||
17
mkdocs/docs/docs/admin/broadcast/index.md
Normal file
17
mkdocs/docs/docs/admin/broadcast/index.md
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: Broadcast
|
||||
description: Newsletter sync, email templates, and SMS campaigns for reaching supporters.
|
||||
icon: material/bullhorn
|
||||
---
|
||||
|
||||
# Broadcast
|
||||
|
||||
Reach supporters through multiple channels — email newsletters, templated campaigns, and SMS text messages.
|
||||
|
||||
---
|
||||
|
||||
## In This Section
|
||||
|
||||
- **[Newsletter](newsletter.md)** — Listmonk integration with automatic subscriber sync from shifts, campaigns, and contacts
|
||||
- **[Email Templates](email-templates.md)** — reusable templates with variable substitution, version history, and test sending
|
||||
- **[SMS](sms.md)** — text message campaigns via a Termux Android bridge with contact lists and response tracking
|
||||
65
mkdocs/docs/docs/admin/broadcast/newsletter.md
Normal file
65
mkdocs/docs/docs/admin/broadcast/newsletter.md
Normal file
@@ -0,0 +1,65 @@
|
||||
---
|
||||
title: Newsletter (Listmonk)
|
||||
description: Integrated opt-in mailing lists and newsletter campaigns powered by Listmonk.
|
||||
icon: material/newspaper-variant
|
||||
---
|
||||
|
||||
# Newsletter (Listmonk)
|
||||
|
||||
Integrated with [Listmonk](https://listmonk.app/) for opt-in mailing lists and newsletter campaigns. Enable with `LISTMONK_SYNC_ENABLED=true`.
|
||||
|
||||
---
|
||||
|
||||
## Managed Lists
|
||||
|
||||
The platform automatically creates and maintains 13 subscriber lists in Listmonk:
|
||||
|
||||
| List Name | Source | Tags |
|
||||
|-----------|--------|------|
|
||||
| All Contacts | All synced records | `v2` |
|
||||
| Campaign Participants | Users who sent advocacy emails | `v2`, `influence` |
|
||||
| Locations - All | Address occupants with email | `v2`, `map` |
|
||||
| Support Level 1-4 | Addresses by canvass support level | `v2`, `map`, `support` |
|
||||
| Has Campaign Sign | Addresses with a yard sign | `v2`, `map`, `signs` |
|
||||
| Users | Active non-temp platform accounts | `v2`, `users` |
|
||||
| Volunteers | Shift signups | `v2`, `map`, `shifts` |
|
||||
| Canvassers | Users who completed canvass sessions | `v2`, `map`, `canvass` |
|
||||
| Subscribers | Active paid subscribers | `v2`, `payments` |
|
||||
| Donors | Users who completed a donation | `v2`, `payments` |
|
||||
|
||||
---
|
||||
|
||||
## Bulk Sync
|
||||
|
||||
The admin panel provides a manual "Sync All" action that synchronizes four data sources to Listmonk:
|
||||
|
||||
1. **Campaign participants** -- distinct email senders from advocacy campaigns
|
||||
2. **Location contacts** -- address occupants with email, mapped to support level and sign lists
|
||||
3. **Users** -- active platform accounts (excludes TEMP users)
|
||||
4. **CRM tags** -- contacts tagged in the People module, synced to tag-linked Listmonk lists
|
||||
|
||||
Each source upserts subscribers (creates new or merges into existing), preserving existing list memberships and merging metadata attributes.
|
||||
|
||||
---
|
||||
|
||||
## Event-Driven Sync
|
||||
|
||||
In addition to bulk sync, the platform fires real-time subscriber upserts on application events:
|
||||
|
||||
- **Shift signup** -- adds to Volunteers list
|
||||
- **Canvass session completed** -- adds to Canvassers list
|
||||
- **Campaign email sent** -- adds to Campaign Participants list
|
||||
- **Subscription activated** -- adds to Subscribers list
|
||||
- **Donation completed** -- adds to Donors list
|
||||
- **Product purchased** -- adds to Donors list
|
||||
- **Address updated** (canvass visit) -- updates support level list membership
|
||||
- **Re-engagement email sent** -- updates Volunteers list metadata
|
||||
- **CRM tag changed** -- adds/removes from tag-linked Listmonk lists
|
||||
|
||||
All event-driven syncs are fire-and-forget and silently fail if Listmonk is unreachable.
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/listmonk` (sidebar: "Newsletter") -- sync status, subscriber counts, campaign stats, and manual sync trigger
|
||||
64
mkdocs/docs/docs/admin/broadcast/sms.md
Normal file
64
mkdocs/docs/docs/admin/broadcast/sms.md
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: SMS Campaigns
|
||||
description: Text message outreach via a Termux Android bridge with contact management and response tracking.
|
||||
icon: material/message-text
|
||||
---
|
||||
|
||||
# SMS Campaigns
|
||||
|
||||
Text message outreach via a Termux Android bridge. Enable with `ENABLE_SMS=true`.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Configure the bridge** — set `TERMUX_API_URL` and `TERMUX_API_KEY` to connect to an Android device running Termux with the SMS plugin
|
||||
2. **Create contact lists** — import contacts or build lists from existing supporters
|
||||
3. **Write a campaign** — compose a message template with variable substitution (name, location, etc.)
|
||||
4. **Launch the campaign** — messages are queued via BullMQ and sent serially through the device
|
||||
5. **Monitor responses** — incoming replies are synced and classified by keyword
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Contact lists** — import, tag, and segment contacts for targeted outreach
|
||||
- **Message templates** — reusable templates with variable placeholders
|
||||
- **BullMQ queue** — serial delivery with configurable delays between messages (`SMS_DELAY_BETWEEN_MS`)
|
||||
- **Response sync** — incoming SMS replies synced and classified automatically (`SMS_RESPONSE_SYNC_INTERVAL_MS`)
|
||||
- **Device monitoring** — health checks and status reporting for the connected Android device (`SMS_DEVICE_MONITOR_INTERVAL_MS`)
|
||||
- **Conversation view** — threaded message history per contact
|
||||
- **Retry logic** — configurable retry attempts for failed deliveries (`SMS_MAX_RETRIES`)
|
||||
|
||||
---
|
||||
|
||||
## Setup Wizard
|
||||
|
||||
The SMS Setup page (`/app/sms/setup`) provides a guided three-step wizard for connecting your Android phone:
|
||||
|
||||
### Step 1: Prepare Phone
|
||||
|
||||
Install Termux and Termux:API from F-Droid on the Android device, then generate a shared API key from the admin panel. The key is used for mutual authentication between the server and the phone.
|
||||
|
||||
### Step 2: Connect
|
||||
|
||||
Choose one of two connection methods:
|
||||
|
||||
- **Tailscale Auto-Discovery (recommended)** -- enter your Tailscale API key and the wizard automatically discovers devices on your tailnet, highlights Android devices, and pre-fills the stable IP address
|
||||
- **Manual URL Entry** -- enter the Termux API server URL directly (typically `http://100.x.x.x:5001` when using Tailscale)
|
||||
|
||||
### Step 3: Test and Save
|
||||
|
||||
Run a live connection test against the phone to verify the URL and API key are correct. The test displays device health info (uptime, messages sent). Once the test passes, save the configuration to enable SMS features platform-wide.
|
||||
|
||||
The wizard stores credentials encrypted in the database and updates the `enableSms` feature flag automatically.
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/sms/setup` -- guided setup wizard with Tailscale auto-discovery
|
||||
- `/app/sms` -- SMS dashboard with campaign overview and device status
|
||||
- `/app/sms/contacts` -- manage contact lists and entries
|
||||
- `/app/sms/campaigns` -- create and monitor SMS campaigns
|
||||
- `/app/sms/conversations` -- view threaded conversations with contacts
|
||||
22
mkdocs/docs/docs/admin/dashboard.md
Normal file
22
mkdocs/docs/docs/admin/dashboard.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
title: Dashboard
|
||||
description: Admin dashboard with live stats, activity feed, upcoming shifts, and service health indicators.
|
||||
icon: material/view-dashboard
|
||||
---
|
||||
|
||||
# Dashboard
|
||||
|
||||
The admin dashboard (`/app`) provides a real-time overview of platform activity.
|
||||
|
||||
---
|
||||
|
||||
## Dashboard Cards
|
||||
|
||||
- **Platform Stats** — active campaigns, total emails sent, registered users, and location count
|
||||
- **Activity Feed** — recent events across all modules (signups, emails, visits, responses)
|
||||
- **Upcoming Shifts** — next scheduled volunteer shifts with signup counts
|
||||
- **Newsletter Stats** — Listmonk subscriber counts and recent campaign performance
|
||||
- **Chat Activity** — Rocket.Chat channel activity and online users (when chat is enabled)
|
||||
- **Service Health** — connectivity status for integrated services (Gitea, Gancio, Vaultwarden, etc.)
|
||||
|
||||
All cards auto-refresh and gracefully degrade when their associated module is disabled.
|
||||
@@ -1,22 +1,89 @@
|
||||
---
|
||||
title: Administration
|
||||
description: Admin guide for managing users, settings, and platform operations.
|
||||
title: Admin Guide
|
||||
description: Day-to-day administration of users, campaigns, content, maps, media, and platform services.
|
||||
icon: material/shield-account
|
||||
---
|
||||
|
||||
# Administration
|
||||
# Admin Guide
|
||||
|
||||
This section covers day-to-day administration of the Changemaker Lite platform.
|
||||
The admin panel at `/app` is your command center for managing the entire platform. Use the sidebar to navigate between modules, or press **Ctrl+K** to open the command palette for quick access to any page, setting, or action.
|
||||
|
||||
!!! warning "Under Construction"
|
||||
Detailed admin documentation is being written. Check back soon.
|
||||
---
|
||||
|
||||
## Topics
|
||||
## Sections
|
||||
|
||||
- **User Management** — create, edit, and deactivate user accounts
|
||||
- **Roles & Permissions** — SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN, USER, TEMP
|
||||
- **Site Settings** — configure site name, contact info, and feature flags
|
||||
- **Email Templates** — manage reusable email templates
|
||||
- **Newsletter Sync** — Listmonk integration for subscriber management
|
||||
- **Email Queue** — monitor and manage the BullMQ advocacy email queue
|
||||
- **Landing Pages** — create and publish pages with the GrapesJS editor
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- :material-view-dashboard:{ .lg .middle } **[Dashboard](dashboard.md)**
|
||||
|
||||
---
|
||||
|
||||
Live overview of platform activity, upcoming shifts, email stats, and service health.
|
||||
|
||||
- :material-account-group:{ .lg .middle } **[People & Access](people-access.md)**
|
||||
|
||||
---
|
||||
|
||||
User management, roles, the People CRM, and contact merging.
|
||||
|
||||
- :material-email-fast:{ .lg .middle } **[Advocacy](advocacy/index.md)**
|
||||
|
||||
---
|
||||
|
||||
Campaigns, response moderation, representative lookup, and email queue monitoring.
|
||||
|
||||
- :material-bullhorn:{ .lg .middle } **[Broadcast](broadcast/index.md)**
|
||||
|
||||
---
|
||||
|
||||
Newsletter sync, email templates, and SMS campaigns.
|
||||
|
||||
- :material-web:{ .lg .middle } **[Web Content](web/index.md)**
|
||||
|
||||
---
|
||||
|
||||
Landing pages, homepage, navigation menu, and documentation management.
|
||||
|
||||
- :material-map-marker-multiple:{ .lg .middle } **[Map & Canvassing](map/index.md)**
|
||||
|
||||
---
|
||||
|
||||
Locations, areas, shifts, canvassing dashboard, data quality, and map settings.
|
||||
|
||||
- :material-play-box-multiple:{ .lg .middle } **[Media](media/index.md)**
|
||||
|
||||
---
|
||||
|
||||
Video/photo library, analytics, playlists, comment moderation, and gallery ads.
|
||||
|
||||
- :material-credit-card:{ .lg .middle } **[Payments](payments/index.md)**
|
||||
|
||||
---
|
||||
|
||||
Products, donations, subscription plans, and Stripe configuration.
|
||||
|
||||
- :material-server-network:{ .lg .middle } **[Services](services/index.md)**
|
||||
|
||||
---
|
||||
|
||||
Tunnel management, monitoring, and third-party integrations.
|
||||
|
||||
- :material-cog:{ .lg .middle } **[Settings](settings.md)**
|
||||
|
||||
---
|
||||
|
||||
Organization branding, theme colors, email config, feature toggles, and notifications.
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Roles Reference
|
||||
|
||||
| Role | Access Level |
|
||||
|------|-------------|
|
||||
| `SUPER_ADMIN` | Full platform access |
|
||||
| `INFLUENCE_ADMIN` | Campaigns, responses, email queue |
|
||||
| `MAP_ADMIN` | Locations, areas, shifts, canvassing |
|
||||
| `USER` | Volunteer portal only |
|
||||
| `TEMP` | Limited volunteer access (auto-created) |
|
||||
|
||||
37
mkdocs/docs/docs/admin/map/areas.md
Normal file
37
mkdocs/docs/docs/admin/map/areas.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: Areas (Cuts)
|
||||
description: Draw polygon canvassing territories on the map and organize locations into manageable regions.
|
||||
icon: material/vector-polygon
|
||||
---
|
||||
|
||||
# Areas (Cuts)
|
||||
|
||||
Draw polygon regions on the map to define canvassing territories. Areas organize locations into manageable chunks for volunteers.
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Polygon drawing** — use the map editor at `/app/map/cuts` to draw, edit, and delete area boundaries
|
||||
- **Automatic association** — locations within an area's polygon boundary are automatically linked
|
||||
- **Area stats** — total addresses, visited count, coverage percentage per area
|
||||
- **Color coding** — assign colors to visually distinguish areas on the map
|
||||
|
||||
---
|
||||
|
||||
## Area Import Wizard
|
||||
|
||||
Bulk-import addresses into an area from multiple data sources:
|
||||
|
||||
- **OpenStreetMap (OSM)** — pull building addresses from Nominatim within the area
|
||||
- **NAR (National Address Register)** — import from the Canadian federal address dataset
|
||||
- **Reverse geocode grid** — generate a grid of points and reverse-geocode to discover addresses
|
||||
- **Deduplication** — imported addresses are checked against existing locations to avoid duplicates
|
||||
- **Progress tracking** — real-time status per source during import
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/map/cuts` — draw and manage canvassing areas
|
||||
- `/app/map/cuts/:id/export` — printable location report for a cut
|
||||
45
mkdocs/docs/docs/admin/map/canvassing.md
Normal file
45
mkdocs/docs/docs/admin/map/canvassing.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: Canvassing
|
||||
description: Canvass dashboard, walk sheets, session management, and contact export for door-to-door outreach.
|
||||
icon: material/walk
|
||||
---
|
||||
|
||||
# Canvassing
|
||||
|
||||
Coordinate and monitor volunteer door-to-door outreach with the canvass dashboard, walk sheets, and contact export tools.
|
||||
|
||||
---
|
||||
|
||||
## Canvass Dashboard
|
||||
|
||||
From `/app/map/canvass`:
|
||||
|
||||
- **Active sessions** — see which volunteers are currently canvassing and their real-time positions
|
||||
- **Leaderboard** — volunteer rankings by visit count
|
||||
- **Activity feed** — recent visit outcomes across all areas
|
||||
- **Stats** — total sessions, visits recorded, and outcome breakdowns
|
||||
|
||||
---
|
||||
|
||||
## Walk Sheets & Exports
|
||||
|
||||
- **Walk sheet** — printable form at `/app/map/walk-sheet` with space for recording visit outcomes; includes up to 3 configurable QR codes
|
||||
- **Cut export** — printable location report at `/app/map/cuts/:id/export` for a specific canvassing area
|
||||
|
||||
---
|
||||
|
||||
## Canvass Contact Export
|
||||
|
||||
Bridge canvassing data with advocacy campaigns:
|
||||
|
||||
- **Filter by outcome** — include specific visit outcomes (spoke with, left literature, come back later)
|
||||
- **Support level range** — filter by recorded support level
|
||||
- **Area selection** — limit export to specific areas
|
||||
- **Campaign targeting** — export contacts as recipients for an advocacy campaign
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/map/canvass` — canvass dashboard
|
||||
- `/app/map/walk-sheet` — printable walk sheet
|
||||
25
mkdocs/docs/docs/admin/map/data-quality.md
Normal file
25
mkdocs/docs/docs/admin/map/data-quality.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: Data Quality
|
||||
description: Geocoding quality metrics, provider distribution, and confidence analysis.
|
||||
icon: material/chart-box
|
||||
---
|
||||
|
||||
# Data Quality
|
||||
|
||||
Monitor geocoding accuracy and coverage from `/app/map/data-quality`.
|
||||
|
||||
---
|
||||
|
||||
## Key Metrics
|
||||
|
||||
- **Geocoding success rate** — percentage of locations with valid coordinates
|
||||
- **Provider distribution** — breakdown of which geocoding provider was used per location
|
||||
- **Confidence scores** — distribution of geocoding confidence levels across the dataset
|
||||
- **Missing data** — locations without coordinates, postal codes, or province assignments
|
||||
- **Bulk re-geocode** — re-process failed or low-confidence locations with a different provider
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/map/data-quality` — geocoding quality dashboard
|
||||
20
mkdocs/docs/docs/admin/map/index.md
Normal file
20
mkdocs/docs/docs/admin/map/index.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
title: Map & Canvassing
|
||||
description: Location management, canvassing territories, volunteer shifts, and door-to-door outreach coordination.
|
||||
icon: material/map-marker-multiple
|
||||
---
|
||||
|
||||
# Map & Canvassing
|
||||
|
||||
Manage locations, organize canvassing territories, schedule volunteer shifts, and coordinate door-to-door outreach.
|
||||
|
||||
---
|
||||
|
||||
## In This Section
|
||||
|
||||
- **[Locations](locations.md)** — import addresses via CSV or NAR, geocode with multiple providers, and manage the location database
|
||||
- **[Areas](areas.md)** — draw polygon territories on the map to organize canvassing regions
|
||||
- **[Shifts](shifts.md)** — schedule volunteer time slots with recurring patterns and calendar views
|
||||
- **[Canvassing](canvassing.md)** — canvass dashboard, walk sheets, contact export, and session management
|
||||
- **[Data Quality](data-quality.md)** — geocoding quality metrics, provider distribution, and confidence analysis
|
||||
- **[Map Settings](settings.md)** — configure map center, zoom level, and QR code links
|
||||
41
mkdocs/docs/docs/admin/map/locations.md
Normal file
41
mkdocs/docs/docs/admin/map/locations.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: Locations
|
||||
description: Import, geocode, and manage addresses for canvassing and public map display.
|
||||
icon: material/map-marker-plus
|
||||
---
|
||||
|
||||
# Locations
|
||||
|
||||
Import addresses via CSV or the Canadian National Address Register (NAR), geocode them with multiple providers, and manage the location database.
|
||||
|
||||
---
|
||||
|
||||
## Adding Locations
|
||||
|
||||
- **Click-to-add** — click on the admin map to drop a new location marker
|
||||
- **Form entry** — manually enter address details
|
||||
- **CSV import** — upload a CSV with address columns; the system geocodes each row
|
||||
- **NAR import** — import Canadian National Address Register data with province, city, postal code, and residential-only filters
|
||||
|
||||
---
|
||||
|
||||
## Geocoding
|
||||
|
||||
Locations are geocoded automatically using a multi-provider system supporting Nominatim, ArcGIS, Photon, Mapbox, Google, and LocationIQ. Failed entries can be re-geocoded individually or in bulk.
|
||||
|
||||
---
|
||||
|
||||
## Bulk Operations
|
||||
|
||||
Select multiple locations for:
|
||||
|
||||
- Re-geocoding with a different provider
|
||||
- Tagging or re-tagging
|
||||
- Deletion
|
||||
- CSV export
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/map` — location CRUD, CSV import/export, geocoding, area import wizard
|
||||
23
mkdocs/docs/docs/admin/map/settings.md
Normal file
23
mkdocs/docs/docs/admin/map/settings.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
title: Map Settings
|
||||
description: Configure map center coordinates, default zoom level, and QR code links for walk sheets.
|
||||
icon: material/cog
|
||||
---
|
||||
|
||||
# Map Settings
|
||||
|
||||
Configure the default map view and QR code links from `/app/map/settings`.
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
- **Map center** — latitude and longitude for the default map center point
|
||||
- **Default zoom** — initial zoom level when maps load (1-18)
|
||||
- **QR code links** — up to 3 configurable URLs that appear as QR codes on printed walk sheets (e.g., campaign page, shift signup, volunteer portal)
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/map/settings` — map configuration
|
||||
39
mkdocs/docs/docs/admin/map/shifts.md
Normal file
39
mkdocs/docs/docs/admin/map/shifts.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: Shifts
|
||||
description: Schedule volunteer time slots with recurring patterns, calendar views, and area assignments.
|
||||
icon: material/calendar-clock
|
||||
---
|
||||
|
||||
# Shifts
|
||||
|
||||
Schedule volunteer time slots and let people sign up through a public page. Shifts can be linked to specific areas so volunteers know where they'll be canvassing.
|
||||
|
||||
---
|
||||
|
||||
## Creating Shifts
|
||||
|
||||
- **Single shifts** — set date, time, location description, and optional area assignment
|
||||
- **Recurring shifts** — create series with daily, weekly, or monthly frequency; weekly allows specific day selection
|
||||
- **Calendar view** — dedicated calendar tab showing shifts by date; click any date to create a new shift pre-filled
|
||||
|
||||
---
|
||||
|
||||
## Series Management
|
||||
|
||||
- **Edit modes** — when editing a recurring shift, choose: this shift only, this and future, or all in series
|
||||
- **Date range** — define start and optional end date; generates up to 12 weeks (capped at 100 shifts)
|
||||
- **Detach** — remove a shift from its series to edit independently
|
||||
|
||||
---
|
||||
|
||||
## Signups
|
||||
|
||||
- **Signup drawer** — view all signups for a shift in the admin panel
|
||||
- **Capacity** — optionally set maximum volunteer count per shift
|
||||
- **Confirmation emails** — automatic email sent when a volunteer signs up or cancels
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/map/shifts` — shift CRUD, calendar view, signup management
|
||||
25
mkdocs/docs/docs/admin/media/ads.md
Normal file
25
mkdocs/docs/docs/admin/media/ads.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: Gallery Ads
|
||||
description: Promotional cards with audience targeting, scheduling, and click-through analytics.
|
||||
icon: material/advertisements
|
||||
---
|
||||
|
||||
# Gallery Ads
|
||||
|
||||
Create promotional cards that appear in the public media gallery and documentation site. Manage from `/app/media/ads`.
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Ad CRUD** — create ads with title, description, image, and click-through URL
|
||||
- **Placement targeting** — assign ads to specific placements (gallery sidebar, gallery feed, docs sidebar)
|
||||
- **Scheduling** — set start and end dates for time-limited promotions
|
||||
- **Click tracking** — view impressions and click-through rates per ad
|
||||
- **Priority ordering** — control which ads appear first when multiple are active
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/media/ads` — gallery ad management and analytics
|
||||
47
mkdocs/docs/docs/admin/media/analytics.md
Normal file
47
mkdocs/docs/docs/admin/media/analytics.md
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
title: Analytics
|
||||
description: Video engagement metrics including views, watch time, completion rates, and traffic sources.
|
||||
icon: material/chart-line
|
||||
---
|
||||
|
||||
# Analytics
|
||||
|
||||
Track video engagement with GDPR-compliant analytics (IP hashing, 90-day retention).
|
||||
|
||||
---
|
||||
|
||||
## Per-Video Metrics
|
||||
|
||||
Each video tracks:
|
||||
|
||||
- **View count** and unique viewers
|
||||
- **Average watch time** and completion rate
|
||||
- **Traffic sources** — direct, embedded, shared
|
||||
- **Registered viewer activity** (when logged in)
|
||||
|
||||
---
|
||||
|
||||
## Global Dashboard
|
||||
|
||||
The analytics dashboard at `/app/media/analytics` provides:
|
||||
|
||||
- Aggregate view counts across all videos
|
||||
- Top-performing content by views and completion rate
|
||||
- Viewer trends over time
|
||||
- Traffic source breakdown
|
||||
|
||||
---
|
||||
|
||||
## Tracking
|
||||
|
||||
Public endpoints record engagement:
|
||||
|
||||
- View initiation
|
||||
- 10-second heartbeat intervals
|
||||
- `navigator.sendBeacon` for reliable end-of-session reporting
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/media/analytics` — global analytics dashboard
|
||||
36
mkdocs/docs/docs/admin/media/curated.md
Normal file
36
mkdocs/docs/docs/admin/media/curated.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: Curated Gallery
|
||||
description: Manage playlists, the shorts feed, and featured content for the public video gallery.
|
||||
icon: material/playlist-play
|
||||
---
|
||||
|
||||
# Curated Gallery
|
||||
|
||||
Curate the public gallery experience with playlists, a shorts feed, and featured content.
|
||||
|
||||
---
|
||||
|
||||
## Playlists
|
||||
|
||||
From `/app/media/curated`:
|
||||
|
||||
- **Three types** — admin playlists (managed), user playlists (personal), and public playlists (community)
|
||||
- **Drag-reorder** — arrange videos within a playlist
|
||||
- **Featured carousel** — feature playlists on the gallery homepage
|
||||
- **Dedicated viewer** — full playlist playback page with up-next queue
|
||||
|
||||
---
|
||||
|
||||
## Shorts Feed
|
||||
|
||||
TikTok-style vertical video feed for clips under 60 seconds:
|
||||
|
||||
- **Automatic classification** — videos under 60 seconds are flagged as shorts
|
||||
- **Vertical feed** — mobile-optimized swipeable interface at `/gallery/shorts`
|
||||
- **Autoplay** — continuous playback as viewers scroll
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/media/curated` — playlist management
|
||||
19
mkdocs/docs/docs/admin/media/index.md
Normal file
19
mkdocs/docs/docs/admin/media/index.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
title: Media
|
||||
description: Video and photo library, analytics, playlists, comment moderation, and gallery ad management.
|
||||
icon: material/play-box-multiple
|
||||
---
|
||||
|
||||
# Media
|
||||
|
||||
Upload, organize, and share campaign videos and photos with built-in analytics and engagement features. Enable with `enableMediaFeatures` in Settings.
|
||||
|
||||
---
|
||||
|
||||
## In This Section
|
||||
|
||||
- **[Library](library.md)** — upload videos and photos, manage metadata, schedule publishing, and generate preview links
|
||||
- **[Analytics](analytics.md)** — view counts, watch time, completion rates, traffic sources, and viewer activity
|
||||
- **[Curated Gallery](curated.md)** — playlists, shorts feed, and featured content for the public gallery
|
||||
- **[Moderation](moderation.md)** — comment review, word filters, and content moderation tools
|
||||
- **[Gallery Ads](ads.md)** — promotional cards with audience targeting, scheduling, and click-through analytics
|
||||
48
mkdocs/docs/docs/admin/media/library.md
Normal file
48
mkdocs/docs/docs/admin/media/library.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: Library
|
||||
description: Upload and manage videos and photos with metadata extraction, scheduled publishing, and preview links.
|
||||
icon: material/folder-play
|
||||
---
|
||||
|
||||
# Library
|
||||
|
||||
The media library at `/app/media/library` is where you upload, organize, and publish video and photo content.
|
||||
|
||||
---
|
||||
|
||||
## Video Upload
|
||||
|
||||
- **Drag-and-drop** — single or batch upload (up to 10GB per file)
|
||||
- **Supported formats** — MP4, MOV, AVI, MKV, WebM, M4V, FLV
|
||||
- **Automatic metadata** — FFprobe extracts duration, dimensions, orientation, quality, and audio info
|
||||
- **Quick actions** — hover a video card for Edit (E), Preview (P), Analytics (A), Schedule (S) keyboard shortcuts
|
||||
|
||||
---
|
||||
|
||||
## Photo Management
|
||||
|
||||
- **Albums** — organize photos into named collections with cover images
|
||||
- **Bulk uploads** — drag-and-drop multiple photos with automatic metadata extraction
|
||||
- **Photo picker** — insert photos into landing pages and email templates via a modal picker
|
||||
|
||||
---
|
||||
|
||||
## Scheduled Publishing
|
||||
|
||||
- **Publish/unpublish dates** — set future dates for automatic state changes
|
||||
- **Timezone support** — 11 supported timezones
|
||||
- **Calendar view** — visualize scheduled items on the Calendar tab
|
||||
- **BullMQ automation** — jobs fire at scheduled times
|
||||
|
||||
---
|
||||
|
||||
## Preview Links
|
||||
|
||||
Generate 24-hour JWT-authenticated preview links for unpublished videos — useful for stakeholder review before publishing.
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/media/library` — video and photo management
|
||||
- `/app/media/jobs` — processing job queue monitoring
|
||||
50
mkdocs/docs/docs/admin/media/moderation.md
Normal file
50
mkdocs/docs/docs/admin/media/moderation.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: Moderation
|
||||
description: Review and manage comments across media content with word filters and moderation tools.
|
||||
icon: material/shield-check
|
||||
---
|
||||
|
||||
# Moderation
|
||||
|
||||
Admin tools for reviewing and managing comments across all media content at `/app/media/moderation`.
|
||||
|
||||
---
|
||||
|
||||
## Moderation Dashboard
|
||||
|
||||
- **Filter by status** — pending, safe, flagged, hidden
|
||||
- **Status counts** — summary stats showing total, pending, flagged, hidden, and safe comments
|
||||
- **Search** — filter by video, date range, or text content
|
||||
|
||||
---
|
||||
|
||||
## Moderation Actions
|
||||
|
||||
For each comment, admins can:
|
||||
|
||||
- **Approve** — mark as safe and unhide if previously hidden
|
||||
- **Hide** — remove from public view with a reason (manual, word filter, spam, or link)
|
||||
- **Unhide** — restore a previously hidden comment
|
||||
- **Delete** — permanently remove
|
||||
- **Add notes** — internal moderation notes (not visible to users)
|
||||
|
||||
---
|
||||
|
||||
## Word Filter
|
||||
|
||||
A configurable list of words with severity levels:
|
||||
|
||||
| Severity | Action |
|
||||
|----------|--------|
|
||||
| High | Auto-blocks the comment |
|
||||
| Medium | Auto-hides for review |
|
||||
| Low | Flags for moderator attention |
|
||||
| Custom | User-defined severity |
|
||||
|
||||
The filter list is cached with a 1-minute TTL and invalidated on changes.
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/media/moderation` — comment moderation and word filter management
|
||||
39
mkdocs/docs/docs/admin/payments/donations.md
Normal file
39
mkdocs/docs/docs/admin/payments/donations.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: Donations
|
||||
description: Create branded donation pages with fundraising goals, suggested amounts, and tracking.
|
||||
icon: material/hand-heart
|
||||
---
|
||||
|
||||
# Donations
|
||||
|
||||
Create custom branded donation landing pages with independent branding and goals.
|
||||
|
||||
---
|
||||
|
||||
## Donation Pages
|
||||
|
||||
From `/app/donation-pages`:
|
||||
|
||||
- **Custom branding** — each page has its own title, description, and cover image
|
||||
- **Configurable amounts** — set suggested donation amounts per page
|
||||
- **Thank-you messages** — customizable post-donation confirmation
|
||||
- **Public slugs** — shareable URL at `/donate/:slug`
|
||||
- **Goal tracking** — fundraising goals with progress indicators
|
||||
- **Multiple campaigns** — run several pages simultaneously with independent tracking
|
||||
|
||||
---
|
||||
|
||||
## Donation Management
|
||||
|
||||
From `/app/donations`:
|
||||
|
||||
- View all donations with date, amount, donor info, and status
|
||||
- Filter by donation page, date range, or amount
|
||||
- Export to CSV for accounting and tax receipts
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/donations` — donation management
|
||||
- `/app/donation-pages` — donation page CRUD
|
||||
26
mkdocs/docs/docs/admin/payments/index.md
Normal file
26
mkdocs/docs/docs/admin/payments/index.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
title: Payments
|
||||
description: Stripe-powered products, donations, subscription plans, and payment configuration.
|
||||
icon: material/credit-card
|
||||
---
|
||||
|
||||
# Payments
|
||||
|
||||
Accept memberships, product sales, and donations through Stripe. Enable with `enablePayments` in Settings.
|
||||
|
||||
---
|
||||
|
||||
## In This Section
|
||||
|
||||
- **[Products](products.md)** — manage merchandise and one-time purchase items with inventory tracking
|
||||
- **[Donations](donations.md)** — donation pages with goals, suggested amounts, and branded thank-you messages
|
||||
- **[Plans](plans.md)** — recurring subscription plans with monthly and yearly billing
|
||||
- **[Settings](settings.md)** — Stripe API key configuration with encrypted storage
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Enable payments in Settings or `.env` (`ENABLE_PAYMENTS=true`)
|
||||
2. Configure Stripe API keys in **Settings > Payments** (stored encrypted with `ENCRYPTION_KEY`)
|
||||
3. Payment widgets become available on landing pages and MkDocs pages
|
||||
25
mkdocs/docs/docs/admin/payments/plans.md
Normal file
25
mkdocs/docs/docs/admin/payments/plans.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: Plans
|
||||
description: Recurring subscription plans with monthly and yearly billing via Stripe.
|
||||
icon: material/card-account-details-star
|
||||
---
|
||||
|
||||
# Plans
|
||||
|
||||
Create and manage recurring subscription plans for campaign supporters at `/app/plans`.
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Tiered plans** — multiple subscription tiers with different pricing and benefits
|
||||
- **Billing cycles** — monthly and yearly billing options
|
||||
- **Stripe integration** — subscriptions managed through Stripe for reliable recurring payments
|
||||
- **Subscriber tracking** — view active subscribers, MRR, and churn metrics
|
||||
- **Public pricing page** — plans displayed at `/pricing`
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/plans` — subscription plan management
|
||||
24
mkdocs/docs/docs/admin/payments/products.md
Normal file
24
mkdocs/docs/docs/admin/payments/products.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: Products
|
||||
description: Manage merchandise and one-time purchase items with inventory and Stripe checkout.
|
||||
icon: material/shopping
|
||||
---
|
||||
|
||||
# Products
|
||||
|
||||
Manage campaign merchandise and one-time purchase items at `/app/products`.
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Product CRUD** — create products with title, description, price, and images
|
||||
- **Inventory management** — track stock levels and set low-stock alerts
|
||||
- **Stripe checkout** — seamless payment flow via Stripe
|
||||
- **Public shop** — products displayed at `/shop` for public browsing and purchase
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/products` — product management
|
||||
33
mkdocs/docs/docs/admin/payments/settings.md
Normal file
33
mkdocs/docs/docs/admin/payments/settings.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: Payment Settings
|
||||
description: Configure Stripe API keys with encrypted storage for secure payment processing.
|
||||
icon: material/credit-card-settings
|
||||
---
|
||||
|
||||
# Payment Settings
|
||||
|
||||
Configure Stripe integration from **Settings > Payments**.
|
||||
|
||||
---
|
||||
|
||||
## Stripe Configuration
|
||||
|
||||
- **Publishable key** — used by the frontend for Stripe Elements and Checkout
|
||||
- **Secret key** — used by the API for creating charges and managing subscriptions
|
||||
- **Encrypted storage** — both keys are stored encrypted in the database using the `ENCRYPTION_KEY` environment variable (AES encryption)
|
||||
|
||||
---
|
||||
|
||||
## Webhook
|
||||
|
||||
Stripe webhooks are automatically configured to handle:
|
||||
|
||||
- Successful payments and subscription renewals
|
||||
- Failed payments and subscription cancellations
|
||||
- Refunds and disputes
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/settings` (Payments tab) — Stripe key configuration
|
||||
131
mkdocs/docs/docs/admin/people-access.md
Normal file
131
mkdocs/docs/docs/admin/people-access.md
Normal file
@@ -0,0 +1,131 @@
|
||||
---
|
||||
title: People & Access
|
||||
description: Manage users, roles, and the unified People CRM.
|
||||
icon: material/account-group
|
||||
---
|
||||
|
||||
# People & Access
|
||||
|
||||
Manage platform user accounts and roles, and use the People CRM to get a unified view of every supporter, donor, and volunteer across all modules.
|
||||
|
||||
---
|
||||
|
||||
## User Management
|
||||
|
||||
### Creating Users
|
||||
|
||||
Navigate to **Users** (`/app/users`) and click **Add User**. Fill in name, email, and role. The user will receive a welcome email with login instructions.
|
||||
|
||||
### Roles
|
||||
|
||||
| Role | Access | Use Case |
|
||||
|------|--------|----------|
|
||||
| `SUPER_ADMIN` | Full platform access | Campaign managers |
|
||||
| `INFLUENCE_ADMIN` | Campaigns, responses, email queue | Advocacy coordinators |
|
||||
| `MAP_ADMIN` | Locations, areas, shifts, canvassing | Field organizers |
|
||||
| `USER` | Volunteer portal only | Active volunteers |
|
||||
| `TEMP` | Limited volunteer access | Shift signups (auto-created) |
|
||||
|
||||
### Password Policy
|
||||
|
||||
Passwords must be at least 12 characters with uppercase, lowercase, and a digit. This is enforced at the API schema level.
|
||||
|
||||
### Deactivating Users
|
||||
|
||||
Edit a user from the Users page and toggle their active status. Deactivated users cannot log in but their data is preserved. Banned users have their sessions invalidated immediately.
|
||||
|
||||
### Service Accounts Panel
|
||||
|
||||
When editing a user, the **Service Accounts** panel shows provisioning status for each integrated service (Rocket.Chat, Gitea, Vaultwarden, Listmonk). You can provision, deprovision, or re-sync individual services per user.
|
||||
|
||||
---
|
||||
|
||||
## People CRM
|
||||
|
||||
Enable with `enablePeople` in Settings. The People module serves as the platform's CRM, aggregating data from all other modules into a unified view.
|
||||
|
||||
### Virtual Aggregation
|
||||
|
||||
The People page does not store a separate "people" table. Instead, it aggregates records in real time from seven data sources:
|
||||
|
||||
| Source | Data |
|
||||
|--------|------|
|
||||
| **Users** | Platform accounts (name, email, phone, last login) |
|
||||
| **Address Occupants** | Named residents from the map/canvassing module |
|
||||
| **Campaign Senders** | People who sent advocacy emails |
|
||||
| **Shift Signups** | Volunteer shift registrants |
|
||||
| **SMS Contacts** | Contacts from SMS campaign lists |
|
||||
| **Donations/Orders** | Buyers from the payments module |
|
||||
| **Manual** | Contacts created directly in the CRM |
|
||||
|
||||
Records are deduplicated by normalized email or phone number, with Users taking highest priority.
|
||||
|
||||
### Managed Contacts
|
||||
|
||||
Any virtual person can be "promoted" to a **managed Contact** record. This creates a persistent Contact entity in the database with:
|
||||
|
||||
- **Display name, first/last name** — editable independently of the source
|
||||
- **Tags** — custom CRM tags for segmentation and filtering
|
||||
- **Notes** — free-text notes field
|
||||
- **Support level** — LEVEL_1 (Strong) through LEVEL_4 (Opposition)
|
||||
- **Opt-out flags** — email opt-out, SMS opt-out, and do-not-contact
|
||||
- **Sign requested** — track yard sign status
|
||||
|
||||
### Contact Details
|
||||
|
||||
Each managed contact supports multiple structured data entries:
|
||||
|
||||
- **Addresses** — link to map locations with optional unit numbers and primary flag; new addresses can be auto-added to the map for geocoding
|
||||
- **Emails** — multiple email addresses with labels (e.g., Personal, Work) and primary designation
|
||||
- **Phones** — multiple phone numbers with labels and primary designation
|
||||
|
||||
### Activity Timeline
|
||||
|
||||
View a chronological timeline of all interactions for a person, across every module:
|
||||
|
||||
- Advocacy emails sent and responses submitted
|
||||
- Shift signups and canvass visits
|
||||
- Donations and product purchases
|
||||
- SMS messages sent and received
|
||||
- Video views
|
||||
- Notes added and contact merges
|
||||
|
||||
### Duplicate Detection and Merge
|
||||
|
||||
The platform identifies potential duplicates by matching normalized email addresses and phone numbers across sources. The merge workflow lets you:
|
||||
|
||||
- Select which fields to keep from the source vs. target contact
|
||||
- Merge tags, addresses, emails, and phones
|
||||
- Preserve the full audit trail (merged contacts are soft-linked, not deleted)
|
||||
|
||||
### Connection Graph
|
||||
|
||||
Build a relationship graph between contacts using typed connections:
|
||||
|
||||
- **Connection types** — Household, Family, Colleague, Referred By, and Custom
|
||||
- **Bidirectional** — connections can be one-way or mutual
|
||||
- **Visual graph** — interactive force-directed graph visualization showing contacts as nodes and connections as edges
|
||||
- **Configurable depth** — explore up to 3 degrees of separation
|
||||
|
||||
### Profile Links
|
||||
|
||||
Generate shareable public profile pages for contacts:
|
||||
|
||||
- **Unique token URLs** at `/profile/:token`
|
||||
- **Configurable expiration** — 24 hours, 7 days, 30 days, 90 days, 1 year, or never
|
||||
- **Optional password protection** — require a PIN or password to view
|
||||
|
||||
### Household Detection
|
||||
|
||||
The Household panel groups contacts who share the same physical address, making it easy to see all members of a household and their combined engagement.
|
||||
|
||||
### Create User from Contact
|
||||
|
||||
Promote a CRM contact to a full platform user account directly from the People interface, with role assignment and optional welcome email.
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/users` — user CRUD, role assignment, service accounts
|
||||
- `/app/people` — contact list with search, filters, source/tag filtering, and bulk actions
|
||||
18
mkdocs/docs/docs/admin/services/index.md
Normal file
18
mkdocs/docs/docs/admin/services/index.md
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
title: Services
|
||||
description: Tunnel management, monitoring, and third-party service integrations.
|
||||
icon: material/server-network
|
||||
---
|
||||
|
||||
# Services
|
||||
|
||||
Manage the platform's infrastructure services, monitoring stack, and third-party integrations.
|
||||
|
||||
---
|
||||
|
||||
## In This Section
|
||||
|
||||
- **[Tunnel](tunnel.md)** — Pangolin tunnel management for public access without port forwarding
|
||||
- **[Monitoring](monitoring.md)** — Prometheus metrics, Grafana dashboards, and Alertmanager
|
||||
- **[Integrations](integrations.md)** — Chat, video conferencing, password manager, whiteboard, Git hosting, automation, and QR codes
|
||||
- **[User Provisioning](user-provisioning.md)** — automatic account sync across integrated services
|
||||
89
mkdocs/docs/docs/admin/services/integrations.md
Normal file
89
mkdocs/docs/docs/admin/services/integrations.md
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
title: Integrations
|
||||
description: Chat, video conferencing, password manager, whiteboard, Git hosting, automation, and QR services.
|
||||
icon: material/puzzle
|
||||
---
|
||||
|
||||
# Integrations
|
||||
|
||||
Changemaker Lite integrates with several self-hosted services. Each runs as a Docker container and can be enabled independently.
|
||||
|
||||
---
|
||||
|
||||
## Team Chat (Rocket.Chat)
|
||||
|
||||
Self-hosted team chat for volunteer coordination. Enable with `enableChat` in Settings.
|
||||
|
||||
- **Channels & DMs** — organize conversations by topic, team, or campaign
|
||||
- **Iframe integration** — embedded in the admin dashboard and volunteer portal
|
||||
- **Floating widget** — minimizable chat FAB on admin pages (toggleable in Settings)
|
||||
- **SSO-ready** — iframe authentication for seamless login
|
||||
- **Mobile apps** — native Rocket.Chat apps work with your instance
|
||||
- **Routes:** `/app/services/rocketchat`, `/volunteer/chat`
|
||||
|
||||
---
|
||||
|
||||
## Video Conferencing (Jitsi Meet)
|
||||
|
||||
Self-hosted video calls integrated with Rocket.Chat via JWT authentication. Enable with `enableMeet` in Settings.
|
||||
|
||||
- **One-click calls** — start a call from any Rocket.Chat channel or DM
|
||||
- **JWT auth** — participants join automatically with no separate login
|
||||
- **4 containers** — jitsi-web, jitsi-prosody (XMPP/JWT), jitsi-jicofo (conference focus), jitsi-jvb (video bridge)
|
||||
- **Setup:** Generate secrets, start containers, configure the Jitsi marketplace app in Rocket.Chat, set token expiration to `now + 1hour`
|
||||
|
||||
!!! warning "Token Expiration"
|
||||
Set the Jitsi app's Token Expiration to `now + 1hour`. A raw number like `120` is interpreted as Unix timestamp 120 (Jan 1970), causing all tokens to appear expired.
|
||||
|
||||
---
|
||||
|
||||
## Password Manager (Vaultwarden)
|
||||
|
||||
Bitwarden-compatible password vault for secure team credential sharing.
|
||||
|
||||
- **Bitwarden client compatible** — use official browser extensions, desktop apps, and mobile apps
|
||||
- **Auto-invite** — initial admin user invited on first startup
|
||||
- **User provisioning** — new platform users can be auto-invited when provisioning is enabled
|
||||
- **Client setup:** Point Bitwarden clients to `https://vault.DOMAIN`
|
||||
|
||||
---
|
||||
|
||||
## Whiteboard (Excalidraw)
|
||||
|
||||
Collaborative whiteboard for brainstorming and campaign planning.
|
||||
|
||||
- **Real-time collaboration** — multi-user drawing with WebSocket support
|
||||
- **Embedded in admin** — full-screen iframe at `/app/services/excalidraw`
|
||||
- **Desktop only** — requires a desktop browser for the drawing experience
|
||||
- **Route:** `/app/services/excalidraw`
|
||||
|
||||
---
|
||||
|
||||
## Git Hosting (Gitea)
|
||||
|
||||
Self-hosted Git repository hosting for campaign code and configuration.
|
||||
|
||||
- **Lightweight Git forge** — repositories, issues, pull requests, and wikis
|
||||
- **User provisioning** — platform users can be auto-provisioned as Gitea accounts
|
||||
- **Embedded** — accessible at `git.DOMAIN` or embedded in admin at `/app/services/gitea`
|
||||
|
||||
---
|
||||
|
||||
## Workflow Automation (n8n)
|
||||
|
||||
Self-hosted workflow automation for connecting platform events to external services.
|
||||
|
||||
- **Visual workflow editor** — drag-and-drop automation builder
|
||||
- **Webhook triggers** — respond to platform events
|
||||
- **Embedded** — accessible at `n8n.DOMAIN` or embedded in admin at `/app/services/n8n`
|
||||
|
||||
---
|
||||
|
||||
## QR Code Generator (Mini QR)
|
||||
|
||||
Built-in QR code generation for walk sheets, volunteer invites, and campaign links.
|
||||
|
||||
- **Public API** — QR code PNG generation at `/api/qr`
|
||||
- **Walk sheet integration** — QR codes embedded in printable walk sheets
|
||||
- **Volunteer quick join** — QR codes for instant volunteer onboarding
|
||||
- **Embedded** — admin interface at `/app/services/qr`
|
||||
63
mkdocs/docs/docs/admin/services/monitoring.md
Normal file
63
mkdocs/docs/docs/admin/services/monitoring.md
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
title: Monitoring
|
||||
description: Prometheus metrics, Grafana dashboards, and Alertmanager for platform observability.
|
||||
icon: material/chart-timeline-variant
|
||||
---
|
||||
|
||||
# Monitoring
|
||||
|
||||
The monitoring stack runs as a Docker Compose profile and provides metrics collection, visualization, and alerting.
|
||||
|
||||
---
|
||||
|
||||
## Starting the Stack
|
||||
|
||||
```bash
|
||||
docker compose --profile monitoring up -d
|
||||
```
|
||||
|
||||
This starts Prometheus, Grafana, Alertmanager, cAdvisor, Node Exporter, and Redis Exporter.
|
||||
|
||||
---
|
||||
|
||||
## Custom Metrics
|
||||
|
||||
The platform exposes 12 custom `cm_*` Prometheus metrics:
|
||||
|
||||
- API request rates and latencies
|
||||
- BullMQ queue sizes (email, SMS, video scheduling)
|
||||
- Active canvass sessions
|
||||
- External service health gauges
|
||||
|
||||
---
|
||||
|
||||
## Grafana Dashboards
|
||||
|
||||
Three pre-configured dashboards auto-provisioned from `configs/grafana/`:
|
||||
|
||||
- **API Performance** — request rates, latencies, error rates
|
||||
- **Application Overview** — queue sizes, active sessions, service health
|
||||
- **System Health** — container resources, host metrics, Redis stats
|
||||
|
||||
---
|
||||
|
||||
## Alertmanager
|
||||
|
||||
Alert rules in `configs/prometheus/alerts.yml` cover:
|
||||
|
||||
- API downtime and high error rates
|
||||
- Queue backlogs
|
||||
- Service connectivity failures
|
||||
- Resource utilization thresholds
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/observability` — embedded Grafana dashboards, alert status, and service health (3 tabs)
|
||||
|
||||
## Direct Access
|
||||
|
||||
- Grafana: `localhost:3001` or `grafana.DOMAIN`
|
||||
- Prometheus: `localhost:9090`
|
||||
- Alertmanager: `localhost:9093`
|
||||
44
mkdocs/docs/docs/admin/services/tunnel.md
Normal file
44
mkdocs/docs/docs/admin/services/tunnel.md
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: Tunnel (Pangolin)
|
||||
description: Manage the Pangolin tunnel for exposing services to the internet without port forwarding.
|
||||
icon: material/tunnel
|
||||
---
|
||||
|
||||
# Tunnel (Pangolin)
|
||||
|
||||
Pangolin provides secure tunneling to expose your self-hosted services to the internet without port forwarding or a static IP.
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
From `/app/pangolin`:
|
||||
|
||||
- **Automated setup** — one-command deployment that creates the Pangolin site, updates `.env` with credentials, and restarts the Newt tunnel container
|
||||
- **Manual setup** — step-by-step instructions for connecting to an existing Pangolin instance
|
||||
|
||||
---
|
||||
|
||||
## Resource Management
|
||||
|
||||
The platform defines 12+ service resources in `configs/pangolin/resources.yml`:
|
||||
|
||||
- Each resource maps a subdomain (e.g., `api.DOMAIN`, `app.DOMAIN`) to an internal service
|
||||
- **Hourly sync** — nginx cron job pushes resource definitions to Pangolin automatically
|
||||
- **Status dashboard** — view tunnel connection status and resource health
|
||||
|
||||
---
|
||||
|
||||
## Newt Container
|
||||
|
||||
The Newt container runs alongside nginx and tunnels traffic to your services:
|
||||
|
||||
- Configured via `PANGOLIN_NEWT_ID` and `PANGOLIN_NEWT_SECRET` environment variables
|
||||
- Depends on nginx (all resources route through `nginx:80`)
|
||||
- Auto-restarts on failure
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/pangolin` — tunnel status, setup wizard, and resource management
|
||||
43
mkdocs/docs/docs/admin/services/user-provisioning.md
Normal file
43
mkdocs/docs/docs/admin/services/user-provisioning.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
title: User Provisioning
|
||||
description: Automatic account creation and sync across integrated services.
|
||||
icon: material/account-sync
|
||||
---
|
||||
|
||||
# User Provisioning
|
||||
|
||||
Automatically create and sync user accounts across integrated services when new platform users are registered. Enable with `enableUserProvisioning` in Settings.
|
||||
|
||||
---
|
||||
|
||||
## Supported Services
|
||||
|
||||
| Service | Mode | Notes |
|
||||
|---------|------|-------|
|
||||
| Rocket.Chat | Always lazy | SSO on first access |
|
||||
| Gitea | Eager or lazy | Admin API provisioning |
|
||||
| Vaultwarden | Eager or lazy | Invite-based (no password management) |
|
||||
| Listmonk | Eager or lazy | Subscriber sync |
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
From **Settings > User Provisioning**:
|
||||
|
||||
- Toggle provisioning per service
|
||||
- Choose **eager** (create immediately on user registration) or **lazy** (create on first access)
|
||||
- View provisioning status per user in the **Service Accounts** panel on the Users page
|
||||
|
||||
---
|
||||
|
||||
## Bulk Sync
|
||||
|
||||
Trigger a bulk sync from `/api/users/provisioning/sync` to provision all existing users across enabled services. Useful after enabling a new service.
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/users` (edit drawer) — per-user service account status and actions
|
||||
- `/app/settings` (User Provisioning tab) — per-service toggle and timing
|
||||
113
mkdocs/docs/docs/admin/settings.md
Normal file
113
mkdocs/docs/docs/admin/settings.md
Normal file
@@ -0,0 +1,113 @@
|
||||
---
|
||||
title: Platform Settings
|
||||
description: Multi-tab admin settings for organization branding, theme colors, email configuration, feature toggles, and notification controls.
|
||||
icon: material/cog
|
||||
---
|
||||
|
||||
# Platform Settings
|
||||
|
||||
Centralized configuration for organization identity, theming, email delivery, feature modules, and automated notifications.
|
||||
|
||||
---
|
||||
|
||||
## Settings Tabs
|
||||
|
||||
### Organization
|
||||
|
||||
Configure your organization's public identity:
|
||||
|
||||
- **Organization Name** — displayed in the admin sidebar, public pages, and emails
|
||||
- **Short Name** — shown when the admin sidebar is collapsed (max 10 characters)
|
||||
- **Logo URL** — displayed on the login page, homepage hero, and public navigation
|
||||
- **Favicon URL** — browser tab icon
|
||||
- **Footer Text** — shown in public page footers
|
||||
- **Login Subtitle** — displayed below the organization name on the login page
|
||||
|
||||
---
|
||||
|
||||
### Theme Colors
|
||||
|
||||
Customize the look of admin and public interfaces:
|
||||
|
||||
**Admin theme:**
|
||||
|
||||
- Primary color (accent color for buttons, links, active states)
|
||||
- Background color (page background)
|
||||
|
||||
**Public theme:**
|
||||
|
||||
- Primary color
|
||||
- Background color
|
||||
- Container color (card and section backgrounds)
|
||||
- Header gradient (CSS gradient string for the public navigation bar)
|
||||
|
||||
A live preview panel shows color swatches and a gradient preview as you configure.
|
||||
|
||||
---
|
||||
|
||||
### Email
|
||||
|
||||
Configure how the platform sends emails:
|
||||
|
||||
- **Sender** — from name and from address for all outgoing emails
|
||||
- **Active SMTP provider** — toggle between MailHog (testing) and Production SMTP with a single click
|
||||
- **Production SMTP** — host, port, username, and password (collapsible panel, disabled when MailHog is active)
|
||||
- **Test mode** — when enabled, all emails redirect to a single test recipient address
|
||||
- **Test actions** — "Test Connection" verifies SMTP connectivity; "Send Test Email" delivers a test message through the active provider
|
||||
|
||||
A configuration summary card at the top displays the current provider, server, authentication status, and test mode state.
|
||||
|
||||
---
|
||||
|
||||
### Feature Toggles
|
||||
|
||||
Enable or disable platform modules. Disabling a module hides it from navigation but does not delete data.
|
||||
|
||||
| Category | Flag | Description |
|
||||
|----------|------|-------------|
|
||||
| **Core Platform** | `enableInfluence` | Advocacy campaigns, email sending, response wall |
|
||||
| | `enableMap` | Map, locations, canvassing, volunteer shifts |
|
||||
| | `enableNewsletter` | Listmonk newsletter sync |
|
||||
| | `enableLandingPages` | GrapesJS landing page builder |
|
||||
| **Media & Content** | `enableMediaFeatures` | Video library, public gallery, analytics |
|
||||
| | `enableGalleryAds` | Promotional cards in the video gallery |
|
||||
| | `enableEvents` | Gancio event calendar integration |
|
||||
| **Communication** | `enableChat` | Rocket.Chat team coordination |
|
||||
| | `enableMeet` | Jitsi video meetings (integrates with Rocket.Chat) |
|
||||
| | `enableSms` | Termux Android SMS campaigns |
|
||||
| **People & Engagement** | `enablePeople` | Unified contacts CRM |
|
||||
| | `enableSocial` | Volunteer social connections and activity feeds |
|
||||
| | `autoSyncPeopleToMap` | Auto-create map locations from contact addresses |
|
||||
| **Commerce** | `enablePayments` | Stripe subscriptions, products, and donations |
|
||||
|
||||
---
|
||||
|
||||
### Notifications
|
||||
|
||||
Control which automated email notifications the platform sends. Disabling a notification stops future emails but does not affect already-queued jobs.
|
||||
|
||||
**Admin alerts:**
|
||||
|
||||
- New shift signup
|
||||
- Response wall submission
|
||||
- Yard sign request (from canvassing)
|
||||
- Shift cancellation
|
||||
|
||||
**Volunteer emails:**
|
||||
|
||||
- Canvass session summary (sent after completing a session)
|
||||
- Signup cancellation confirmation
|
||||
- 24-hour pre-shift reminder
|
||||
- Post-shift thank-you (sent 2 hours after shift ends)
|
||||
|
||||
**Re-engagement:**
|
||||
|
||||
- Re-engagement emails for inactive volunteers
|
||||
- Configurable inactivity threshold (days without activity)
|
||||
- Configurable cooldown period (minimum days between re-engagement emails)
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/settings` — multi-tab settings page (supports deep-linking to a specific tab via router state)
|
||||
49
mkdocs/docs/docs/admin/web/documentation.md
Normal file
49
mkdocs/docs/docs/admin/web/documentation.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Documentation
|
||||
description: MkDocs site management, page analytics, comment moderation, and documentation settings.
|
||||
icon: material/book-open-variant
|
||||
---
|
||||
|
||||
# Documentation
|
||||
|
||||
Manage the MkDocs documentation site, track page engagement, and moderate visitor comments.
|
||||
|
||||
---
|
||||
|
||||
## MkDocs Management
|
||||
|
||||
From **Docs** (`/app/docs`):
|
||||
|
||||
- View MkDocs build status and health
|
||||
- Browse the documentation file tree
|
||||
- Export landing pages to MkDocs as Jinja2 Material theme overrides
|
||||
- Configure documentation settings from **MkDocs Settings** (`/app/docs/settings`)
|
||||
|
||||
---
|
||||
|
||||
## Documentation Analytics
|
||||
|
||||
Track how visitors interact with documentation pages using the MkDocs Material theme's custom analytics provider and `navigation.tracking`.
|
||||
|
||||
- **Navigation tracking** — updates the browser URL as users scroll through sections, enabling section-level engagement tracking
|
||||
- **Custom provider** — integrates with any third-party analytics tool (Plausible, Umami, Google Analytics) via template overrides in `docs/overrides/`
|
||||
|
||||
---
|
||||
|
||||
## Comments
|
||||
|
||||
Visitors can leave comments on documentation pages using a Gitea-backed comment system.
|
||||
|
||||
- **Anonymous posting** — visitors can comment without creating an account
|
||||
- **Gitea-backed** — comments stored as Gitea issues (one issue per page) for version control and searchability
|
||||
- **Moderation** — admin panel at `/app/docs-comments` for approving, hiding, or deleting comments
|
||||
- **OAuth login** — optional Gitea OAuth for authenticated commenting
|
||||
- **Per-page threads** — each documentation page gets its own comment thread
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/docs` — MkDocs management (file tree, config, build triggers)
|
||||
- `/app/docs/settings` — documentation configuration
|
||||
- `/app/docs-comments` — moderate documentation comments
|
||||
43
mkdocs/docs/docs/admin/web/homepage.md
Normal file
43
mkdocs/docs/docs/admin/web/homepage.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
title: Public Homepage
|
||||
description: Dynamic landing page that aggregates campaigns, shifts, media, events, and platform stats for public visitors.
|
||||
icon: material/home-variant
|
||||
---
|
||||
|
||||
# Public Homepage
|
||||
|
||||
A dynamic public landing page that showcases your organization and aggregates content from across the platform.
|
||||
|
||||
---
|
||||
|
||||
## Sections
|
||||
|
||||
The homepage assembles its content from enabled modules. Sections are only displayed when their corresponding module is active and has data to show.
|
||||
|
||||
- **Hero banner** — organization name, logo, tagline (configurable via Settings), and call-to-action buttons for campaigns and volunteer signups
|
||||
- **Stats counters** — active campaigns, total emails sent, and volunteer signups (shown only when counts are greater than zero)
|
||||
- **Featured campaigns** — up to 3 active campaigns, sorted by highlight status then creation date, with email counts and descriptions
|
||||
- **Upcoming shifts** — up to 3 open shifts with date, time, location, and spots remaining
|
||||
- **Latest videos** — up to 4 recently published videos from the media library, displayed in a horizontal scroll strip with thumbnails and durations
|
||||
- **Upcoming events** — up to 3 future events from Gancio with date, location, and tags
|
||||
- **Recent activity** — a compact activity feed showing the latest platform actions
|
||||
|
||||
---
|
||||
|
||||
## Data & Caching
|
||||
|
||||
All homepage data is fetched from a single API endpoint (`/api/homepage`) and cached in Redis for 2 minutes. Individual section queries use `Promise.allSettled` so that a failure in one module (e.g., Gancio being offline) does not prevent the rest of the page from loading.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
- **Organization name and logo** — set via Settings > Organization
|
||||
- **Homepage tagline** — set via the `homepageTagline` field in site settings
|
||||
- **Module visibility** — controlled by feature flags (enableInfluence, enableMap, enableMediaFeatures, enableEvents, enablePayments, enableLandingPages)
|
||||
|
||||
---
|
||||
|
||||
## Public Routes
|
||||
|
||||
- `/home` — public homepage
|
||||
24
mkdocs/docs/docs/admin/web/index.md
Normal file
24
mkdocs/docs/docs/admin/web/index.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: Web Content
|
||||
description: Landing pages, homepage configuration, navigation, and documentation management.
|
||||
icon: material/web
|
||||
---
|
||||
|
||||
# Web Content
|
||||
|
||||
Manage the public-facing web presence — landing pages, the dynamic homepage, navigation menu, and documentation site.
|
||||
|
||||
---
|
||||
|
||||
## In This Section
|
||||
|
||||
- **[Landing Pages](landing-pages.md)** — build campaign microsites with the GrapesJS drag-and-drop editor
|
||||
- **[Homepage](homepage.md)** — dynamic public landing page aggregating campaigns, shifts, media, and events
|
||||
- **[Navigation](navigation.md)** — customize the public navigation menu with toggles, custom links, and reordering
|
||||
- **[Documentation](documentation.md)** — MkDocs site management, page analytics, and comment moderation
|
||||
|
||||
---
|
||||
|
||||
## Social Sharing
|
||||
|
||||
All public content (campaigns, landing pages, gallery videos) automatically generates Open Graph and Twitter Card meta tags for rich link previews when shared on social media, messaging apps, and search engines. OG responses are cached in Redis for 10 minutes.
|
||||
29
mkdocs/docs/docs/admin/web/landing-pages.md
Normal file
29
mkdocs/docs/docs/admin/web/landing-pages.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: Landing Pages
|
||||
description: Build campaign microsites with a drag-and-drop GrapesJS visual editor.
|
||||
icon: material/application-edit
|
||||
---
|
||||
|
||||
# Landing Pages
|
||||
|
||||
Build campaign microsites with a drag-and-drop visual editor.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Create a new page from the admin panel
|
||||
2. Open the GrapesJS visual editor — drag blocks, edit text, adjust styles
|
||||
3. Save and publish — the page goes live at `/p/:slug`
|
||||
4. Optionally export to MkDocs for inclusion in the documentation site
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/pages` — list and manage landing pages
|
||||
- `/app/pages/:id/edit` — full-screen GrapesJS editor
|
||||
|
||||
## Public Routes
|
||||
|
||||
- `/p/:slug` — view a published landing page
|
||||
53
mkdocs/docs/docs/admin/web/navigation.md
Normal file
53
mkdocs/docs/docs/admin/web/navigation.md
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
title: Navigation Settings
|
||||
description: Customize the public-facing navigation menu from the admin panel.
|
||||
icon: material/menu
|
||||
---
|
||||
|
||||
# Navigation Settings
|
||||
|
||||
Customize the public-facing navigation menu from the admin panel. The navigation bar appears on all public pages, the admin header, the Gancio events page, and the MkDocs documentation site.
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Per-item toggle** -- enable or disable each navigation item with a switch
|
||||
- **Custom links** -- add external links or internal paths to the navigation
|
||||
- **Reorder** -- move items up and down to arrange them in any order
|
||||
- **Editable labels and paths** -- rename any item or change its destination
|
||||
- **Feature flag awareness** -- builtin items tied to a feature flag (e.g., Campaigns requires `enableInfluence`) are automatically hidden when that feature is disabled
|
||||
- **Visitor control** -- determine exactly what public visitors can access
|
||||
|
||||
---
|
||||
|
||||
## Builtin Items
|
||||
|
||||
The platform ships with 11 builtin navigation items that cover the main public routes:
|
||||
|
||||
Home, Campaigns, Map, Shifts, Events, Gallery, Pricing, Shop, Donate, Website (landing page), and Docs (documentation site).
|
||||
|
||||
Each builtin item has a default icon and path. Some paths use special `$` tokens (e.g., `$landing`, `$docs`) that are automatically resolved to the correct external URL based on the deployment environment.
|
||||
|
||||
---
|
||||
|
||||
## Custom Links
|
||||
|
||||
Add any number of custom links via the "Add Custom Link" button. Custom links support:
|
||||
|
||||
- Internal paths (e.g., `/blog`)
|
||||
- External URLs (e.g., `https://example.com`) -- automatically detected and opened in a new tab
|
||||
|
||||
Custom links can be deleted from the navigation; builtin items can only be toggled off.
|
||||
|
||||
---
|
||||
|
||||
## Mobile Handling
|
||||
|
||||
On mobile devices, the navigation collapses into a hamburger menu that opens a full-height drawer. On desktop, the nav bar also supports a collapse mode that hides labels and shows only icons, toggled via a fold/unfold button. The collapse state is persisted in local storage.
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/navigation` -- navigation editor with per-item toggle, reorder, label editing, and custom link management
|
||||
@@ -1,251 +0,0 @@
|
||||
---
|
||||
title: Feature Guides
|
||||
description: Explore Changemaker Lite's campaign, mapping, media, and communication features.
|
||||
icon: material/star-shooting
|
||||
---
|
||||
|
||||
# Feature Guides
|
||||
|
||||
Changemaker Lite bundles advocacy campaigns, geographic mapping, volunteer management, media hosting, and landing pages into a single self-hosted platform. Every feature can be toggled on or off from **Settings** in the admin panel.
|
||||
|
||||
---
|
||||
|
||||
## Advocacy Campaigns
|
||||
|
||||
Help supporters contact their elected representatives through email campaigns.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **An admin creates a campaign** — writes the email subject and body, selects which government levels to target (federal, provincial, municipal, school board), and publishes it.
|
||||
2. **A supporter visits the campaign page** — enters their postal code to look up their representatives.
|
||||
3. **The supporter sends the email** — either directly through the platform ("Send Now") or by opening it in their own email app (Gmail, Outlook, etc.).
|
||||
4. **Responses get tracked** — supporters and admins can share representative responses on the Response Wall, with upvoting and moderation.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Postal code lookup** — powered by the Represent API, returns representatives at all government levels
|
||||
- **Two send methods** — server-sent SMTP (tracked) or mailto link (opens user's email app)
|
||||
- **Email editing** — optionally let supporters personalize the email before sending
|
||||
- **Response Wall** — public wall where people share how their representatives responded, with moderation and verification
|
||||
- **Campaign stats** — track emails sent, responses received, and upvotes
|
||||
- **Featured campaigns** — highlight important campaigns on the public listing page
|
||||
|
||||
### Admin Routes
|
||||
|
||||
- `/app/campaigns` — create, edit, and manage campaigns
|
||||
- `/app/responses` — moderate submitted responses
|
||||
- `/app/email-queue` — monitor outgoing email delivery
|
||||
|
||||
### Public Routes
|
||||
|
||||
- `/campaigns` — browse active campaigns
|
||||
- `/campaign/:slug` — take action on a specific campaign
|
||||
- `/campaign/:slug/responses` — view the response wall
|
||||
|
||||
---
|
||||
|
||||
## Map & Canvassing
|
||||
|
||||
Manage locations, organize canvassing territories, and coordinate volunteer door-to-door outreach.
|
||||
|
||||
### Locations
|
||||
|
||||
Import addresses via CSV or the Canadian NAR (National Address Register) dataset. Each location can be geocoded using multiple providers (Nominatim, ArcGIS, Photon, Mapbox, and more). Locations appear as color-coded markers on admin and public maps.
|
||||
|
||||
### Areas (Cuts)
|
||||
|
||||
Draw polygon regions on the map to define canvassing territories. Areas help organize locations into manageable chunks for volunteers. Each area shows stats like total addresses, visit counts, and coverage percentage.
|
||||
|
||||
### Shifts
|
||||
|
||||
Schedule volunteer time slots and let people sign up through a public page. Shifts can be linked to specific areas so volunteers know where they'll be canvassing.
|
||||
|
||||
### Canvassing
|
||||
|
||||
The volunteer canvass map is a full-screen GPS-tracked experience:
|
||||
|
||||
- **Walking routes** — the system generates an efficient route through assigned locations
|
||||
- **Visit recording** — tap a marker, record the outcome (not home, supportive, opposed, etc.)
|
||||
- **GPS tracking** — real-time position shown on the map
|
||||
- **Cluster markers** — addresses grouped intelligently for performance at scale
|
||||
|
||||
### Admin Routes
|
||||
|
||||
- `/app/map` — manage locations (CRUD, CSV import/export, geocoding)
|
||||
- `/app/map/cuts` — draw and manage canvassing areas
|
||||
- `/app/map/shifts` — schedule shifts and view signups
|
||||
- `/app/map/canvass` — canvass dashboard (stats, activity feed, leaderboard)
|
||||
|
||||
### Public Routes
|
||||
|
||||
- `/map` — public map view (address locations only, no canvass data)
|
||||
- `/shifts` — sign up for volunteer shifts
|
||||
|
||||
### Volunteer Routes
|
||||
|
||||
- `/volunteer` — full-screen canvass map with GPS and visit recording
|
||||
- `/volunteer/shifts` — view assigned shifts
|
||||
- `/volunteer/activity` — visit history and outcome breakdown
|
||||
- `/volunteer/routes` — past canvassing routes
|
||||
|
||||
---
|
||||
|
||||
## Media Manager
|
||||
|
||||
Upload, organize, and share campaign videos with built-in analytics.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Video upload** — drag-and-drop upload with automatic metadata extraction (duration, dimensions, quality)
|
||||
- **Public gallery** — shareable video gallery at `/gallery` with category browsing
|
||||
- **Analytics** — view counts, watch time, completion rates, and traffic sources (GDPR-compliant)
|
||||
- **Scheduled publishing** — automate publish/unpublish with timezone support and a calendar view
|
||||
- **Curated playlists** — organize videos into shareable collections
|
||||
- **Quick actions** — keyboard shortcuts (E/P/A/S) for edit, preview, analytics, schedule
|
||||
|
||||
### Admin Routes
|
||||
|
||||
- `/app/media/library` — upload and manage videos
|
||||
- `/app/media/curated` — manage playlists
|
||||
- `/app/media/jobs` — monitor processing jobs
|
||||
|
||||
### Public Routes
|
||||
|
||||
- `/gallery` — browse the public video gallery
|
||||
- `/gallery/watch/:id` — watch a specific video
|
||||
|
||||
---
|
||||
|
||||
## Landing Pages
|
||||
|
||||
Build campaign microsites with a drag-and-drop visual editor.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. Create a new page from the admin panel
|
||||
2. Open the GrapesJS visual editor — drag blocks, edit text, adjust styles
|
||||
3. Save and publish — the page goes live at `/p/:slug`
|
||||
4. Optionally export to MkDocs for inclusion in the documentation site
|
||||
|
||||
### Admin Routes
|
||||
|
||||
- `/app/pages` — list and manage landing pages
|
||||
- `/app/pages/:id/edit` — full-screen GrapesJS editor
|
||||
|
||||
### Public Routes
|
||||
|
||||
- `/p/:slug` — view a published landing page
|
||||
|
||||
---
|
||||
|
||||
## Newsletter (Listmonk)
|
||||
|
||||
Integrated with [Listmonk](https://listmonk.app/) for opt-in mailing lists and newsletter campaigns.
|
||||
|
||||
### Sync
|
||||
|
||||
When enabled (`LISTMONK_SYNC_ENABLED=true`), the platform syncs shift participants, location contacts, and user accounts to Listmonk subscriber lists. Sync is triggered manually from the admin panel.
|
||||
|
||||
### Admin Routes
|
||||
|
||||
- `/app/listmonk` (sidebar: "Newsletter") — sync status, subscriber counts, and manual sync trigger
|
||||
|
||||
---
|
||||
|
||||
## Email Templates
|
||||
|
||||
Create reusable email templates with variable substitution for campaign communications.
|
||||
|
||||
### Admin Routes
|
||||
|
||||
- `/app/email-templates` — create and manage email templates with a visual editor
|
||||
|
||||
---
|
||||
|
||||
## Payments (Stripe)
|
||||
|
||||
Accept memberships, product sales, and donations through Stripe. Enable with `ENABLE_PAYMENTS=true`.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. Enable payments in the `.env` file or admin Settings page
|
||||
2. Configure your Stripe API keys in **Admin → Settings → Payments** (keys are stored encrypted in the database)
|
||||
3. Payment widgets become available on landing pages and MkDocs pages
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Memberships** — recurring payment support for campaign subscribers
|
||||
- **Products** — one-time purchases for campaign merchandise or events
|
||||
- **Donations** — accept and track contributions
|
||||
- **Encrypted storage** — Stripe API keys stored with AES encryption (`ENCRYPTION_KEY`)
|
||||
|
||||
### Admin Routes
|
||||
|
||||
- `/app/settings` — configure Stripe API keys in the Payments tab
|
||||
|
||||
---
|
||||
|
||||
## Events (Gancio)
|
||||
|
||||
Integrated with [Gancio](https://gancio.org/) for self-hosted event management. When enabled, volunteer shifts are automatically published as public events.
|
||||
|
||||
### Shift-to-Event Sync
|
||||
|
||||
When `GANCIO_SYNC_ENABLED=true`, the platform:
|
||||
|
||||
1. **Creates** a Gancio event whenever a new shift is published
|
||||
2. **Updates** the event if the shift time, location, or details change
|
||||
3. **Deletes** the event if the shift is cancelled
|
||||
|
||||
Sync uses OAuth authentication with the Gancio admin account.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Automatic sync** — shifts appear as public events without manual entry
|
||||
- **Embeddable calendar** — GrapesJS block and MkDocs widget for embedding the event calendar on pages
|
||||
- **Public events page** — linked from the public navigation when `enableEvents` is enabled in settings
|
||||
|
||||
### Admin Routes
|
||||
|
||||
- `/app/gancio` — Gancio service status and iframe embed
|
||||
|
||||
### Public Routes
|
||||
|
||||
- `/events` — public events navigation link (when enabled)
|
||||
- `events.DOMAIN` — Gancio web interface for browsing and RSVPs
|
||||
|
||||
---
|
||||
|
||||
## Team Chat (Rocket.Chat)
|
||||
|
||||
Self-hosted team chat for volunteer coordination and campaign communication. Enable with `ENABLE_CHAT=true`.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Channels & DMs** — organize conversations by topic, team, or campaign
|
||||
- **Iframe integration** — embedded directly in the admin dashboard and volunteer portal
|
||||
- **SSO-ready** — supports iframe authentication for seamless login from the admin panel
|
||||
- **File sharing** — share documents, images, and campaign materials
|
||||
- **Mobile apps** — native Rocket.Chat mobile apps work with your self-hosted instance
|
||||
|
||||
### Admin Routes
|
||||
|
||||
- `/app/chat` — embedded Rocket.Chat interface
|
||||
|
||||
---
|
||||
|
||||
## Password Manager (Vaultwarden)
|
||||
|
||||
Self-hosted Bitwarden-compatible password vault for secure credential sharing across the campaign team.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Bitwarden client compatible** — use the official Bitwarden browser extensions, desktop apps, and mobile apps
|
||||
- **Secure sharing** — share login credentials, notes, and API keys between team members
|
||||
- **Auto-invite** — the initial admin user is automatically invited on first startup
|
||||
- **HTTPS required** — account creation requires HTTPS (provided by Pangolin tunnel)
|
||||
|
||||
### Access
|
||||
|
||||
- `vault.DOMAIN` — Vaultwarden web vault
|
||||
- Bitwarden clients — point them to `https://vault.DOMAIN`
|
||||
289
mkdocs/docs/docs/getting-started/features.md
Normal file
289
mkdocs/docs/docs/getting-started/features.md
Normal file
@@ -0,0 +1,289 @@
|
||||
---
|
||||
title: Features at a Glance
|
||||
description: A visual overview of every Changemaker Lite module.
|
||||
icon: material/star-shooting
|
||||
---
|
||||
|
||||
# Features at a Glance
|
||||
|
||||
Changemaker Lite bundles advocacy campaigns, geographic mapping, volunteer management, media hosting, and landing pages into a single self-hosted platform. Every feature can be toggled on or off from **Settings** in the admin panel.
|
||||
|
||||
---
|
||||
|
||||
## Core Features
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- :material-email-fast:{ .lg .middle } **Advocacy Campaigns**
|
||||
|
||||
---
|
||||
|
||||
Help supporters contact elected representatives through email campaigns with postal code lookup and a public response wall.
|
||||
|
||||
[:octicons-arrow-right-24: Campaign guide](../admin/advocacy/campaigns.md)
|
||||
|
||||
- :material-map-marker-multiple:{ .lg .middle } **Map & Canvassing**
|
||||
|
||||
---
|
||||
|
||||
Manage locations, draw canvassing territories, schedule volunteer shifts, and run GPS-tracked door-to-door outreach.
|
||||
|
||||
[:octicons-arrow-right-24: Map guide](../admin/map/index.md)
|
||||
|
||||
- :material-play-box-multiple:{ .lg .middle } **Media Manager**
|
||||
|
||||
---
|
||||
|
||||
Upload videos and photos, curate playlists, publish a shorts feed, and track engagement with built-in analytics.
|
||||
|
||||
[:octicons-arrow-right-24: Media guide](../admin/media/index.md)
|
||||
|
||||
- :material-application-edit:{ .lg .middle } **Landing Pages**
|
||||
|
||||
---
|
||||
|
||||
Build campaign microsites with a drag-and-drop GrapesJS visual editor and publish at custom slugs.
|
||||
|
||||
[:octicons-arrow-right-24: Landing pages guide](../admin/web/landing-pages.md)
|
||||
|
||||
- :material-credit-card:{ .lg .middle } **Payments (Stripe)**
|
||||
|
||||
---
|
||||
|
||||
Accept memberships, product sales, and donations with encrypted Stripe integration and branded donation pages.
|
||||
|
||||
[:octicons-arrow-right-24: Payments guide](../admin/payments/index.md)
|
||||
|
||||
- :material-message-text:{ .lg .middle } **SMS Campaigns**
|
||||
|
||||
---
|
||||
|
||||
Text message outreach via a Termux Android bridge with contact lists, templates, and response tracking.
|
||||
|
||||
[:octicons-arrow-right-24: SMS guide](../admin/broadcast/sms.md)
|
||||
|
||||
- :material-home-variant:{ .lg .middle } **Public Homepage**
|
||||
|
||||
---
|
||||
|
||||
Customizable landing page with hero section, live stats, featured campaigns, upcoming shifts, and activity feed.
|
||||
|
||||
[:octicons-arrow-right-24: Homepage guide](../admin/web/homepage.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Communication & Collaboration
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- :material-newspaper-variant:{ .lg .middle } **Newsletter (Listmonk)**
|
||||
|
||||
---
|
||||
|
||||
Opt-in mailing lists and newsletter campaigns with automatic subscriber sync from shifts and contacts.
|
||||
|
||||
[:octicons-arrow-right-24: Newsletter guide](../admin/broadcast/newsletter.md)
|
||||
|
||||
- :material-email-edit:{ .lg .middle } **Email Templates**
|
||||
|
||||
---
|
||||
|
||||
Reusable email templates with variable substitution for campaign communications.
|
||||
|
||||
[:octicons-arrow-right-24: Email templates guide](../admin/broadcast/email-templates.md)
|
||||
|
||||
- :material-chat:{ .lg .middle } **Team Chat (Rocket.Chat)**
|
||||
|
||||
---
|
||||
|
||||
Self-hosted team chat with iframe integration, floating widget, and native mobile app support.
|
||||
|
||||
[:octicons-arrow-right-24: Chat guide](../admin/services/integrations.md)
|
||||
|
||||
- :material-video:{ .lg .middle } **Video Conferencing (Jitsi)**
|
||||
|
||||
---
|
||||
|
||||
Self-hosted video calls integrated with Rocket.Chat via JWT authentication — no separate login required.
|
||||
|
||||
[:octicons-arrow-right-24: Video conferencing guide](../admin/services/integrations.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Integrations & Services
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- :material-calendar-star:{ .lg .middle } **Events (Gancio)**
|
||||
|
||||
---
|
||||
|
||||
Self-hosted event management with automatic shift-to-event sync and an embeddable calendar widget.
|
||||
|
||||
[:octicons-arrow-right-24: Events guide](../user-guide/events.md)
|
||||
|
||||
- :material-shield-lock:{ .lg .middle } **Password Manager (Vaultwarden)**
|
||||
|
||||
---
|
||||
|
||||
Bitwarden-compatible password vault for secure team credential sharing.
|
||||
|
||||
[:octicons-arrow-right-24: Password manager guide](../admin/services/integrations.md)
|
||||
|
||||
- :material-account-sync:{ .lg .middle } **User Provisioning**
|
||||
|
||||
---
|
||||
|
||||
Automatic account creation and sync across Rocket.Chat, Gitea, Vaultwarden, and Listmonk.
|
||||
|
||||
[:octicons-arrow-right-24: User provisioning guide](../admin/services/user-provisioning.md)
|
||||
|
||||
- :material-account-group:{ .lg .middle } **People / Contacts**
|
||||
|
||||
---
|
||||
|
||||
Centralized contact management for supporters, donors, and community members with cross-module linking.
|
||||
|
||||
[:octicons-arrow-right-24: People guide](../admin/people-access.md)
|
||||
|
||||
- :material-draw:{ .lg .middle } **Whiteboard (Excalidraw)**
|
||||
|
||||
---
|
||||
|
||||
Self-hosted collaborative whiteboard for brainstorming, planning, and visual collaboration.
|
||||
|
||||
[:octicons-arrow-right-24: Whiteboard guide](../admin/services/integrations.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Volunteer Portal
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- :material-account-heart:{ .lg .middle } **Social Connections**
|
||||
|
||||
---
|
||||
|
||||
Friend system, activity feed, groups, profiles, pokes, and privacy controls for volunteer community building.
|
||||
|
||||
[:octicons-arrow-right-24: Social guide](../volunteer/social.md)
|
||||
|
||||
- :material-trophy:{ .lg .middle } **Achievements & Leaderboard**
|
||||
|
||||
---
|
||||
|
||||
Badge system with 11 achievements across 4 categories, progress tracking, and competitive leaderboards.
|
||||
|
||||
[:octicons-arrow-right-24: Achievements guide](../volunteer/achievements.md)
|
||||
|
||||
- :material-qrcode:{ .lg .middle } **Volunteer Quick Join**
|
||||
|
||||
---
|
||||
|
||||
QR code invite links for instant volunteer onboarding — scan, fill a short form, and start canvassing.
|
||||
|
||||
[:octicons-arrow-right-24: Quick join guide](../user-guide/shifts.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Automation & Analytics
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- :material-robot:{ .lg .middle } **Email Automation**
|
||||
|
||||
---
|
||||
|
||||
Automated volunteer lifecycle emails — thank-you notes, shift reminders, weekly summaries, and re-engagement campaigns.
|
||||
|
||||
[:octicons-arrow-right-24: Automation guide](../admin/services/integrations.md)
|
||||
|
||||
- :material-chart-bar:{ .lg .middle } **Data Quality Dashboard**
|
||||
|
||||
---
|
||||
|
||||
Geocoding quality metrics with per-provider stats, confidence tiers, and coverage analysis.
|
||||
|
||||
[:octicons-arrow-right-24: Data quality guide](../admin/map/data-quality.md)
|
||||
|
||||
- :material-google-analytics:{ .lg .middle } **Documentation Analytics**
|
||||
|
||||
---
|
||||
|
||||
Page view tracking and engagement metrics for MkDocs documentation pages.
|
||||
|
||||
[:octicons-arrow-right-24: Docs analytics guide](../admin/web/documentation.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## Admin Tools
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- :material-comment-text-multiple:{ .lg .middle } **Docs Comments**
|
||||
|
||||
---
|
||||
|
||||
Gitea-backed comment system for documentation pages with anonymous posting and moderation.
|
||||
|
||||
[:octicons-arrow-right-24: Docs comments guide](../admin/web/documentation.md)
|
||||
|
||||
- :material-text-search:{ .lg .middle } **Command Palette**
|
||||
|
||||
---
|
||||
|
||||
Global Ctrl+K search across pages, campaigns, locations, users, settings, and media.
|
||||
|
||||
[:octicons-arrow-right-24: Command palette guide](../admin/index.md)
|
||||
|
||||
- :material-menu:{ .lg .middle } **Navigation Settings**
|
||||
|
||||
---
|
||||
|
||||
Customize the public navigation menu with feature toggles, custom links, and drag-and-drop reordering.
|
||||
|
||||
[:octicons-arrow-right-24: Navigation guide](../admin/web/navigation.md)
|
||||
|
||||
- :material-cog:{ .lg .middle } **Platform Settings**
|
||||
|
||||
---
|
||||
|
||||
Five-tab settings page covering organization details, theme colors, email configuration, feature flags, and notifications.
|
||||
|
||||
[:octicons-arrow-right-24: Settings guide](../admin/settings.md)
|
||||
|
||||
- :material-share-variant:{ .lg .middle } **Social Sharing (OG Tags)**
|
||||
|
||||
---
|
||||
|
||||
Open Graph meta tags for campaigns, landing pages, and gallery videos — rich link previews on social media.
|
||||
|
||||
[:octicons-arrow-right-24: OG sharing guide](../admin/web/index.md)
|
||||
|
||||
- :material-advertisements:{ .lg .middle } **Gallery Ads**
|
||||
|
||||
---
|
||||
|
||||
Internal ad system with 5 ad types, audience targeting, scheduling, frequency caps, and CTR analytics.
|
||||
|
||||
[:octicons-arrow-right-24: Gallery ads guide](../admin/media/ads.md)
|
||||
|
||||
- :material-card-account-details:{ .lg .middle } **Self-Service Contact Profile**
|
||||
|
||||
---
|
||||
|
||||
Token-based public profile pages where contacts can view and update their information and preferences.
|
||||
|
||||
[:octicons-arrow-right-24: Contact profile guide](../user-guide/profile.md)
|
||||
|
||||
</div>
|
||||
80
mkdocs/docs/docs/getting-started/first-steps.md
Normal file
80
mkdocs/docs/docs/getting-started/first-steps.md
Normal file
@@ -0,0 +1,80 @@
|
||||
---
|
||||
title: First Steps
|
||||
description: Log in, explore the dashboard, and set up your first campaign and volunteer shift.
|
||||
icon: material/shoe-print
|
||||
---
|
||||
|
||||
# First Steps
|
||||
|
||||
You've installed Changemaker Lite — here's what to do next.
|
||||
|
||||
---
|
||||
|
||||
## 1. Log In
|
||||
|
||||
Open the admin panel at `http://localhost:3000` (or `app.DOMAIN` in production) and sign in with the admin email and password you configured during setup.
|
||||
|
||||
!!! warning "Change your password"
|
||||
If you used the wizard's generated password, change it immediately from **Settings > Organization**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Explore the Dashboard
|
||||
|
||||
The dashboard gives you an at-a-glance view of platform activity. Initially it will be empty — that's normal.
|
||||
|
||||
---
|
||||
|
||||
## 3. Configure Settings
|
||||
|
||||
Visit **Settings** (`/app/settings`) to:
|
||||
|
||||
- Set your organization name, logo, and tagline
|
||||
- Choose theme colors for admin and public interfaces
|
||||
- Enable feature modules (campaigns, map, media, payments, etc.)
|
||||
- Configure email delivery (MailHog for testing, production SMTP for live use)
|
||||
|
||||
---
|
||||
|
||||
## 4. Create Your First Campaign
|
||||
|
||||
Go to **Campaigns** (`/app/campaigns`) and click **Create Campaign**:
|
||||
|
||||
1. Write a title and description
|
||||
2. Compose the email template supporters will send
|
||||
3. Select government levels to target
|
||||
4. Publish — the campaign appears at `/campaigns`
|
||||
|
||||
---
|
||||
|
||||
## 5. Add Locations
|
||||
|
||||
Go to **Locations** (`/app/map`) and add addresses:
|
||||
|
||||
- Click on the map to drop a marker
|
||||
- Import a CSV of addresses
|
||||
- Use the NAR (National Address Register) import for Canadian data
|
||||
|
||||
---
|
||||
|
||||
## 6. Schedule a Shift
|
||||
|
||||
Go to **Shifts** (`/app/map/shifts`) and create your first volunteer shift:
|
||||
|
||||
1. Set a date, time, and location description
|
||||
2. Optionally link it to a canvassing area
|
||||
3. Share the public shifts page (`/shifts`) with volunteers
|
||||
|
||||
---
|
||||
|
||||
## 7. Invite Volunteers
|
||||
|
||||
Share the shifts page link or generate QR codes for in-person events. Volunteers sign up with just an email address.
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Features at a Glance](features.md) — visual overview of every module
|
||||
- [Admin Guide](../admin/index.md) — full administration reference
|
||||
- [Deployment](../deployment/index.md) — production setup with tunneling and SSL
|
||||
@@ -140,7 +140,8 @@ docker compose --profile monitoring up -d
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Environment Variables](environment-variables.md) — complete `.env` reference with every configurable option
|
||||
- [Feature Guides](../features/index.md) — explore campaigns, map, and media
|
||||
- [Environment Variables](environment-variables.md) — complete `.env` reference
|
||||
- [First Steps](first-steps.md) — create your first campaign and add locations
|
||||
- [Features at a Glance](features.md) — visual overview of every module
|
||||
- [Admin Guide](../admin/index.md) — full administration reference
|
||||
- [Deployment](../deployment/index.md) — production setup with SSL and tunneling
|
||||
- [Architecture](../architecture/index.md) — understand the system design
|
||||
|
||||
61
mkdocs/docs/docs/getting-started/installation.md
Normal file
61
mkdocs/docs/docs/getting-started/installation.md
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: Installation
|
||||
description: System requirements, installation methods, and initial service startup.
|
||||
icon: material/download
|
||||
---
|
||||
|
||||
# Installation
|
||||
|
||||
Changemaker Lite runs as a set of Docker containers orchestrated by Docker Compose.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Docker** 24+ and **Docker Compose** v2
|
||||
- **OpenSSL** (for secret generation)
|
||||
- A Linux server (Ubuntu 22.04+ recommended) or macOS for development
|
||||
- At least 2 GB RAM and 10 GB disk space
|
||||
- A domain name (optional, but recommended for production)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://gitea.bnkops.com/admin/changemaker.lite
|
||||
cd changemaker.lite
|
||||
git checkout v2
|
||||
|
||||
# Run the configuration wizard
|
||||
bash config.sh
|
||||
|
||||
# Start core services
|
||||
docker compose up -d v2-postgres redis api admin
|
||||
|
||||
# Run database migrations and seed
|
||||
docker compose exec api npx prisma migrate deploy
|
||||
docker compose exec api npx prisma db seed
|
||||
```
|
||||
|
||||
Open **http://localhost:3000** and sign in with the admin credentials you configured.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Wizard
|
||||
|
||||
The `config.sh` wizard walks you through domain setup, admin credentials, secret generation, SMTP config, feature flags, and Pangolin tunnel setup. After completion you'll have a fully populated `.env` with no placeholder passwords.
|
||||
|
||||
---
|
||||
|
||||
## Manual Setup
|
||||
|
||||
If you prefer to configure by hand, copy `.env.example` to `.env` and set the required values. See [Environment Variables](environment-variables.md) for every option.
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [First Steps](first-steps.md) — explore the dashboard and create your first campaign
|
||||
- [Environment Variables](environment-variables.md) — complete configuration reference
|
||||
@@ -28,7 +28,7 @@ Welcome to the Changemaker Lite documentation. Whether you're a campaign volunte
|
||||
|
||||
Campaigns, email advocacy, response walls, map locations, landing pages, and media.
|
||||
|
||||
[:octicons-arrow-right-24: Feature Guides](features/index.md)
|
||||
[:octicons-arrow-right-24: Feature Guides](getting-started/features.md)
|
||||
|
||||
- :material-shield-account:{ .lg .middle } **Administration**
|
||||
|
||||
|
||||
@@ -1,2 +1,115 @@
|
||||
# phil
|
||||
---
|
||||
title: Philosophy
|
||||
description: Why we build Changemaker Lite — and why your movement should own its infrastructure.
|
||||
---
|
||||
|
||||
# Philosophy
|
||||
|
||||
## Software Is Political
|
||||
|
||||
Every tool your movement adopts shapes how you organize. Proprietary platforms reinforce hierarchy — the vendor decides what features you get, what data you can export, and what happens when you stop paying. Community-controlled tools support democratic autonomy because the people using them decide how they work.
|
||||
|
||||
**If you do politics, who is reading your secrets?** Corporate platforms harvest political intelligence systematically. Facebook chat data has been used in criminal prosecutions. Social media platforms are leveraged for political coordination and surveillance. When you organize on corporate infrastructure, you hand your strategies, your voter data, and your movement's internal conversations to entities that may have every reason to work against you.
|
||||
|
||||
Changemaker Lite exists because we believe **organizational independence requires technological independence.**
|
||||
|
||||
---
|
||||
|
||||
## The Extractive Model
|
||||
|
||||
Most campaign and political software is extractive by design. The pattern is familiar:
|
||||
|
||||
1. **Free trial** hooks you in
|
||||
2. **Paid features** gate the tools you actually need
|
||||
3. **Data export** becomes difficult or impossible
|
||||
4. **Pricing escalates** as you grow and become dependent
|
||||
5. **Your usage patterns** are monetized through data partnerships, behavioral analytics, and enterprise contracts
|
||||
|
||||
This isn't a side effect — it's the business model. You pay with money *and* with data. Your voter lists, canvassing outcomes, donor records, and communication patterns become assets on someone else's balance sheet.
|
||||
|
||||
Every subscription to corporate software funds the machine you're fighting.
|
||||
|
||||
---
|
||||
|
||||
## The Alternative: Grow Power, Don't Rent It
|
||||
|
||||
Changemaker asks a different question than most political tech: instead of **"how do we extract more data from a community?"** we ask **"what tools are needed to grow change in a community?"**
|
||||
|
||||
Growing change means:
|
||||
|
||||
- **Making real connections** between organizers, volunteers, and community members — not just collecting their contact info
|
||||
- **Providing access** to the same caliber of tools that well-funded campaigns use — without the price tag or the surveillance
|
||||
- **Deeply understanding** the wants and needs of your movement — on infrastructure you control, with data you own
|
||||
|
||||
---
|
||||
|
||||
## Distributed Organizing Is The Way Out
|
||||
|
||||
Socialist movements will never outspend capital. Progressive organizations cannot compete financially with well-funded conservative movements, and chasing big-donor dollars leads to mission drift and organizational capture — what some call the Political Industrial Complex.
|
||||
|
||||
**A thousand neighborhood mailing lists has more potential impact than any single organization.** When organizing knowledge and digital tools are widely distributed — not gatekept by leadership or locked behind vendor paywalls — movements become genuinely resilient.
|
||||
|
||||
The historical pattern is clear: worker victories occurred when organizing knowledge was widely distributed, not concentrated at the top. Changemaker Lite is built on this premise — provide the tools freely, train people to use them, and get out of the way.
|
||||
|
||||
Workers, with the right tools, will build the future.
|
||||
|
||||
---
|
||||
|
||||
## De-Corp Your Stack
|
||||
|
||||
The practical work of digital sovereignty starts with replacing corporate services one at a time:
|
||||
|
||||
| Corporate Tool | Changemaker Alternative | What You Gain |
|
||||
|---|---|---|
|
||||
| Mailchimp | Listmonk | Unlimited subscribers, no per-send charges, your data stays local |
|
||||
| NationBuilder | Changemaker Lite | Full campaign platform without the $50-500/month ransom |
|
||||
| Google Docs | Gitea + Code Server | Version control, collaboration, no algorithmic scanning |
|
||||
| Slack | Rocket.Chat | Team chat with SSO, no message limits, no corporate eavesdropping |
|
||||
| SurveyMonkey | Response Wall | Supporter voices on your terms, with moderation you control |
|
||||
| Google Maps | Self-hosted Leaflet | No API fees, no tracking, offline-capable canvassing |
|
||||
|
||||
**The cost reduction is dramatic.** Organizations spending thousands monthly on SaaS tools can replace them with a single self-hosted server running Changemaker Lite for roughly the cost of hosting — often under $50/month.
|
||||
|
||||
But the real value isn't cost savings. It's **control.** No vendor can cut off your access. No acquisition can change your terms. No government can compel a foreign company to hand over your data. Your movement's digital infrastructure belongs to your movement.
|
||||
|
||||
---
|
||||
|
||||
## Security Culture Starts With Infrastructure
|
||||
|
||||
Security culture isn't just about who knows what — it's about who *can* know what. When your communications run through corporate servers, you've made a structural decision about who has access before you've even thought about operational security.
|
||||
|
||||
Key principles:
|
||||
|
||||
- **Compartmentalization by design** — Self-hosted systems let you control exactly who has access to what, at the infrastructure level
|
||||
- **No third-party access** — No corporate subpoenas for your data, no partnership agreements sharing your information
|
||||
- **Audit everything** — When you run the servers, you can verify that your security promises are real, not just marketing
|
||||
- **Consent and autonomy** — Your community sets its own security boundaries rather than accepting whatever a vendor's privacy policy allows
|
||||
|
||||
You wouldn't hold a sensitive strategy meeting in a room wired by someone else. Why would you plan your campaign on someone else's servers?
|
||||
|
||||
---
|
||||
|
||||
## Our Principles
|
||||
|
||||
**Liberation First**
|
||||
Technology should center marginalized voices. The tools we build reflect the values we hold, and they shape the movements that use them.
|
||||
|
||||
**Community Over Profit**
|
||||
Changemaker Lite is free and open source software, built by a cooperative — not a startup looking for an exit. There are no shareholders to satisfy, no venture capitalists to answer to. The software serves the community because that's the only thing it's designed to do.
|
||||
|
||||
**Data Sovereignty**
|
||||
Communities should own their complete digital infrastructure. Not just the content — the servers, the databases, the encryption keys, and the ability to pack up and leave at any time.
|
||||
|
||||
**Radical Accessibility**
|
||||
Self-hosted doesn't have to mean self-excluding. Changemaker Lite is designed for organizers, not sysadmins. If you can follow a guide to set up a WordPress site, you can run this platform.
|
||||
|
||||
---
|
||||
|
||||
## Further Reading
|
||||
|
||||
These articles explore the ideas behind Changemaker Lite in depth:
|
||||
|
||||
- [If You Do Politics, Who Is Reading Your Secrets?](https://docs.bnkops.com/archive/repo.archive/thatreallyblondehuman/Thoughts%20%F0%9F%A4%94/If%20you%20do%20politics%20who%20is%20reading%20your%20secrets%20-%20why%20you%20should%20de-corp%20your%20software%20stack/) — Why you should de-corp your software stack
|
||||
- [Distributed Digital Organizing Is The Way Out](https://docs.bnkops.com/archive/repo.archive/thatreallyblondehuman/Thoughts%20%F0%9F%A4%94/Distributed%20Digital%20Organizing%20is%20The%20Way%20Out/) — Why decentralized power structures outperform centralized ones
|
||||
- [How Not To Get Got Making Content](https://docs.bnkops.com/archive/repo.archive/thatreallyblondehuman/Thoughts%20%F0%9F%A4%94/How%20not%20to%20get%20got%20making%20content%20v2/) — Platform independence for political content creators
|
||||
- [What Is Security Culture?](https://docs.bnkops.com/archive/repo.archive/Zines%20We%20Like%20%F0%9F%98%8E/What%20Is%20Security%20Culture%20%E2%98%A0/) — The foundations of security culture for movements
|
||||
|
||||
@@ -34,7 +34,7 @@ The essential services that power the application.
|
||||
|
||||
**Port:** `4100` · **Container:** `changemaker-media-api`
|
||||
|
||||
[:octicons-arrow-right-24: Media Guide](../features/index.md)
|
||||
[:octicons-arrow-right-24: Media Guide](../admin/media/index.md)
|
||||
|
||||
- :material-react:{ .lg .middle } **Admin GUI**
|
||||
|
||||
@@ -44,7 +44,7 @@ The essential services that power the application.
|
||||
|
||||
**Port:** `3000` · **Container:** `changemaker-v2-admin`
|
||||
|
||||
[:octicons-arrow-right-24: Feature Guides](../features/index.md)
|
||||
[:octicons-arrow-right-24: Feature Guides](../getting-started/features.md)
|
||||
|
||||
- :material-database:{ .lg .middle } **PostgreSQL 16**
|
||||
|
||||
@@ -234,6 +234,16 @@ The essential services that power the application.
|
||||
|
||||
[:octicons-arrow-right-24: Rocket.Chat Docs](https://docs.rocket.chat/){ target="_blank" }
|
||||
|
||||
- :material-video:{ .lg .middle } **Jitsi Meet**
|
||||
|
||||
---
|
||||
|
||||
Self-hosted video conferencing with JWT authentication. Four containers (web, Prosody, Jicofo, JVB) provide the full video call stack. Integrated with Rocket.Chat for one-click calls from channels and DMs. Enable with `ENABLE_MEET=true`.
|
||||
|
||||
**Containers:** `jitsi-web`, `jitsi-prosody`, `jitsi-jicofo`, `jitsi-jvb` · **Subdomain:** `meet.DOMAIN`
|
||||
|
||||
[:octicons-arrow-right-24: Setup Guide](../admin/services/integrations.md) · [:octicons-arrow-right-24: Jitsi Docs](https://jitsi.github.io/handbook/){ target="_blank" }
|
||||
|
||||
- :material-calendar-multiple:{ .lg .middle } **Gancio**
|
||||
|
||||
---
|
||||
@@ -375,6 +385,7 @@ All services at a glance with their default ports and subdomains.
|
||||
| Excalidraw | 8090 | `draw.` | default |
|
||||
| Vaultwarden | 8445 | `vault.` | default |
|
||||
| Rocket.Chat | — | `chat.` | default |
|
||||
| Jitsi Meet | — | `meet.` | default |
|
||||
| Gancio | 8092 | `events.` | default |
|
||||
| Newt (tunnel) | — | — | default |
|
||||
| Prometheus | 9090 | — | `monitoring` |
|
||||
|
||||
41
mkdocs/docs/docs/user-guide/campaigns.md
Normal file
41
mkdocs/docs/docs/user-guide/campaigns.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: Campaigns
|
||||
description: Find advocacy campaigns, look up your representatives by postal code, and send emails.
|
||||
icon: material/email-fast
|
||||
---
|
||||
|
||||
# Campaigns
|
||||
|
||||
Browse active advocacy campaigns and contact your elected representatives.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Browse campaigns** at `/campaigns` — see active campaigns with descriptions and email counts
|
||||
2. **Pick a campaign** — read about the issue and who it targets
|
||||
3. **Enter your postal code** — the system looks up your federal, provincial, municipal, and school board representatives
|
||||
4. **Send the email** — use "Send Now" to send through the platform, or open it in your own email app (Gmail, Outlook, etc.)
|
||||
5. **Share the response** — if a representative replies, submit it to the public Response Wall
|
||||
|
||||
---
|
||||
|
||||
## Response Wall
|
||||
|
||||
Each campaign has a public response wall where supporters share how their representatives responded. Responses can be upvoted and are moderated by admins. Verified responses display a trust badge.
|
||||
|
||||
---
|
||||
|
||||
## Submit Your Own Campaign
|
||||
|
||||
Registered users can draft and submit their own advocacy campaigns at `/campaigns/create`. Submissions go through admin review before being published.
|
||||
|
||||
---
|
||||
|
||||
## Public Routes
|
||||
|
||||
- `/campaigns` — browse active campaigns
|
||||
- `/campaign/:slug` — take action on a specific campaign
|
||||
- `/campaign/:slug/responses` — view the response wall
|
||||
- `/campaigns/create` — submit a user-generated campaign (requires login)
|
||||
- `/campaigns/mine` — manage your submitted campaigns (requires login)
|
||||
37
mkdocs/docs/docs/user-guide/donations.md
Normal file
37
mkdocs/docs/docs/user-guide/donations.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: Donations
|
||||
description: Support the cause with one-time donations on branded pages with goals and suggested amounts.
|
||||
icon: material/hand-heart
|
||||
---
|
||||
|
||||
# Donations
|
||||
|
||||
Make one-time contributions on branded donation pages.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Browse donation pages at `/donate`
|
||||
2. Choose a campaign to support
|
||||
3. Select a suggested amount or enter a custom amount
|
||||
4. Complete payment securely through Stripe
|
||||
5. Receive a confirmation with a thank-you message
|
||||
|
||||
---
|
||||
|
||||
## Donation Pages
|
||||
|
||||
Each donation page has:
|
||||
|
||||
- **Custom branding** — unique title, description, and cover image
|
||||
- **Suggested amounts** — pre-set donation tiers for quick selection
|
||||
- **Goal tracking** — progress bar showing how close the campaign is to its fundraising goal
|
||||
- **Anonymous giving** — option to donate without displaying your name
|
||||
|
||||
---
|
||||
|
||||
## Public Routes
|
||||
|
||||
- `/donate` — browse donation pages
|
||||
- `/donate/:slug` — donate on a specific campaign page
|
||||
40
mkdocs/docs/docs/user-guide/events.md
Normal file
40
mkdocs/docs/docs/user-guide/events.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: Events (Gancio)
|
||||
description: Self-hosted event management with automatic shift-to-event sync powered by Gancio.
|
||||
icon: material/calendar-star
|
||||
---
|
||||
|
||||
# Events (Gancio)
|
||||
|
||||
Integrated with [Gancio](https://gancio.org/) for self-hosted event management. When enabled, volunteer shifts are automatically published as public events.
|
||||
|
||||
---
|
||||
|
||||
## Shift-to-Event Sync
|
||||
|
||||
When `GANCIO_SYNC_ENABLED=true`, the platform:
|
||||
|
||||
1. **Creates** a Gancio event whenever a new shift is published
|
||||
2. **Updates** the event if the shift time, location, or details change
|
||||
3. **Deletes** the event if the shift is cancelled
|
||||
|
||||
Sync uses OAuth authentication with the Gancio admin account.
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Automatic sync** — shifts appear as public events without manual entry
|
||||
- **Embeddable calendar** — GrapesJS block and MkDocs widget for embedding the event calendar on pages
|
||||
- **Public events page** — linked from the public navigation when `enableEvents` is enabled in settings
|
||||
|
||||
---
|
||||
|
||||
## Admin Routes
|
||||
|
||||
- `/app/gancio` — Gancio service status and iframe embed
|
||||
|
||||
## Public Routes
|
||||
|
||||
- `/events` — public events navigation link (when enabled)
|
||||
- `events.DOMAIN` — Gancio web interface for browsing and RSVPs
|
||||
41
mkdocs/docs/docs/user-guide/gallery.md
Normal file
41
mkdocs/docs/docs/user-guide/gallery.md
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: Gallery
|
||||
description: Watch campaign videos, browse photos, explore playlists, and view shorts.
|
||||
icon: material/play-box-multiple
|
||||
---
|
||||
|
||||
# Gallery
|
||||
|
||||
The public gallery at `/gallery` showcases campaign videos, photos, and curated playlists.
|
||||
|
||||
---
|
||||
|
||||
## Videos
|
||||
|
||||
- **Browse by category** — videos organized into categories with thumbnails and durations
|
||||
- **Video player** — full playback with engagement features (reactions, comments)
|
||||
- **Shorts feed** — TikTok-style vertical video feed for clips under 60 seconds at `/gallery/shorts`
|
||||
|
||||
---
|
||||
|
||||
## Photos
|
||||
|
||||
- **Photo albums** — browse photos organized into named collections
|
||||
- **Reactions and comments** — engage with individual photos
|
||||
|
||||
---
|
||||
|
||||
## Playlists
|
||||
|
||||
- **Curated playlists** — admin and community-created video collections
|
||||
- **Featured carousel** — highlighted playlists on the gallery homepage
|
||||
- **Playlist viewer** — continuous playback with up-next queue at `/gallery/playlist/:id`
|
||||
|
||||
---
|
||||
|
||||
## Public Routes
|
||||
|
||||
- `/gallery` — public video and photo gallery
|
||||
- `/gallery/watch/:id` — watch a specific video
|
||||
- `/gallery/playlist/:id` — view a playlist
|
||||
- `/gallery/shorts` — browse the shorts feed
|
||||
65
mkdocs/docs/docs/user-guide/index.md
Normal file
65
mkdocs/docs/docs/user-guide/index.md
Normal file
@@ -0,0 +1,65 @@
|
||||
---
|
||||
title: User Guide
|
||||
description: How to use Changemaker Lite as a public visitor or registered supporter.
|
||||
icon: material/account
|
||||
---
|
||||
|
||||
# User Guide
|
||||
|
||||
This guide covers everything you can do as a visitor or registered supporter — from contacting representatives to signing up for volunteer shifts.
|
||||
|
||||
---
|
||||
|
||||
## What You Can Do
|
||||
|
||||
<div class="grid cards" markdown>
|
||||
|
||||
- :material-email-fast:{ .lg .middle } **[Campaigns](campaigns.md)**
|
||||
|
||||
---
|
||||
|
||||
Find active advocacy campaigns, look up your representatives, and send emails.
|
||||
|
||||
- :material-map:{ .lg .middle } **[Map](map.md)**
|
||||
|
||||
---
|
||||
|
||||
Explore the interactive map showing locations across your community.
|
||||
|
||||
- :material-calendar-clock:{ .lg .middle } **[Shifts](shifts.md)**
|
||||
|
||||
---
|
||||
|
||||
Sign up for volunteer shifts and join canvassing teams.
|
||||
|
||||
- :material-calendar-star:{ .lg .middle } **[Events](events.md)**
|
||||
|
||||
---
|
||||
|
||||
Browse upcoming events and RSVP through the event calendar.
|
||||
|
||||
- :material-play-box-multiple:{ .lg .middle } **[Gallery](gallery.md)**
|
||||
|
||||
---
|
||||
|
||||
Watch campaign videos, browse photos, and explore playlists.
|
||||
|
||||
- :material-shopping:{ .lg .middle } **[Shop & Pricing](shop.md)**
|
||||
|
||||
---
|
||||
|
||||
Purchase campaign merchandise or subscribe to a membership plan.
|
||||
|
||||
- :material-hand-heart:{ .lg .middle } **[Donations](donations.md)**
|
||||
|
||||
---
|
||||
|
||||
Support the cause with one-time donations on branded pages.
|
||||
|
||||
- :material-card-account-details:{ .lg .middle } **[Your Profile](profile.md)**
|
||||
|
||||
---
|
||||
|
||||
View and manage your contact profile, preferences, and activity history.
|
||||
|
||||
</div>
|
||||
24
mkdocs/docs/docs/user-guide/map.md
Normal file
24
mkdocs/docs/docs/user-guide/map.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: Map
|
||||
description: Explore the interactive community map showing locations across your area.
|
||||
icon: material/map
|
||||
---
|
||||
|
||||
# Map
|
||||
|
||||
The public map at `/map` shows locations across your community on an interactive Leaflet map.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Interactive map** — zoom, pan, and click markers to see address details
|
||||
- **Color-coded markers** — locations are color-coded based on their status
|
||||
- **Cluster groups** — markers group together when zoomed out for better performance
|
||||
- **Fullscreen mode** — expand the map to fill your screen
|
||||
|
||||
---
|
||||
|
||||
## Public Routes
|
||||
|
||||
- `/map` — public interactive map
|
||||
49
mkdocs/docs/docs/user-guide/profile.md
Normal file
49
mkdocs/docs/docs/user-guide/profile.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Self-Service Contact Profile
|
||||
description: Token-based CRM contact profiles where supporters can view their engagement history, edit their details, manage communication preferences, and opt out.
|
||||
icon: material/card-account-details
|
||||
---
|
||||
|
||||
# Self-Service Contact Profile
|
||||
|
||||
Give supporters a private, token-based link to view and manage their own contact profile -- no login required.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **An admin generates a profile link** from the People CRM -- each link contains a unique 64-character hex token with a configurable expiration (24 hours to 1 year).
|
||||
2. **The supporter opens the link** -- if the link is password-protected, they enter the password first. If expired, they see a branded expiration notice.
|
||||
3. **The supporter views and edits their profile** -- they can update their name, email, phone, address, and cover photo.
|
||||
4. **Communication preferences** -- supporters can opt out of email and/or SMS communications with simple toggle switches.
|
||||
|
||||
---
|
||||
|
||||
## Profile Tabs
|
||||
|
||||
- **Profile** -- edit display name, first/last name, email, phone, and address
|
||||
- **Preferences** -- toggle email and SMS opt-out switches
|
||||
- **Activity** -- paginated timeline of all engagement: emails sent, responses submitted, shift signups, canvass visits, donations, video views, and profile edits
|
||||
- **Social tabs** -- if the viewer is a logged-in user viewing their own profile and the social feature is enabled, additional tabs appear: Friends, Feed, Achievements, Notifications, and Discover
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
- **Token-based access** -- no account or login needed; the URL token grants access
|
||||
- **Password protection** -- admins can optionally set a password on the profile link
|
||||
- **Expiration** -- links expire after a configurable duration, showing a branded message with the expiration date
|
||||
- **Rate limiting** -- separate rate limits on profile views, edits, photo uploads, and password attempts
|
||||
- **Cover photo** -- supporters can upload a JPEG, PNG, or WebP cover photo (max 5 MB), automatically resized to 800x400
|
||||
|
||||
---
|
||||
|
||||
## Engagement Score
|
||||
|
||||
Each profile shows a circular engagement score (0-100) calculated from the contact's activity across the platform -- emails, shifts, canvass visits, donations, and video views.
|
||||
|
||||
---
|
||||
|
||||
## Public Routes
|
||||
|
||||
- `/profile/:token` -- self-service contact profile page
|
||||
49
mkdocs/docs/docs/user-guide/shifts.md
Normal file
49
mkdocs/docs/docs/user-guide/shifts.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Shifts
|
||||
description: Sign up for volunteer shifts, join canvassing teams, and get started quickly with QR codes.
|
||||
icon: material/calendar-clock
|
||||
---
|
||||
|
||||
# Shifts
|
||||
|
||||
Browse available volunteer shifts and sign up to participate in canvassing and other campaign activities.
|
||||
|
||||
---
|
||||
|
||||
## Signing Up
|
||||
|
||||
1. Visit `/shifts` to see available time slots
|
||||
2. Pick a shift that works for your schedule
|
||||
3. Fill in your name and email
|
||||
4. You'll receive a confirmation email with login credentials
|
||||
|
||||
---
|
||||
|
||||
## Quick Join
|
||||
|
||||
Organizers may share a **QR code** at events for instant onboarding:
|
||||
|
||||
1. **Scan the QR code** — it opens a Quick Join page
|
||||
2. **Enter your email** (and optionally your name and phone)
|
||||
3. **Start immediately** — you're logged in and redirected to the volunteer portal with your area pre-loaded
|
||||
|
||||
Quick Join creates a temporary 24-hour account. Your organizer can upgrade it to a permanent account afterward.
|
||||
|
||||
---
|
||||
|
||||
## After Signing Up
|
||||
|
||||
Once you have an account, log in to access the **Volunteer Portal** where you can:
|
||||
|
||||
- View your assigned shifts and canvassing areas
|
||||
- Open the canvass map for GPS-guided door-to-door outreach
|
||||
- Track your activity and visit history
|
||||
|
||||
See the [Volunteer Guide](../volunteer/index.md) for the full volunteer experience.
|
||||
|
||||
---
|
||||
|
||||
## Public Routes
|
||||
|
||||
- `/shifts` — browse and sign up for volunteer shifts
|
||||
- `/join?token=...` — quick join via invite link or QR code
|
||||
36
mkdocs/docs/docs/user-guide/shop.md
Normal file
36
mkdocs/docs/docs/user-guide/shop.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: Shop & Pricing
|
||||
description: Browse campaign merchandise and membership subscription plans.
|
||||
icon: material/shopping
|
||||
---
|
||||
|
||||
# Shop & Pricing
|
||||
|
||||
Support the campaign by purchasing merchandise or subscribing to a membership plan.
|
||||
|
||||
---
|
||||
|
||||
## Shop
|
||||
|
||||
Browse available products at `/shop`:
|
||||
|
||||
- Campaign merchandise and branded items
|
||||
- One-time purchases with Stripe checkout
|
||||
- Product details with images and descriptions
|
||||
|
||||
---
|
||||
|
||||
## Membership Plans
|
||||
|
||||
View subscription options at `/pricing`:
|
||||
|
||||
- Tiered membership plans with different benefits
|
||||
- Monthly and yearly billing options
|
||||
- Secure recurring payments through Stripe
|
||||
|
||||
---
|
||||
|
||||
## Public Routes
|
||||
|
||||
- `/shop` — browse products
|
||||
- `/pricing` — view subscription plans
|
||||
88
mkdocs/docs/docs/volunteer/achievements.md
Normal file
88
mkdocs/docs/docs/volunteer/achievements.md
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
title: Achievements & Leaderboard
|
||||
description: Badge-based gamification and volunteer leaderboards to recognize and encourage platform participation.
|
||||
icon: material/trophy
|
||||
---
|
||||
|
||||
# Achievements & Leaderboard
|
||||
|
||||
Recognize volunteer contributions with unlockable achievement badges and competitive leaderboards.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
Achievements are checked automatically when relevant actions occur (e.g., signing up for a shift, completing a canvass session, accepting a friend request). When a user's progress meets the threshold, the badge is unlocked and an in-app notification is sent.
|
||||
|
||||
---
|
||||
|
||||
## Badge Categories
|
||||
|
||||
### Shifts
|
||||
|
||||
| Badge | Name | Threshold | Description |
|
||||
|-------|------|-----------|-------------|
|
||||
| FIRST_SHIFT | First Steps | 1 confirmed signup | Sign up for your first volunteer shift |
|
||||
| SHIFT_STREAK_3 | Reliable Volunteer | 3 confirmed signups | Sign up for 3 volunteer shifts |
|
||||
| SHIFT_STREAK_10 | Shift Champion | 10 confirmed signups | Sign up for 10 volunteer shifts |
|
||||
|
||||
### Canvassing
|
||||
|
||||
| Badge | Name | Threshold | Description |
|
||||
|-------|------|-----------|-------------|
|
||||
| FIRST_CANVASS | Door Knocker | 1 completed session | Complete your first canvass session |
|
||||
| CANVASS_50_DOORS | Neighbourhood Explorer | 50 visits | Record 50 canvass visits |
|
||||
| CANVASS_100_DOORS | Community Connector | 100 visits | Record 100 canvass visits |
|
||||
| CANVASS_500_DOORS | Door-to-Door Legend | 500 visits | Record 500 canvass visits |
|
||||
|
||||
### Campaigns
|
||||
|
||||
| Badge | Name | Threshold | Description |
|
||||
|-------|------|-----------|-------------|
|
||||
| FIRST_CAMPAIGN_EMAIL | Voice Heard | 1 email sent | Send your first advocacy email |
|
||||
| CAMPAIGN_CHAMPION | Campaign Champion | 5 distinct campaigns | Participate in 5 different campaigns |
|
||||
|
||||
### Social
|
||||
|
||||
| Badge | Name | Threshold | Description |
|
||||
|-------|------|-----------|-------------|
|
||||
| SOCIAL_BUTTERFLY | Social Butterfly | 10 accepted friends | Make 10 friends on the platform |
|
||||
| TEAM_PLAYER | Team Player | 3 group memberships | Be a member of 3 groups |
|
||||
|
||||
---
|
||||
|
||||
## Progress Tracking
|
||||
|
||||
Each badge displays a progress bar showing current progress toward the threshold. Already-unlocked badges show the unlock date and the progress value at unlock time or the current count (whichever is higher).
|
||||
|
||||
---
|
||||
|
||||
## Leaderboards
|
||||
|
||||
The Achievements page includes a leaderboard tab with three ranking types:
|
||||
|
||||
- **Canvass** — ranked by total canvass visits recorded
|
||||
- **Shifts** — ranked by total confirmed shift signups
|
||||
- **Campaigns** — ranked by number of distinct campaigns participated in
|
||||
|
||||
Leaderboard entries show rank, user name, and score. Users who have disabled "Show in Friend Activity" in their privacy settings are excluded from leaderboard rankings to respect their privacy choices.
|
||||
|
||||
---
|
||||
|
||||
## Volunteer Stats
|
||||
|
||||
The Achievements page also displays aggregate stats for the current user:
|
||||
|
||||
- Confirmed shift signups
|
||||
- Completed canvass sessions
|
||||
- Total canvass visits
|
||||
- Advocacy emails sent
|
||||
- Campaigns participated in
|
||||
- Friend count
|
||||
- Group memberships
|
||||
|
||||
---
|
||||
|
||||
## Volunteer Routes
|
||||
|
||||
- `/volunteer/achievements` — badge gallery, progress bars, leaderboard tabs, and personal stats
|
||||
57
mkdocs/docs/docs/volunteer/canvassing.md
Normal file
57
mkdocs/docs/docs/volunteer/canvassing.md
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: Canvassing
|
||||
description: GPS-guided door-to-door canvassing with the volunteer map, visit recording, and walking routes.
|
||||
icon: material/map-marker-path
|
||||
---
|
||||
|
||||
# Canvassing
|
||||
|
||||
The volunteer canvass map is your main tool for door-to-door outreach — a full-screen GPS-tracked experience.
|
||||
|
||||
---
|
||||
|
||||
## The Volunteer Map
|
||||
|
||||
### What You See
|
||||
|
||||
- **Colored markers** — each marker is an address. Colors indicate the outcome of the last visit (green = supportive, red = opposed, grey = not yet visited)
|
||||
- **Clusters** — when zoomed out, markers group together showing the address count. Tap a cluster to zoom in.
|
||||
- **Blue dot** — your current GPS position
|
||||
- **Walking route** — a suggested path through the addresses (dotted line)
|
||||
|
||||
### Recording a Visit
|
||||
|
||||
1. **Tap a marker** to select an address
|
||||
2. A **bottom panel** slides up showing address details
|
||||
3. Tap **Record Visit** to log what happened:
|
||||
- **Not Home** — nobody answered
|
||||
- **Supportive** — positive interaction
|
||||
- **Opposed** — not supportive
|
||||
- **Undecided** — hasn't made up their mind
|
||||
- **Moved** — no longer lives there
|
||||
- **Refused** — declined to talk
|
||||
4. Optionally add a **note** about the visit
|
||||
5. Tap **Save** — the marker color updates immediately
|
||||
|
||||
---
|
||||
|
||||
## Sessions
|
||||
|
||||
- **Start a session** before you begin knocking on doors — this tracks your route and time
|
||||
- **End your session** when you're done for the day
|
||||
- The map works offline for basic viewing, but you need a connection to save visits
|
||||
- If GPS is inaccurate, manually tap the correct marker on the map
|
||||
|
||||
---
|
||||
|
||||
## Routes
|
||||
|
||||
The **Routes** tab shows your past canvassing routes on a map, helping you see which areas you've covered and plan your next outing.
|
||||
|
||||
---
|
||||
|
||||
## Volunteer Routes
|
||||
|
||||
- `/volunteer` — full-screen canvass map with GPS and visit recording
|
||||
- `/volunteer/activity` — visit history and outcome breakdown
|
||||
- `/volunteer/routes` — past canvassing routes
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
title: Volunteer Guide
|
||||
description: Guide for volunteers using the canvassing and shift signup features.
|
||||
description: Getting started as a volunteer — shifts, canvassing, social connections, and achievements.
|
||||
icon: material/walk
|
||||
---
|
||||
|
||||
# Volunteer Guide
|
||||
|
||||
Welcome! This guide walks you through everything you need to know as a campaign volunteer — from signing up for your first shift to canvassing door-to-door with the GPS map.
|
||||
Welcome! This guide walks you through everything you need as a campaign volunteer.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,94 +14,48 @@ Welcome! This guide walks you through everything you need to know as a campaign
|
||||
|
||||
### 1. Sign Up for a Shift
|
||||
|
||||
Visit the **Shifts** page (your organizer will share the link, or find it at `/shifts`). Browse available time slots, pick one that works for you, and fill in your name and email. You'll receive a confirmation email with your login credentials.
|
||||
Visit the **Shifts** page (your organizer will share the link, or find it at `/shifts`). Browse available time slots, pick one that works, and fill in your name and email. You'll receive a confirmation email with login credentials.
|
||||
|
||||
### 2. Log In
|
||||
|
||||
Go to the login page and sign in with the email and password from your confirmation. If you already have an account, just sign in normally.
|
||||
Sign in with the email and password from your confirmation at the login page.
|
||||
|
||||
### 3. Open the Volunteer Portal
|
||||
### 3. Explore the Volunteer Portal
|
||||
|
||||
After logging in, you'll land on the **volunteer map** — a full-screen view of your assigned canvassing area. This is your home base.
|
||||
After logging in, you'll land on the volunteer portal. Use the bottom navigation to access:
|
||||
|
||||
- **Map** — your canvassing area with GPS tracking
|
||||
- **Shifts** — your upcoming and past shifts
|
||||
- **Friends** — social connections with other volunteers
|
||||
- **Achievements** — badges and leaderboards
|
||||
|
||||
---
|
||||
|
||||
## The Volunteer Map
|
||||
## In This Section
|
||||
|
||||
The volunteer map is your main tool for canvassing. It shows all the addresses in your assigned area and tracks your position with GPS.
|
||||
|
||||
### What You See
|
||||
|
||||
- **Colored markers** — each marker is an address. Colors indicate the outcome of the last visit (green = supportive, red = opposed, grey = not yet visited, etc.)
|
||||
- **Clusters** — when zoomed out, markers group together and show the number of addresses in that area. Tap a cluster to zoom in.
|
||||
- **Blue dot** — your current GPS position
|
||||
- **Walking route** — a suggested path through the addresses (dotted line)
|
||||
|
||||
### Recording a Visit
|
||||
|
||||
1. **Tap a marker** to select an address
|
||||
2. A **bottom panel** slides up showing the address details
|
||||
3. Tap **Record Visit** to log what happened:
|
||||
- **Not Home** — nobody answered
|
||||
- **Supportive** — positive interaction
|
||||
- **Opposed** — not supportive
|
||||
- **Undecided** — hasn't made up their mind
|
||||
- **Moved** — no longer lives there
|
||||
- **Refused** — declined to talk
|
||||
4. Optionally add a **note** about the visit
|
||||
5. Tap **Save** — the marker color updates immediately
|
||||
|
||||
### Tips for Canvassing
|
||||
|
||||
- **Start a session** before you begin knocking on doors — this tracks your route and time
|
||||
- **End your session** when you're done for the day
|
||||
- The map works offline for basic viewing, but you need a connection to save visits
|
||||
- If GPS is inaccurate, you can manually tap the correct marker on the map
|
||||
|
||||
---
|
||||
|
||||
## Your Shifts
|
||||
|
||||
Visit **Shifts** in the bottom navigation to see your upcoming and past shifts. Each shift shows:
|
||||
|
||||
- Date and time
|
||||
- Assigned area (if linked)
|
||||
- A button to open the canvass map for that area
|
||||
|
||||
---
|
||||
|
||||
## Activity Log
|
||||
|
||||
The **Activity** tab shows your complete visit history:
|
||||
|
||||
- **Outcome breakdown** — pie chart of your visit outcomes
|
||||
- **Visit list** — each visit with address, outcome, time, and any notes
|
||||
- **Stats** — total visits, addresses covered, and sessions completed
|
||||
|
||||
---
|
||||
|
||||
## Routes
|
||||
|
||||
The **Routes** tab shows your past canvassing routes on a map. This helps you see which areas you've covered and plan your next outing.
|
||||
- **[Canvassing](canvassing.md)** — the GPS-guided canvass map, recording visits, and walking routes
|
||||
- **[Shifts](shifts.md)** — viewing your assigned shifts, activity log, and route history
|
||||
- **[Social](social.md)** — friend connections, activity feed, groups, profiles, and privacy settings
|
||||
- **[Achievements](achievements.md)** — unlockable badges, progress tracking, and competitive leaderboards
|
||||
|
||||
---
|
||||
|
||||
## Browsing Public Pages
|
||||
|
||||
Tap your name/avatar in the header and select **Browse Site** to visit the public pages — campaigns, the public map, and shift signups. This is useful for sharing links with friends or checking campaign progress.
|
||||
Tap your name/avatar in the header and select **Browse Site** to visit the public pages — campaigns, the public map, and shift signups.
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: I can't find my assigned area on the map.**
|
||||
A: Make sure your shift has an area assigned. Check with your organizer if nothing appears.
|
||||
A: Make sure your shift has an area assigned. Check with your organizer.
|
||||
|
||||
**Q: My GPS isn't working.**
|
||||
A: Make sure you've allowed location access in your browser. Try moving to a window or stepping outside for better signal.
|
||||
A: Allow location access in your browser. Try moving near a window for better signal.
|
||||
|
||||
**Q: I accidentally recorded the wrong outcome.**
|
||||
A: Visit the same address again and record the correct outcome. The most recent visit is what counts.
|
||||
**Q: I recorded the wrong outcome.**
|
||||
A: Visit the same address again and record the correct outcome. The most recent visit counts.
|
||||
|
||||
**Q: How do I sign up for more shifts?**
|
||||
A: Visit the public shifts page (ask your organizer for the link, or go to `/shifts`).
|
||||
A: Visit the public shifts page at `/shifts`.
|
||||
|
||||
36
mkdocs/docs/docs/volunteer/shifts.md
Normal file
36
mkdocs/docs/docs/volunteer/shifts.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: Your Shifts
|
||||
description: View your assigned volunteer shifts, activity history, and canvassing stats.
|
||||
icon: material/calendar-check
|
||||
---
|
||||
|
||||
# Your Shifts
|
||||
|
||||
View your upcoming and past volunteer shifts from the **Shifts** tab in the bottom navigation.
|
||||
|
||||
---
|
||||
|
||||
## Shift Details
|
||||
|
||||
Each shift shows:
|
||||
|
||||
- Date and time
|
||||
- Assigned area (if linked to a canvassing territory)
|
||||
- A button to open the canvass map for that area
|
||||
|
||||
---
|
||||
|
||||
## Activity Log
|
||||
|
||||
The **Activity** tab shows your complete visit history:
|
||||
|
||||
- **Outcome breakdown** — pie chart of your visit outcomes
|
||||
- **Visit list** — each visit with address, outcome, time, and notes
|
||||
- **Stats** — total visits, addresses covered, and sessions completed
|
||||
|
||||
---
|
||||
|
||||
## Volunteer Routes
|
||||
|
||||
- `/volunteer/shifts` — view assigned shifts
|
||||
- `/volunteer/activity` — visit history and outcome breakdown
|
||||
87
mkdocs/docs/docs/volunteer/social.md
Normal file
87
mkdocs/docs/docs/volunteer/social.md
Normal file
@@ -0,0 +1,87 @@
|
||||
---
|
||||
title: Social Connections
|
||||
description: Connect with fellow volunteers through friend requests, activity feeds, groups, and real-time notifications.
|
||||
icon: material/account-heart
|
||||
---
|
||||
|
||||
# Social Connections
|
||||
|
||||
Connect with fellow volunteers through friend requests, activity feeds, team groups, and real-time notifications. Enable via **Settings > Feature Toggles > Social Connections**.
|
||||
|
||||
---
|
||||
|
||||
## Friends
|
||||
|
||||
- **Send requests** — search for other volunteers and send friend requests
|
||||
- **Accept / decline / cancel** — manage requests from the Friends page
|
||||
- **Mutual friends** — view shared connections between users
|
||||
- **Block / unblock** — blocked users cannot send requests or appear in suggestions
|
||||
|
||||
---
|
||||
|
||||
## Discover
|
||||
|
||||
The Discover page suggests potential friends using a ranked scoring algorithm based on:
|
||||
|
||||
- Household/family connections (highest priority)
|
||||
- Mutual friends
|
||||
- Shared shifts (co-volunteers from the last 90 days)
|
||||
- Shared campaigns (co-participants from the last 90 days)
|
||||
|
||||
---
|
||||
|
||||
## Activity Feed
|
||||
|
||||
The Social Feed at `/volunteer/feed` shows recent activity from your friends:
|
||||
|
||||
- Shift signups, campaign emails, canvass sessions, and response submissions
|
||||
- Limited to the last 30 days (max 50 items)
|
||||
|
||||
---
|
||||
|
||||
## Groups
|
||||
|
||||
Groups are automatically created based on platform activity:
|
||||
|
||||
- **Shift teams** — created when 2+ volunteers share a shift
|
||||
- **Campaign teams** — created when 2+ users participate in the same campaign
|
||||
|
||||
---
|
||||
|
||||
## Profiles
|
||||
|
||||
Each volunteer has a social profile showing volunteer stats, achievement badges, friendship status, and recent activity.
|
||||
|
||||
---
|
||||
|
||||
## Pokes
|
||||
|
||||
Send a friendly nudge to any accepted friend (24-hour cooldown per pair).
|
||||
|
||||
---
|
||||
|
||||
## Privacy Settings
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| Show online status | On | Whether friends see you as online |
|
||||
| Show in friend activity | On | Whether your actions appear in feeds |
|
||||
| Allow friend requests | On | Whether others can send you requests |
|
||||
|
||||
---
|
||||
|
||||
## Digest Emails
|
||||
|
||||
Opt into periodic social digest emails with friend activity, unread notifications, and pending requests. Choose daily or weekly frequency.
|
||||
|
||||
---
|
||||
|
||||
## Volunteer Routes
|
||||
|
||||
- `/volunteer/feed` — social activity feed
|
||||
- `/volunteer/friends` — friends, requests, blocked, and groups
|
||||
- `/volunteer/discover` — ranked friend suggestions
|
||||
- `/volunteer/profile` — your social profile
|
||||
- `/volunteer/profile/:userId` — another volunteer's profile
|
||||
- `/volunteer/notifications` — notification center and preferences
|
||||
- `/volunteer/groups/:id` — group detail with member list
|
||||
262
mkdocs/docs/javascripts/ad-widgets.js
Normal file
262
mkdocs/docs/javascripts/ad-widgets.js
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Ad Widget Hydration for MkDocs
|
||||
*
|
||||
* Converts ad block placeholders (inserted via DocsPage toolbar) into
|
||||
* rendered ad cards when pages are viewed in MkDocs.
|
||||
*
|
||||
* Supports:
|
||||
* - .ad-specific-block[data-ad-id] — renders a specific ad by ID
|
||||
* - .ad-slot-block[data-placement][data-variant] — renders a dynamic ad slot
|
||||
*
|
||||
* Reads API URL from window.PAYMENT_API_URL (set by env-config.js).
|
||||
* Tracks impressions and clicks via POST /api/gallery-ads/track.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var API_URL = window.PAYMENT_API_URL || '';
|
||||
|
||||
/** Lighten/darken a hex color by an amount */
|
||||
function adjustColor(hex, amount) {
|
||||
function clamp(v) { return Math.max(0, Math.min(255, v)); }
|
||||
var h = hex.replace('#', '');
|
||||
var r = clamp(parseInt(h.substring(0, 2), 16) + amount);
|
||||
var g = clamp(parseInt(h.substring(2, 4), 16) + amount);
|
||||
var b = clamp(parseInt(h.substring(4, 6), 16) + amount);
|
||||
return '#' + r.toString(16).padStart(2, '0') + g.toString(16).padStart(2, '0') + b.toString(16).padStart(2, '0');
|
||||
}
|
||||
|
||||
/** Get or create a simple session ID for tracking */
|
||||
function getSessionId() {
|
||||
var key = 'cm_ad_session';
|
||||
var id = sessionStorage.getItem(key);
|
||||
if (!id) {
|
||||
id = Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
sessionStorage.setItem(key, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Track an impression or click */
|
||||
function trackEvent(adId, event) {
|
||||
if (!API_URL) return;
|
||||
fetch(API_URL + '/api/gallery-ads/track', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ adId: adId, event: event, sessionId: getSessionId() }),
|
||||
}).catch(function () {}); // silent
|
||||
}
|
||||
|
||||
/** Render an ad card into a container element */
|
||||
function renderAdCard(container, ad) {
|
||||
container.innerHTML = '';
|
||||
container.setAttribute('data-hydrated', 'true');
|
||||
|
||||
var isHighlight = ad.variant === 'highlight';
|
||||
var isMinimal = ad.variant === 'minimal';
|
||||
|
||||
var defaultPrimary = '#1677ff';
|
||||
var bgColor = ad.bgColor || defaultPrimary;
|
||||
var bgGradient = ad.bgColor
|
||||
? 'linear-gradient(135deg, ' + ad.bgColor + ' 0%, ' + adjustColor(ad.bgColor, -30) + ' 100%)'
|
||||
: isHighlight
|
||||
? 'linear-gradient(135deg, ' + defaultPrimary + ' 0%, ' + adjustColor(defaultPrimary, -40) + ' 100%)'
|
||||
: 'linear-gradient(135deg, #1f1f2e 0%, #141422 100%)';
|
||||
|
||||
var borderStyle = isHighlight
|
||||
? '2px solid ' + bgColor
|
||||
: '1px solid rgba(255,255,255,0.08)';
|
||||
|
||||
var card = document.createElement('div');
|
||||
card.style.cssText = 'border-radius:12px;overflow:hidden;border:' + borderStyle +
|
||||
';cursor:' + (ad.linkUrl ? 'pointer' : 'default') +
|
||||
';transition:all 0.2s ease;display:flex;flex-direction:column;max-width:400px;margin:16px auto;' +
|
||||
(isHighlight ? 'box-shadow:0 0 20px ' + bgColor + '33;' : '');
|
||||
|
||||
// Click handler
|
||||
card.addEventListener('click', function () {
|
||||
trackEvent(ad.id, 'click');
|
||||
if (ad.linkUrl) {
|
||||
if (ad.linkUrl.indexOf('http') === 0) {
|
||||
window.open(ad.linkUrl, '_blank', 'noopener');
|
||||
} else {
|
||||
window.location.href = ad.linkUrl;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Top section (16:9 visual area) — skip for minimal variant
|
||||
if (!isMinimal) {
|
||||
var topBg = ad.imagePath
|
||||
? 'url(' + ad.imagePath + ') center/cover no-repeat'
|
||||
: bgGradient;
|
||||
var top = document.createElement('div');
|
||||
top.style.cssText = 'position:relative;padding-top:56.25%;background:' + topBg + ';';
|
||||
|
||||
var overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px;text-align:center;' +
|
||||
(ad.imagePath ? 'background:rgba(0,0,0,0.5);' : '');
|
||||
|
||||
if (ad.iconEmoji) {
|
||||
var emoji = document.createElement('span');
|
||||
emoji.style.cssText = 'font-size:36px;margin-bottom:8px;';
|
||||
emoji.textContent = ad.iconEmoji;
|
||||
overlay.appendChild(emoji);
|
||||
}
|
||||
|
||||
var title = document.createElement('h4');
|
||||
title.style.cssText = 'color:#fff;margin:0;text-shadow:0 1px 3px rgba(0,0,0,0.3);font-size:16px;font-weight:600;';
|
||||
title.textContent = ad.title;
|
||||
overlay.appendChild(title);
|
||||
|
||||
if (ad.subtitle) {
|
||||
var subtitle = document.createElement('p');
|
||||
subtitle.style.cssText = 'color:rgba(255,255,255,0.85);margin:8px 0 0;font-size:13px;max-width:240px;';
|
||||
subtitle.textContent = ad.subtitle;
|
||||
overlay.appendChild(subtitle);
|
||||
}
|
||||
|
||||
top.appendChild(overlay);
|
||||
card.appendChild(top);
|
||||
}
|
||||
|
||||
// Bottom section
|
||||
var bottom = document.createElement('div');
|
||||
bottom.style.cssText = 'padding:' + (isMinimal ? '20px 16px' : '12px 16px') +
|
||||
';background:' + (isMinimal ? bgGradient : '#1b2838') +
|
||||
';display:flex;flex-direction:column;gap:8px;';
|
||||
|
||||
if (isMinimal) {
|
||||
if (ad.iconEmoji) {
|
||||
var emojiMin = document.createElement('span');
|
||||
emojiMin.style.fontSize = '24px';
|
||||
emojiMin.textContent = ad.iconEmoji;
|
||||
bottom.appendChild(emojiMin);
|
||||
}
|
||||
var titleMin = document.createElement('h5');
|
||||
titleMin.style.cssText = 'color:#fff;margin:0;font-size:14px;font-weight:600;';
|
||||
titleMin.textContent = ad.title;
|
||||
bottom.appendChild(titleMin);
|
||||
|
||||
if (ad.subtitle) {
|
||||
var subMin = document.createElement('p');
|
||||
subMin.style.cssText = 'color:rgba(255,255,255,0.6);font-size:12px;margin:0;';
|
||||
subMin.textContent = ad.subtitle;
|
||||
bottom.appendChild(subMin);
|
||||
}
|
||||
}
|
||||
|
||||
if (ad.ctaText) {
|
||||
var cta = document.createElement('a');
|
||||
cta.textContent = ad.ctaText;
|
||||
cta.href = ad.linkUrl || '#';
|
||||
cta.style.cssText = 'display:block;text-align:center;padding:6px 16px;border-radius:6px;font-size:13px;font-weight:500;text-decoration:none;' +
|
||||
(ad.ctaStyle === 'primary'
|
||||
? 'background:' + defaultPrimary + ';color:#fff;'
|
||||
: ad.ctaStyle === 'outline'
|
||||
? 'background:transparent;color:' + defaultPrimary + ';border:1px solid ' + defaultPrimary + ';'
|
||||
: 'background:transparent;color:' + defaultPrimary + ';');
|
||||
cta.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
bottom.appendChild(cta);
|
||||
}
|
||||
|
||||
var promo = document.createElement('p');
|
||||
promo.style.cssText = 'font-size:10px;color:rgba(255,255,255,0.25);text-align:center;margin:0;';
|
||||
promo.textContent = 'Promoted';
|
||||
bottom.appendChild(promo);
|
||||
|
||||
card.appendChild(bottom);
|
||||
container.appendChild(card);
|
||||
|
||||
// Impression tracking via IntersectionObserver
|
||||
var impressionSent = false;
|
||||
var timer = null;
|
||||
var observer = new IntersectionObserver(function (entries) {
|
||||
if (entries[0] && entries[0].isIntersecting) {
|
||||
timer = setTimeout(function () {
|
||||
if (!impressionSent) {
|
||||
impressionSent = true;
|
||||
trackEvent(ad.id, 'impression');
|
||||
}
|
||||
}, 1000);
|
||||
} else if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
}, { threshold: 0.5 });
|
||||
observer.observe(card);
|
||||
}
|
||||
|
||||
/** Hydrate all ad blocks on the page */
|
||||
function hydrateAds() {
|
||||
if (!API_URL) return;
|
||||
|
||||
var specificBlocks = document.querySelectorAll('.ad-specific-block:not([data-hydrated])');
|
||||
var slotBlocks = document.querySelectorAll('.ad-slot-block:not([data-hydrated])');
|
||||
|
||||
if (specificBlocks.length === 0 && slotBlocks.length === 0) return;
|
||||
|
||||
// Fetch all active ads (for both specific and slot hydration)
|
||||
fetch(API_URL + '/api/gallery-ads?placement=docs')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (ads) {
|
||||
if (!Array.isArray(ads)) return;
|
||||
|
||||
// Hydrate specific ad blocks
|
||||
specificBlocks.forEach(function (el) {
|
||||
var adId = parseInt(el.getAttribute('data-ad-id') || '0', 10);
|
||||
if (!adId) return;
|
||||
var ad = ads.find(function (a) { return a.id === adId; });
|
||||
if (!ad) {
|
||||
// Ad not found or not active — try fetching all ads (placement filter may exclude it)
|
||||
fetch(API_URL + '/api/gallery-ads')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (allAds) {
|
||||
var found = allAds.find(function (a) { return a.id === adId; });
|
||||
if (found) renderAdCard(el, found);
|
||||
else el.style.display = 'none';
|
||||
})
|
||||
.catch(function () { el.style.display = 'none'; });
|
||||
return;
|
||||
}
|
||||
renderAdCard(el, ad);
|
||||
});
|
||||
|
||||
// Hydrate dynamic ad slot blocks
|
||||
if (slotBlocks.length > 0 && ads.length > 0) {
|
||||
var idx = 0;
|
||||
slotBlocks.forEach(function (el) {
|
||||
var variant = el.getAttribute('data-variant') || 'standard';
|
||||
var match = ads.find(function (a) { return a.variant === variant; }) || ads[idx % ads.length];
|
||||
idx++;
|
||||
if (match) renderAdCard(el, match);
|
||||
else el.style.display = 'none';
|
||||
});
|
||||
} else if (slotBlocks.length > 0) {
|
||||
slotBlocks.forEach(function (el) { el.style.display = 'none'; });
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.warn('[Ad Widgets] Failed to fetch ads:', err);
|
||||
specificBlocks.forEach(function (el) { el.style.display = 'none'; });
|
||||
slotBlocks.forEach(function (el) { el.style.display = 'none'; });
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', hydrateAds);
|
||||
} else {
|
||||
hydrateAds();
|
||||
}
|
||||
|
||||
// Re-initialize on MkDocs SPA navigation
|
||||
if (typeof window.document$ !== 'undefined') {
|
||||
window.document$.subscribe(function () {
|
||||
setTimeout(hydrateAds, 100);
|
||||
});
|
||||
}
|
||||
})();
|
||||
479
mkdocs/docs/javascripts/docs-comments.js
Normal file
479
mkdocs/docs/javascripts/docs-comments.js
Normal file
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* Docs Comments Widget — Gitea Issues-backed comments for MkDocs pages
|
||||
*
|
||||
* Loads approved comments from the Express API proxy, supports:
|
||||
* - Anonymous comments (with moderation queue)
|
||||
* - Gitea OAuth2 login for instant comments
|
||||
* - Dark/light theme via MkDocs Material CSS vars
|
||||
* - SPA re-init via document$.subscribe()
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// --- Config ---
|
||||
function getApiUrl() {
|
||||
// env-config.js sets window.PAYMENT_API_URL to the resolved API URL
|
||||
return window.PAYMENT_API_URL || 'http://localhost:4000';
|
||||
}
|
||||
|
||||
var API_BASE = '';
|
||||
var SESSION_KEY = 'docs-comment-gitea-token';
|
||||
var USER_KEY = 'docs-comment-gitea-user';
|
||||
var STATE_KEY = 'docs-comment-oauth-state';
|
||||
var RETURN_KEY = 'docs-comment-return-url';
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function escapeHtml(str) {
|
||||
var div = document.createElement('div');
|
||||
div.appendChild(document.createTextNode(str));
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal inline markdown: **bold**, *italic*, `code`, [text](url)
|
||||
*/
|
||||
function renderInlineMarkdown(text) {
|
||||
var html = escapeHtml(text);
|
||||
// Code (backticks)
|
||||
html = html.replace(/`([^`]+)`/g, '<code class="dc-inline-code">$1</code>');
|
||||
// Bold
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
// Italic
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
// Links
|
||||
html = html.replace(
|
||||
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
|
||||
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>'
|
||||
);
|
||||
// Newlines
|
||||
html = html.replace(/\n/g, '<br>');
|
||||
return html;
|
||||
}
|
||||
|
||||
function timeAgo(dateStr) {
|
||||
var now = Date.now();
|
||||
var then = new Date(dateStr).getTime();
|
||||
var diff = Math.floor((now - then) / 1000);
|
||||
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||
if (diff < 2592000) return Math.floor(diff / 86400) + 'd ago';
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
function getInitials(name) {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.map(function (w) { return w[0]; })
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
|
||||
function getToken() {
|
||||
try { return sessionStorage.getItem(SESSION_KEY); } catch { return null; }
|
||||
}
|
||||
|
||||
function getUser() {
|
||||
try {
|
||||
var data = sessionStorage.getItem(USER_KEY);
|
||||
return data ? JSON.parse(data) : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function setAuth(token, user) {
|
||||
try {
|
||||
sessionStorage.setItem(SESSION_KEY, token);
|
||||
sessionStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
} catch { /* sessionStorage unavailable */ }
|
||||
}
|
||||
|
||||
function clearAuth() {
|
||||
try {
|
||||
sessionStorage.removeItem(SESSION_KEY);
|
||||
sessionStorage.removeItem(USER_KEY);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
|
||||
function fetchComments(pagePath, page, callback) {
|
||||
var url = API_BASE + '/api/docs-comments/comments?pagePath=' +
|
||||
encodeURIComponent(pagePath) + '&page=' + (page || 1);
|
||||
|
||||
fetch(url)
|
||||
.then(function (res) {
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
return res.json();
|
||||
})
|
||||
.then(function (data) { callback(null, data); })
|
||||
.catch(function (err) { callback(err); });
|
||||
}
|
||||
|
||||
function postAnonymous(payload, callback) {
|
||||
fetch(API_BASE + '/api/docs-comments/comments/anonymous', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.then(function (res) {
|
||||
if (!res.ok) return res.json().then(function (d) { throw new Error(d.error || 'Error'); });
|
||||
return res.json();
|
||||
})
|
||||
.then(function (data) { callback(null, data); })
|
||||
.catch(function (err) { callback(err); });
|
||||
}
|
||||
|
||||
function postAuthenticated(payload, token, callback) {
|
||||
fetch(API_BASE + '/api/docs-comments/comments/authenticated', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Gitea-Token': token,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.then(function (res) {
|
||||
if (!res.ok) return res.json().then(function (d) { throw new Error(d.error || 'Error'); });
|
||||
return res.json();
|
||||
})
|
||||
.then(function (data) { callback(null, data); })
|
||||
.catch(function (err) { callback(err); });
|
||||
}
|
||||
|
||||
function fetchOAuthConfig(callback) {
|
||||
fetch(API_BASE + '/api/docs-comments/oauth/config')
|
||||
.then(function (res) { return res.json(); })
|
||||
.then(function (data) { callback(null, data); })
|
||||
.catch(function (err) { callback(err); });
|
||||
}
|
||||
|
||||
function exchangeCode(code, redirectUri, callback) {
|
||||
fetch(API_BASE + '/api/docs-comments/oauth/exchange', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: code, redirectUri: redirectUri }),
|
||||
})
|
||||
.then(function (res) {
|
||||
if (!res.ok) throw new Error('OAuth exchange failed');
|
||||
return res.json();
|
||||
})
|
||||
.then(function (data) { callback(null, data); })
|
||||
.catch(function (err) { callback(err); });
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
|
||||
function renderAvatar(comment) {
|
||||
if (comment.avatarUrl) {
|
||||
return '<img class="dc-avatar" src="' + escapeHtml(comment.avatarUrl) + '" alt="" loading="lazy">';
|
||||
}
|
||||
return '<span class="dc-avatar dc-avatar--initials">' + escapeHtml(getInitials(comment.authorName)) + '</span>';
|
||||
}
|
||||
|
||||
function renderComment(comment) {
|
||||
var fullDate = new Date(comment.createdAt).toLocaleString();
|
||||
return (
|
||||
'<div class="dc-comment">' +
|
||||
'<div class="dc-comment__header">' +
|
||||
renderAvatar(comment) +
|
||||
'<span class="dc-comment__author">' + escapeHtml(comment.authorName) + '</span>' +
|
||||
(comment.isAnonymous ? '<span class="dc-comment__badge">Guest</span>' : '') +
|
||||
'<time class="dc-comment__time" title="' + escapeHtml(fullDate) + '">' + timeAgo(comment.createdAt) + '</time>' +
|
||||
'</div>' +
|
||||
'<div class="dc-comment__body">' + renderInlineMarkdown(comment.body) + '</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function renderCommentList(data) {
|
||||
if (!data.comments || data.comments.length === 0) {
|
||||
return '<p class="dc-empty">No comments yet. Be the first to share your thoughts!</p>';
|
||||
}
|
||||
return data.comments.map(renderComment).join('');
|
||||
}
|
||||
|
||||
function renderForm(pagePath, oauthConfig) {
|
||||
var token = getToken();
|
||||
var user = getUser();
|
||||
|
||||
if (token && user) {
|
||||
// Authenticated form
|
||||
return (
|
||||
'<div class="dc-form dc-form--authenticated">' +
|
||||
'<div class="dc-form__user">' +
|
||||
'<img class="dc-avatar" src="' + escapeHtml(user.avatarUrl || '') + '" alt="">' +
|
||||
'<span>Commenting as <strong>' + escapeHtml(user.name) + '</strong></span>' +
|
||||
'<button class="dc-btn dc-btn--text dc-logout-btn" type="button">Sign out</button>' +
|
||||
'</div>' +
|
||||
'<textarea class="dc-textarea dc-auth-body" placeholder="Write a comment... (Markdown supported)" rows="3"></textarea>' +
|
||||
'<div class="dc-form__actions">' +
|
||||
'<button class="dc-btn dc-btn--primary dc-submit-auth" type="button">Post Comment</button>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
// Anonymous form with optional OAuth login
|
||||
var loginBtn = '';
|
||||
if (oauthConfig && oauthConfig.oauthEnabled) {
|
||||
loginBtn = '<button class="dc-btn dc-btn--outline dc-login-btn" type="button">Sign in with Gitea</button>';
|
||||
}
|
||||
|
||||
return (
|
||||
'<div class="dc-form dc-form--anonymous">' +
|
||||
'<div class="dc-form__row">' +
|
||||
'<input class="dc-input dc-anon-name" type="text" placeholder="Your name *" maxlength="100">' +
|
||||
'<input class="dc-input dc-anon-email" type="email" placeholder="Email (optional)" maxlength="255">' +
|
||||
'</div>' +
|
||||
// Honeypot — hidden from humans
|
||||
'<input class="dc-honeypot" type="text" name="website" tabindex="-1" autocomplete="off">' +
|
||||
'<textarea class="dc-textarea dc-anon-body" placeholder="Write a comment... (Markdown supported, 10+ characters)" rows="3"></textarea>' +
|
||||
'<div class="dc-form__actions">' +
|
||||
'<button class="dc-btn dc-btn--primary dc-submit-anon" type="button">Post as Guest</button>' +
|
||||
loginBtn +
|
||||
'</div>' +
|
||||
'<p class="dc-form__note">Guest comments are reviewed before appearing.</p>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main Init ---
|
||||
|
||||
function initWidget(container) {
|
||||
var pagePath = container.getAttribute('data-page-path') || '';
|
||||
if (!pagePath) return;
|
||||
|
||||
API_BASE = getApiUrl();
|
||||
|
||||
container.innerHTML =
|
||||
'<div class="dc-widget">' +
|
||||
'<h3 class="dc-title">Comments</h3>' +
|
||||
'<div class="dc-comments-list dc-loading">Loading comments...</div>' +
|
||||
'<div class="dc-form-container"></div>' +
|
||||
'</div>';
|
||||
|
||||
var listEl = container.querySelector('.dc-comments-list');
|
||||
var formContainer = container.querySelector('.dc-form-container');
|
||||
var oauthConfig = null;
|
||||
|
||||
// Load OAuth config + comments in parallel
|
||||
fetchOAuthConfig(function (err, config) {
|
||||
if (!err && config) oauthConfig = config;
|
||||
renderFormSection();
|
||||
});
|
||||
|
||||
loadComments(1);
|
||||
|
||||
function loadComments(page) {
|
||||
listEl.classList.add('dc-loading');
|
||||
listEl.innerHTML = 'Loading comments...';
|
||||
|
||||
fetchComments(pagePath, page, function (err, data) {
|
||||
listEl.classList.remove('dc-loading');
|
||||
if (err) {
|
||||
listEl.innerHTML = '<p class="dc-error">Comments unavailable.</p>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = renderCommentList(data);
|
||||
});
|
||||
}
|
||||
|
||||
function renderFormSection() {
|
||||
formContainer.innerHTML = renderForm(pagePath, oauthConfig);
|
||||
bindFormEvents();
|
||||
}
|
||||
|
||||
function bindFormEvents() {
|
||||
// Anonymous submit
|
||||
var submitAnon = formContainer.querySelector('.dc-submit-anon');
|
||||
if (submitAnon) {
|
||||
submitAnon.addEventListener('click', function () {
|
||||
var name = formContainer.querySelector('.dc-anon-name').value.trim();
|
||||
var email = formContainer.querySelector('.dc-anon-email').value.trim();
|
||||
var body = formContainer.querySelector('.dc-anon-body').value.trim();
|
||||
var honeypot = formContainer.querySelector('.dc-honeypot').value;
|
||||
|
||||
if (!name) { showFormError('Please enter your name.'); return; }
|
||||
if (body.length < 10) { showFormError('Comment must be at least 10 characters.'); return; }
|
||||
|
||||
submitAnon.disabled = true;
|
||||
submitAnon.textContent = 'Posting...';
|
||||
|
||||
postAnonymous(
|
||||
{ pagePath: pagePath, authorName: name, authorEmail: email || undefined, body: body, website: honeypot },
|
||||
function (err) {
|
||||
submitAnon.disabled = false;
|
||||
submitAnon.textContent = 'Post as Guest';
|
||||
if (err) { showFormError(err.message || 'Failed to post comment.'); return; }
|
||||
formContainer.querySelector('.dc-anon-body').value = '';
|
||||
showFormSuccess('Your comment has been submitted for review.');
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Authenticated submit
|
||||
var submitAuth = formContainer.querySelector('.dc-submit-auth');
|
||||
if (submitAuth) {
|
||||
submitAuth.addEventListener('click', function () {
|
||||
var body = formContainer.querySelector('.dc-auth-body').value.trim();
|
||||
var token = getToken();
|
||||
|
||||
if (body.length < 10) { showFormError('Comment must be at least 10 characters.'); return; }
|
||||
if (!token) { showFormError('Session expired. Please sign in again.'); clearAuth(); renderFormSection(); return; }
|
||||
|
||||
submitAuth.disabled = true;
|
||||
submitAuth.textContent = 'Posting...';
|
||||
|
||||
postAuthenticated(
|
||||
{ pagePath: pagePath, body: body },
|
||||
token,
|
||||
function (err) {
|
||||
submitAuth.disabled = false;
|
||||
submitAuth.textContent = 'Post Comment';
|
||||
if (err) {
|
||||
if (err.message && err.message.indexOf('401') !== -1) {
|
||||
clearAuth();
|
||||
renderFormSection();
|
||||
showFormError('Session expired. Please sign in again.');
|
||||
return;
|
||||
}
|
||||
showFormError(err.message || 'Failed to post comment.');
|
||||
return;
|
||||
}
|
||||
formContainer.querySelector('.dc-auth-body').value = '';
|
||||
loadComments(1);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// OAuth login
|
||||
var loginBtn = formContainer.querySelector('.dc-login-btn');
|
||||
if (loginBtn && oauthConfig && oauthConfig.oauthEnabled) {
|
||||
loginBtn.addEventListener('click', function () {
|
||||
var state = Math.random().toString(36).slice(2);
|
||||
try {
|
||||
sessionStorage.setItem(STATE_KEY, state);
|
||||
sessionStorage.setItem(RETURN_KEY, window.location.href);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
var redirectUri = window.location.origin + '/comments/callback/';
|
||||
var url = oauthConfig.authorizeUrl +
|
||||
'?client_id=' + encodeURIComponent(oauthConfig.clientId) +
|
||||
'&redirect_uri=' + encodeURIComponent(redirectUri) +
|
||||
'&response_type=code' +
|
||||
'&state=' + encodeURIComponent(state);
|
||||
|
||||
window.location.href = url;
|
||||
});
|
||||
}
|
||||
|
||||
// Logout
|
||||
var logoutBtn = formContainer.querySelector('.dc-logout-btn');
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', function () {
|
||||
clearAuth();
|
||||
renderFormSection();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function showFormError(msg) {
|
||||
removeFormMessages();
|
||||
var el = document.createElement('p');
|
||||
el.className = 'dc-form-message dc-form-message--error';
|
||||
el.textContent = msg;
|
||||
formContainer.appendChild(el);
|
||||
setTimeout(function () { el.remove(); }, 5000);
|
||||
}
|
||||
|
||||
function showFormSuccess(msg) {
|
||||
removeFormMessages();
|
||||
var el = document.createElement('p');
|
||||
el.className = 'dc-form-message dc-form-message--success';
|
||||
el.textContent = msg;
|
||||
formContainer.appendChild(el);
|
||||
setTimeout(function () { el.remove(); }, 5000);
|
||||
}
|
||||
|
||||
function removeFormMessages() {
|
||||
var msgs = formContainer.querySelectorAll('.dc-form-message');
|
||||
for (var i = 0; i < msgs.length; i++) msgs[i].remove();
|
||||
}
|
||||
}
|
||||
|
||||
// --- OAuth Callback Handler ---
|
||||
|
||||
function handleOAuthCallback() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var code = params.get('code');
|
||||
var state = params.get('state');
|
||||
|
||||
if (!code) return;
|
||||
|
||||
// Verify state
|
||||
var savedState;
|
||||
try { savedState = sessionStorage.getItem(STATE_KEY); } catch { /* ignore */ }
|
||||
if (savedState && state !== savedState) {
|
||||
console.warn('[DocsComments] OAuth state mismatch');
|
||||
return;
|
||||
}
|
||||
|
||||
API_BASE = getApiUrl();
|
||||
var redirectUri = window.location.origin + '/comments/callback/';
|
||||
|
||||
exchangeCode(code, redirectUri, function (err, data) {
|
||||
if (err || !data || !data.accessToken) {
|
||||
console.error('[DocsComments] OAuth exchange failed:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
setAuth(data.accessToken, data.user);
|
||||
|
||||
// Redirect back to original page
|
||||
var returnUrl;
|
||||
try { returnUrl = sessionStorage.getItem(RETURN_KEY); } catch { /* ignore */ }
|
||||
try {
|
||||
sessionStorage.removeItem(STATE_KEY);
|
||||
sessionStorage.removeItem(RETURN_KEY);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
window.location.href = returnUrl || '/';
|
||||
});
|
||||
}
|
||||
|
||||
// --- SPA Init ---
|
||||
|
||||
function init() {
|
||||
// Check if this is the OAuth callback page
|
||||
if (window.location.pathname.indexOf('/comments/callback') !== -1) {
|
||||
handleOAuthCallback();
|
||||
return;
|
||||
}
|
||||
|
||||
var container = document.getElementById('docs-comments');
|
||||
if (container) {
|
||||
initWidget(container);
|
||||
}
|
||||
}
|
||||
|
||||
// MkDocs Material SPA navigation support
|
||||
if (typeof document$ !== 'undefined') {
|
||||
document$.subscribe(function () {
|
||||
init();
|
||||
});
|
||||
} else {
|
||||
// Fallback for non-SPA or non-Material builds
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -40,6 +40,7 @@
|
||||
--branch-devops: #FBBF24;
|
||||
--branch-sovereignty: #F87171;
|
||||
--branch-fundraising: #EC4899;
|
||||
--branch-social: #38BDF8;
|
||||
|
||||
/* Surfaces — dark */
|
||||
--bg-deep: #0F172A;
|
||||
@@ -768,6 +769,130 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
GROWING CHANGE — Philosophy Bridge
|
||||
============================================ */
|
||||
.growing-change {
|
||||
background: var(--bg-deep);
|
||||
border-top: 1px solid var(--border-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.growing-change-content {
|
||||
max-width: 840px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.growing-change-card {
|
||||
background: rgba(30, 41, 59, 0.85);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2.5rem 3rem;
|
||||
margin-bottom: 3rem;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
[data-theme="light"] .growing-change-card {
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border-color: rgba(100, 116, 139, 0.15);
|
||||
}
|
||||
|
||||
.growing-change-card h2 {
|
||||
margin-bottom: 1.5rem;
|
||||
background: linear-gradient(135deg, var(--primary-light) 0%, #C084FC 50%, var(--success) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.growing-lead {
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.8;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.growing-change-card > p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.growing-change-card > p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.growing-callout {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
border-left: 3px solid var(--primary-light);
|
||||
padding-left: 1rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.growing-cta {
|
||||
text-align: center;
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
|
||||
.growing-pillars {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.growing-pillar {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.75rem;
|
||||
transition: all var(--transition);
|
||||
border-top: 2px solid var(--success);
|
||||
}
|
||||
|
||||
.growing-pillar:nth-child(2) {
|
||||
border-top-color: var(--primary-light);
|
||||
}
|
||||
|
||||
.growing-pillar:nth-child(3) {
|
||||
border-top-color: var(--branch-content);
|
||||
}
|
||||
|
||||
.growing-pillar:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--myc-node-border);
|
||||
}
|
||||
|
||||
.pillar-icon {
|
||||
font-size: 1.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.growing-pillar h4 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.growing-pillar p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.growing-pillars {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
FEATURE NETWORK — THE CORE VISUAL
|
||||
============================================ */
|
||||
@@ -889,6 +1014,7 @@
|
||||
.branch-icon.devops { background: rgba(251, 191, 36, 0.15); border: 1px solid rgba(251, 191, 36, 0.3); }
|
||||
.branch-icon.sovereignty { background: rgba(248, 113, 113, 0.15); border: 1px solid rgba(248, 113, 113, 0.3); }
|
||||
.branch-icon.fundraising { background: rgba(236, 72, 153, 0.15); border: 1px solid rgba(236, 72, 153, 0.3); }
|
||||
.branch-icon.social { background: rgba(56, 189, 248, 0.15); border: 1px solid rgba(56, 189, 248, 0.3); }
|
||||
|
||||
.branch-title h3 {
|
||||
font-size: 1.35rem;
|
||||
@@ -938,6 +1064,7 @@
|
||||
.branch-devops .feature-node { border-top: 2px solid var(--branch-devops); }
|
||||
.branch-sovereignty .feature-node { border-top: 2px solid var(--branch-sovereignty); }
|
||||
.branch-fundraising .feature-node { border-top: 2px solid var(--branch-fundraising); }
|
||||
.branch-social .feature-node { border-top: 2px solid var(--branch-social); }
|
||||
|
||||
.branch-comm .feature-node:hover { border-color: var(--branch-comm); box-shadow: 0 0 20px rgba(192,132,252,0.2); }
|
||||
.branch-map .feature-node:hover { border-color: var(--branch-map); box-shadow: 0 0 20px rgba(52,211,153,0.2); }
|
||||
@@ -946,6 +1073,7 @@
|
||||
.branch-devops .feature-node:hover { border-color: var(--branch-devops); box-shadow: 0 0 20px rgba(251,191,36,0.2); }
|
||||
.branch-sovereignty .feature-node:hover { border-color: var(--branch-sovereignty); box-shadow: 0 0 20px rgba(248,113,113,0.2); }
|
||||
.branch-fundraising .feature-node:hover { border-color: var(--branch-fundraising); box-shadow: 0 0 20px rgba(236,72,153,0.2); }
|
||||
.branch-social .feature-node:hover { border-color: var(--branch-social); box-shadow: 0 0 20px rgba(56,189,248,0.2); }
|
||||
|
||||
/* Branch-specific node icon tinting */
|
||||
.branch-comm .node-icon { background: rgba(192,132,252,0.12); }
|
||||
@@ -955,6 +1083,7 @@
|
||||
.branch-devops .node-icon { background: rgba(251,191,36,0.12); }
|
||||
.branch-sovereignty .node-icon { background: rgba(248,113,113,0.12); }
|
||||
.branch-fundraising .node-icon { background: rgba(236,72,153,0.12); }
|
||||
.branch-social .node-icon { background: rgba(56,189,248,0.12); }
|
||||
|
||||
.node-header {
|
||||
display: flex;
|
||||
@@ -1447,7 +1576,6 @@
|
||||
}
|
||||
|
||||
.branch { padding-left: 0; }
|
||||
.root-network-svg { display: none; }
|
||||
.floating-elements { display: none; }
|
||||
|
||||
.sites-grid {
|
||||
@@ -1665,8 +1793,9 @@
|
||||
<div class="hero-badge">Self-Hosted Campaign Infrastructure</div>
|
||||
<h1>Grow Power.<br>Don't Rent It.</h1>
|
||||
<p class="hero-subtitle">
|
||||
A deeply integrated and opinionated collection of free and open source services and production ready applications for growing political movements. Campaigns, canvassing, fundraising, team chat, and media — all on your own infrastructure.
|
||||
No corporate surveillance. No foreign interference. No monthly ransoms. Free and open source.
|
||||
Run your campaigns, canvassing, fundraising, team chat, media, and more — all on your own infrastructure.
|
||||
No corporate surveillance. No foreign interference. No monthly ransoms.
|
||||
A free and open source toolkit built for growing political movements.
|
||||
</p>
|
||||
<div class="hero-cta">
|
||||
<a href="mailto:cmlite@bnkops.ca?subject=Request%20to%20Chat%20-%20CMLITE&body=Hi%20CMlite%20Team%2C%20I%20would%20like%20to%20chat!%20Please%20send%20me%20a%20email%20back.%20Cheers%2C%20" class="btn-primary">Schedule a Chat <span aria-hidden="true">→</span></a>
|
||||
@@ -1702,7 +1831,7 @@
|
||||
<div class="hero-stat-label">Self-Hosted</div>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
<div class="hero-stat-value">30+</div>
|
||||
<div class="hero-stat-value">45+</div>
|
||||
<div class="hero-stat-label">Integrated Tools</div>
|
||||
</div>
|
||||
<div class="hero-stat">
|
||||
@@ -1757,6 +1886,42 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================
|
||||
GROWING CHANGE — Philosophy Bridge
|
||||
============================================ -->
|
||||
<section class="section growing-change" id="growing-change">
|
||||
<div class="container">
|
||||
<div class="growing-change-content reveal">
|
||||
<div class="growing-change-card">
|
||||
<h2>Software Should Grow Power, Not Extract It</h2>
|
||||
<p class="growing-lead">Most campaign and political software is extractive by nature — designed to pull information <em>from</em> a community in order to influence politics. Your voter data in corporate clouds. Your strategies readable by foreign jurisdictions. Your movement’s future in someone else’s hands.</p>
|
||||
<p>Changemaker asks a different question: <mark>“what tools are needed to grow change in a community?”</mark> Growing change means making connections between people, providing access to tools that create new opportunities, and deeply understanding the wants and needs of your movement — on infrastructure <em>you</em> control.</p>
|
||||
<p class="growing-callout">Organizational independence requires technological independence. Socialist movements will never outspend capital — but a thousand neighborhood mailing lists has more potential impact than any single organization. Workers, with the right tools, will build the future.</p>
|
||||
</div>
|
||||
<div class="growing-pillars stagger">
|
||||
<div class="growing-pillar">
|
||||
<div class="pillar-icon">🤝</div>
|
||||
<h4>Distribute Power</h4>
|
||||
<p>Decentralized organizing is the way out. When knowledge and tools are widely distributed — not gatekept by leadership or locked behind vendor paywalls — movements become resilient.</p>
|
||||
</div>
|
||||
<div class="growing-pillar">
|
||||
<div class="pillar-icon">🔒</div>
|
||||
<h4>Own Your Secrets</h4>
|
||||
<p>If you do politics, who is reading your secrets? Corporate platforms extract intelligence systematically. Self-hosted infrastructure means your strategies stay yours — no algorithmic surveillance, no foreign data laws, no backdoors.</p>
|
||||
</div>
|
||||
<div class="growing-pillar">
|
||||
<div class="pillar-icon">🌱</div>
|
||||
<h4>De-Corp Your Stack</h4>
|
||||
<p>Every subscription to corporate software funds the machine you’re fighting. Free and open source tools reduce dependence on capital, eliminate vendor lock-in, and keep your movement’s resources where they belong — in the community.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="growing-cta reveal">
|
||||
<a href="/docs/phil/" class="btn-secondary">Read Our Philosophy <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================
|
||||
FEATURE NETWORK
|
||||
============================================ -->
|
||||
@@ -1764,7 +1929,7 @@
|
||||
<div class="container">
|
||||
<div class="section-header reveal">
|
||||
<h2>The Network</h2>
|
||||
<p>30+ tools connected — each node strengthens the whole</p>
|
||||
<p>50+ tools connected — each node strengthens the whole</p>
|
||||
</div>
|
||||
|
||||
<!-- ====== BRANCH 1: Communication ====== -->
|
||||
@@ -1773,7 +1938,7 @@
|
||||
<div class="branch-icon comm">📨</div>
|
||||
<div class="branch-title">
|
||||
<h3>Communication</h3>
|
||||
<p>Email campaigns, newsletters, advocacy, and public engagement</p>
|
||||
<p>Email campaigns, SMS outreach, newsletters, advocacy, and team chat</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nodes-grid stagger">
|
||||
@@ -1825,6 +1990,22 @@
|
||||
<p>Async notification queue for admin alerts and volunteer feedback. Shift reminders, session summaries, and signup confirmations.</p>
|
||||
<div class="node-tags"><span class="node-tag">BullMQ</span><span class="node-tag">Reminders</span><span class="node-tag">Summaries</span></div>
|
||||
</div>
|
||||
<div class="feature-node">
|
||||
<div class="node-header">
|
||||
<div class="node-icon">📱</div>
|
||||
<h4>SMS Campaigns</h4>
|
||||
</div>
|
||||
<p>Text message outreach via Termux Android bridge. Contact lists, templated campaigns, delivery tracking, response sync, and device health monitoring.</p>
|
||||
<div class="node-tags"><span class="node-tag">Termux bridge</span><span class="node-tag">BullMQ queue</span><span class="node-tag">Response sync</span></div>
|
||||
</div>
|
||||
<div class="feature-node">
|
||||
<div class="node-header">
|
||||
<div class="node-icon">🗨</div>
|
||||
<h4>Chat Widget</h4>
|
||||
</div>
|
||||
<p>Floating Rocket.Chat panel for logged-in team members. Minimizable FAB, auth-gated access, and settings-toggleable visibility across the admin interface.</p>
|
||||
<div class="node-tags"><span class="node-tag">Rocket.Chat</span><span class="node-tag">Auth-gated</span><span class="node-tag">Floating</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1886,6 +2067,14 @@
|
||||
<p>Import Canadian National Address Register data with province/city/postal filtering, coordinate projection, and streaming.</p>
|
||||
<div class="node-tags"><span class="node-tag">2025 format</span><span class="node-tag">Proj4</span><span class="node-tag">Streaming</span></div>
|
||||
</div>
|
||||
<div class="feature-node">
|
||||
<div class="node-header">
|
||||
<div class="node-icon">📆</div>
|
||||
<h4>Gancio Events</h4>
|
||||
</div>
|
||||
<p>Public event calendar synced from shifts via Gancio. OAuth integration, map markers for upcoming events, and embeddable GrapesJS block.</p>
|
||||
<div class="node-tags"><span class="node-tag">OAuth sync</span><span class="node-tag">Map markers</span><span class="node-tag">Embeddable</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1895,7 +2084,7 @@
|
||||
<div class="branch-icon content">🎬</div>
|
||||
<div class="branch-title">
|
||||
<h3>Content & Media</h3>
|
||||
<p>Video library, page builder, documentation, and web IDE</p>
|
||||
<p>Video, photos, playlists, page builder, documentation, and web IDE</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nodes-grid stagger">
|
||||
@@ -1904,8 +2093,8 @@
|
||||
<div class="node-icon">📹</div>
|
||||
<h4>Video Library</h4>
|
||||
</div>
|
||||
<p>Upload, manage, and share videos with FFprobe metadata extraction, scheduled publishing, analytics, and a public gallery.</p>
|
||||
<div class="node-tags"><span class="node-tag">Analytics</span><span class="node-tag">Scheduling</span><span class="node-tag">10GB uploads</span></div>
|
||||
<p>Upload and manage videos with FFprobe metadata, scheduled publishing, view analytics, emoji reactions, threaded comments, and live chat.</p>
|
||||
<div class="node-tags"><span class="node-tag">Analytics</span><span class="node-tag">Live chat</span><span class="node-tag">Scheduling</span></div>
|
||||
</div>
|
||||
<div class="feature-node">
|
||||
<div class="node-header">
|
||||
@@ -1920,8 +2109,8 @@
|
||||
<div class="node-icon">📖</div>
|
||||
<h4>MkDocs Documentation</h4>
|
||||
</div>
|
||||
<p>Material-themed documentation site with full-text search, blog, social cards, and Jinja2 template overrides.</p>
|
||||
<div class="node-tags"><span class="node-tag">Material theme</span><span class="node-tag">Lunr search</span><span class="node-tag">Blog</span></div>
|
||||
<p>Material-themed docs with full-text search, blog, social cards, and Gitea-backed page comments with anonymous posting and moderation.</p>
|
||||
<div class="node-tags"><span class="node-tag">Material theme</span><span class="node-tag">Comments</span><span class="node-tag">Blog</span></div>
|
||||
</div>
|
||||
<div class="feature-node">
|
||||
<div class="node-header">
|
||||
@@ -1939,6 +2128,30 @@
|
||||
<p>Collaborative diagramming and whiteboard tool. Plan canvassing routes, sketch campaign strategies, and brainstorm as a team.</p>
|
||||
<div class="node-tags"><span class="node-tag">Collaborative</span><span class="node-tag">Diagrams</span><span class="node-tag">Real-time</span></div>
|
||||
</div>
|
||||
<div class="feature-node">
|
||||
<div class="node-header">
|
||||
<div class="node-icon">📷</div>
|
||||
<h4>Photo Management</h4>
|
||||
</div>
|
||||
<p>Album organization with bulk uploads, metadata extraction, and engagement tracking. Reactions, comments, and a public photo gallery.</p>
|
||||
<div class="node-tags"><span class="node-tag">Albums</span><span class="node-tag">Engagement</span><span class="node-tag">Gallery</span></div>
|
||||
</div>
|
||||
<div class="feature-node">
|
||||
<div class="node-header">
|
||||
<div class="node-icon">🎵</div>
|
||||
<h4>Playlists</h4>
|
||||
</div>
|
||||
<p>Curated video collections with admin, user, and public playlists. Drag-reorder, sidebar navigation, featured carousel, and dedicated viewer page.</p>
|
||||
<div class="node-tags"><span class="node-tag">Curated</span><span class="node-tag">Public/Private</span><span class="node-tag">Reorderable</span></div>
|
||||
</div>
|
||||
<div class="feature-node">
|
||||
<div class="node-header">
|
||||
<div class="node-icon">📲</div>
|
||||
<h4>Shorts Feed</h4>
|
||||
</div>
|
||||
<p>TikTok-style vertical video feed for clips under 60 seconds. Autoplay, sorting modes, and mobile-optimized swipeable interface.</p>
|
||||
<div class="node-tags"><span class="node-tag">Vertical video</span><span class="node-tag">Autoplay</span><span class="node-tag">Mobile-first</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1948,7 +2161,7 @@
|
||||
<div class="branch-icon data">📊</div>
|
||||
<div class="branch-title">
|
||||
<h3>Data & Automation</h3>
|
||||
<p>Database browsing, workflow automation, version control, and utilities</p>
|
||||
<p>Database browsing, workflow automation, version control, search, and utilities</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nodes-grid stagger">
|
||||
@@ -1984,6 +2197,22 @@
|
||||
<p>QR code generator for walk sheets, campaign materials, and event signage. Instant PNG generation.</p>
|
||||
<div class="node-tags"><span class="node-tag">PNG output</span><span class="node-tag">Embeddable</span><span class="node-tag">Public API</span></div>
|
||||
</div>
|
||||
<div class="feature-node">
|
||||
<div class="node-header">
|
||||
<div class="node-icon">⌘</div>
|
||||
<h4>Command Palette</h4>
|
||||
</div>
|
||||
<p>Global Ctrl+K search across pages, campaigns, locations, users, and settings. Fuzzy matching, recent items, and keyboard-driven navigation.</p>
|
||||
<div class="node-tags"><span class="node-tag">Ctrl+K</span><span class="node-tag">Fuzzy search</span><span class="node-tag">Keyboard-first</span></div>
|
||||
</div>
|
||||
<div class="feature-node">
|
||||
<div class="node-header">
|
||||
<div class="node-icon">⚙</div>
|
||||
<h4>Navigation Settings</h4>
|
||||
</div>
|
||||
<p>Customizable public nav menu with feature toggles, custom external links, drag-reorder, and real-time preview. Control what visitors see.</p>
|
||||
<div class="node-tags"><span class="node-tag">Drag-reorder</span><span class="node-tag">Feature flags</span><span class="node-tag">Custom links</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2037,6 +2266,14 @@
|
||||
<p>Self-hosted Bitwarden-compatible password manager. Secure credential sharing for your team with real-time sync and browser extensions.</p>
|
||||
<div class="node-tags"><span class="node-tag">Bitwarden</span><span class="node-tag">Team sharing</span><span class="node-tag">Encrypted</span></div>
|
||||
</div>
|
||||
<div class="feature-node">
|
||||
<div class="node-header">
|
||||
<div class="node-icon">👥</div>
|
||||
<h4>User Provisioning</h4>
|
||||
</div>
|
||||
<p>Automatic account sync across Rocket.Chat, Gitea, Vaultwarden, and Listmonk. Eager or lazy strategies with per-user status tracking and bulk sync.</p>
|
||||
<div class="node-tags"><span class="node-tag">4 services</span><span class="node-tag">Auto-sync</span><span class="node-tag">Lifecycle hooks</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2090,10 +2327,71 @@
|
||||
<p>Promote donations, products, and subscriptions within the media gallery. Visibility targeting, scheduling, and click analytics.</p>
|
||||
<div class="node-tags"><span class="node-tag">Targeting</span><span class="node-tag">Scheduling</span><span class="node-tag">CTR tracking</span></div>
|
||||
</div>
|
||||
<div class="feature-node">
|
||||
<div class="node-header">
|
||||
<div class="node-icon">📝</div>
|
||||
<h4>Donation Pages</h4>
|
||||
</div>
|
||||
<p>Custom branded donation pages with configurable amounts, thank-you messages, and public slugs. Multiple campaigns with independent branding and goals.</p>
|
||||
<div class="node-tags"><span class="node-tag">Custom branding</span><span class="node-tag">Slug URLs</span><span class="node-tag">Goals</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== BRANCH 7: Data Sovereignty ====== -->
|
||||
<!-- ====== BRANCH 7: Social & Community ====== -->
|
||||
<div class="branch branch-social" data-branch="social">
|
||||
<div class="branch-header reveal">
|
||||
<div class="branch-icon social">👥</div>
|
||||
<div class="branch-title">
|
||||
<h3>Social & Community</h3>
|
||||
<p>Friendships, activity feeds, achievements, groups, reactions, and real-time notifications</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nodes-grid stagger">
|
||||
<div class="feature-node">
|
||||
<div class="node-header">
|
||||
<div class="node-icon">👥</div>
|
||||
<h4>Friend System</h4>
|
||||
</div>
|
||||
<p>Friend requests, suggestions, pokes, cross-module badges on campaigns, shifts, and the map.</p>
|
||||
<div class="node-tags"><span class="node-tag">Friend requests</span><span class="node-tag">Suggestions</span><span class="node-tag">Poke</span></div>
|
||||
</div>
|
||||
<div class="feature-node">
|
||||
<div class="node-header">
|
||||
<div class="node-icon">📰</div>
|
||||
<h4>Activity Feed</h4>
|
||||
</div>
|
||||
<p>Real-time SSE feed of friend activity across campaigns, shifts, canvassing, and responses.</p>
|
||||
<div class="node-tags"><span class="node-tag">Real-time</span><span class="node-tag">SSE</span><span class="node-tag">Cross-module</span></div>
|
||||
</div>
|
||||
<div class="feature-node">
|
||||
<div class="node-header">
|
||||
<div class="node-icon">🏆</div>
|
||||
<h4>Achievements & Notifications</h4>
|
||||
</div>
|
||||
<p>Milestone badges, real-time notification bell with friend requests, pokes, comments, and alerts.</p>
|
||||
<div class="node-tags"><span class="node-tag">Badges</span><span class="node-tag">Bell UI</span><span class="node-tag">Real-time</span></div>
|
||||
</div>
|
||||
<div class="feature-node">
|
||||
<div class="node-header">
|
||||
<div class="node-icon">👫</div>
|
||||
<h4>Groups & Teams</h4>
|
||||
</div>
|
||||
<p>Auto-groups for shift teams and campaign crews, custom groups, and shared updates.</p>
|
||||
<div class="node-tags"><span class="node-tag">Shift teams</span><span class="node-tag">Campaign crews</span><span class="node-tag">Custom groups</span></div>
|
||||
</div>
|
||||
<div class="feature-node">
|
||||
<div class="node-header">
|
||||
<div class="node-icon">😍</div>
|
||||
<h4>Reactions & Comments</h4>
|
||||
</div>
|
||||
<p>6 emoji reactions with floating animations, threaded comments with word-filter safety, pagination, and auto-notification.</p>
|
||||
<div class="node-tags"><span class="node-tag">6 emoji types</span><span class="node-tag">Threaded</span><span class="node-tag">Content safety</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== BRANCH 8: Data Sovereignty ====== -->
|
||||
<div class="branch branch-sovereignty" data-branch="sovereignty">
|
||||
<div class="branch-header reveal">
|
||||
<div class="branch-icon sovereignty">🇨🇦</div>
|
||||
@@ -2238,7 +2536,7 @@
|
||||
<div class="price">$0</div>
|
||||
<div class="price-period">forever</div>
|
||||
<ul class="pricing-features">
|
||||
<li>All 30+ campaign tools</li>
|
||||
<li>All 45+ campaign tools</li>
|
||||
<li>Unlimited users & data</li>
|
||||
<li>Complete documentation</li>
|
||||
<li>Community support</li>
|
||||
@@ -2339,7 +2637,7 @@
|
||||
<h4>Community</h4>
|
||||
<ul class="footer-links">
|
||||
<li><a href="https://gitea.bnkops.com/admin/changemaker.lite" target="_blank" rel="noopener">Source Code</a></li>
|
||||
<li><a href="/phil/">Philosophy</a></li>
|
||||
<li><a href="/docs/phil/">Philosophy</a></li>
|
||||
<li><a href="https://bnkops.com/" target="_blank" rel="noopener">BNKops</a></li>
|
||||
<li><a href="mailto:cmlite@bnkops.ca">Contact</a></li>
|
||||
</ul>
|
||||
@@ -2682,6 +2980,7 @@
|
||||
{ sel: '.branch[data-branch="data"]', headerSel: '.branch[data-branch="data"] .branch-header', cardSel: '.feature-node', color: '#22D3EE', angle: 0.22 },
|
||||
{ sel: '.branch[data-branch="devops"]', headerSel: '.branch[data-branch="devops"] .branch-header', cardSel: '.feature-node', color: '#FBBF24', angle: -0.08 },
|
||||
{ sel: '.branch[data-branch="fundraising"]', headerSel: '.branch[data-branch="fundraising"] .branch-header', cardSel: '.feature-node', color: '#EC4899', angle: 0.18 },
|
||||
{ sel: '.branch[data-branch="social"]', headerSel: '.branch[data-branch="social"] .branch-header', cardSel: '.feature-node', color: '#38BDF8', angle: -0.18 },
|
||||
{ sel: '.branch[data-branch="sovereignty"]', headerSel: '.branch[data-branch="sovereignty"] .branch-header', cardSel: '.feature-node', color: '#F87171', angle: 0.08 },
|
||||
{ sel: '.live-sites', headerSel: '.live-sites .section-header', cardSel: '.site-card', color: '#8B5CF6', angle: -0.12 },
|
||||
{ sel: '#pricing', headerSel: '#pricing .section-header', cardSel: '.pricing-card, .cost-compare', color: '#C084FC', angle: 0.12 },
|
||||
@@ -2695,17 +2994,31 @@
|
||||
_pageH: 1,
|
||||
_rootY: 0,
|
||||
|
||||
isMobile: false,
|
||||
|
||||
init() {
|
||||
if (window.innerWidth <= 768) return;
|
||||
this.drawAll();
|
||||
this.isMobile = window.innerWidth <= 768;
|
||||
this.redraw();
|
||||
this.startScroll();
|
||||
let resizeTimer;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => { this.drawAll(); this.updateScroll(); }, 300);
|
||||
resizeTimer = setTimeout(() => {
|
||||
this.isMobile = window.innerWidth <= 768;
|
||||
this.redraw();
|
||||
this.updateScroll();
|
||||
}, 300);
|
||||
});
|
||||
},
|
||||
|
||||
redraw() {
|
||||
if (this.isMobile) {
|
||||
this.drawMobile();
|
||||
} else {
|
||||
this.drawAll();
|
||||
}
|
||||
},
|
||||
|
||||
rand(seed) {
|
||||
const x = Math.sin(seed * 9301 + 49297) * 49297;
|
||||
return x - Math.floor(x);
|
||||
@@ -2766,6 +3079,92 @@
|
||||
return c;
|
||||
},
|
||||
|
||||
drawMobile() {
|
||||
const old = document.getElementById('root-network');
|
||||
if (old) old.remove();
|
||||
this.tendrils = [];
|
||||
|
||||
const rootSvgEl = document.querySelector('.hero-root-svg');
|
||||
if (!rootSvgEl) return;
|
||||
|
||||
const rootRect = rootSvgEl.getBoundingClientRect();
|
||||
const rootX = rootRect.left + rootRect.width / 2;
|
||||
const rootY = rootRect.top + window.scrollY + rootRect.height / 2;
|
||||
|
||||
this._rootY = rootY;
|
||||
this._pageH = document.documentElement.scrollHeight;
|
||||
const pageW = document.documentElement.scrollWidth;
|
||||
const spineX = 20;
|
||||
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('id', 'root-network');
|
||||
svg.setAttribute('class', 'root-network-svg');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
svg.style.height = this._pageH + 'px';
|
||||
svg.style.width = pageW + 'px';
|
||||
|
||||
// Collect junction points for each section
|
||||
const junctions = [];
|
||||
this.sections.forEach((sec, si) => {
|
||||
const headerEl = document.querySelector(sec.headerSel);
|
||||
if (!headerEl) return;
|
||||
const hRect = headerEl.getBoundingClientRect();
|
||||
const jy = hRect.top + window.scrollY + hRect.height / 2;
|
||||
junctions.push({ y: jy, color: sec.color, idx: si });
|
||||
});
|
||||
|
||||
if (junctions.length === 0) { document.body.appendChild(svg); return; }
|
||||
|
||||
// --- Initial curve: root ball center → (spineX, first section Y) ---
|
||||
const firstJ = junctions[0];
|
||||
const initD = this.wavyPath(rootX, rootY, spineX, firstJ.y, 42, 0.06);
|
||||
const initPath = this.makeLine(svg, initD, '#8B5CF6', 2.0);
|
||||
const initDot = this.makeDot(svg, spineX, firstJ.y, 3, firstJ.color);
|
||||
this.tendrils.push({ path: initPath, dot: initDot, targetY: firstJ.y, len: 0, opacity: 0.7, isTrunk: true, sectionIdx: 0 });
|
||||
|
||||
// --- Spine segments + branch stubs ---
|
||||
let prevY = firstJ.y;
|
||||
for (let i = 1; i < junctions.length; i++) {
|
||||
const j = junctions[i];
|
||||
const seed = 100 + i * 73;
|
||||
|
||||
// Spine segment: vertical along spineX
|
||||
const spineSW = this.taperSW(j.y, 1.8);
|
||||
const spineD = this.wavyPath(spineX, prevY, spineX, j.y, seed, 0.03);
|
||||
const spinePath = this.makeLine(svg, spineD, j.color, Math.max(spineSW, 1.0));
|
||||
const spineDot = this.makeDot(svg, spineX, j.y, 3, j.color);
|
||||
this.tendrils.push({ path: spinePath, dot: spineDot, targetY: j.y, len: 0, opacity: 0.7, isTrunk: true, sectionIdx: j.idx });
|
||||
|
||||
// Branch stub: short horizontal reach to the right
|
||||
const stubD = this.wavyPath(spineX, j.y, spineX + 50, j.y, seed + 37, 0.15);
|
||||
const stubPath = this.makeLine(svg, stubD, j.color, 1.2);
|
||||
this.tendrils.push({ path: stubPath, dot: null, targetY: j.y, len: 0, opacity: 0.5, parentY: j.y });
|
||||
|
||||
prevY = j.y;
|
||||
}
|
||||
|
||||
// Also add a branch stub for the first junction
|
||||
const firstStubD = this.wavyPath(spineX, firstJ.y, spineX + 50, firstJ.y, 137, 0.15);
|
||||
const firstStubPath = this.makeLine(svg, firstStubD, firstJ.color, 1.2);
|
||||
this.tendrils.push({ path: firstStubPath, dot: null, targetY: firstJ.y, len: 0, opacity: 0.5, parentY: firstJ.y });
|
||||
|
||||
document.body.appendChild(svg);
|
||||
|
||||
// Measure path lengths + set up dash animation
|
||||
requestAnimationFrame(() => {
|
||||
this.tendrils.forEach(t => {
|
||||
try {
|
||||
t.len = t.path.getTotalLength();
|
||||
t.path.style.strokeDasharray = t.len;
|
||||
t.path.style.strokeDashoffset = t.len;
|
||||
t.path.style.opacity = '0';
|
||||
if (t.dot) t.dot.style.opacity = '0';
|
||||
} catch(e) {}
|
||||
});
|
||||
this.updateScroll();
|
||||
});
|
||||
},
|
||||
|
||||
drawAll() {
|
||||
const old = document.getElementById('root-network');
|
||||
if (old) old.remove();
|
||||
|
||||
@@ -2,43 +2,63 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block announce %}
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet">
|
||||
<nav class="cm-header-nav" role="navigation" aria-label="Application">
|
||||
<div class="cm-header-nav__inner">
|
||||
<a href="#" data-path="/campaigns" class="cm-header-nav__link">
|
||||
<span class="material-icons">campaign</span>
|
||||
<span class="cm-header-nav__label">Campaigns</span>
|
||||
</a>
|
||||
<a href="#" data-path="/map" class="cm-header-nav__link">
|
||||
<span class="material-icons">map</span>
|
||||
<span class="cm-header-nav__label">Map</span>
|
||||
</a>
|
||||
<a href="#" data-path="/shifts" class="cm-header-nav__link">
|
||||
<span class="material-icons">groups</span>
|
||||
<span class="cm-header-nav__label">Volunteer</span>
|
||||
</a>
|
||||
<a href="#" data-path="/gallery" class="cm-header-nav__link">
|
||||
<span class="material-icons">play_circle</span>
|
||||
<span class="cm-header-nav__label">Gallery</span>
|
||||
</a>
|
||||
<a href="#" data-path="/responses" class="cm-header-nav__link">
|
||||
<span class="material-icons">forum</span>
|
||||
<span class="cm-header-nav__label">Responses</span>
|
||||
</a>
|
||||
<a href="#" data-path="/donate" class="cm-header-nav__link">
|
||||
<span class="material-icons">favorite</span>
|
||||
<span class="cm-header-nav__label">Donate</span>
|
||||
</a>
|
||||
<a href="#" data-path="/login" class="cm-header-nav__link">
|
||||
<span class="material-icons">login</span>
|
||||
<span class="cm-header-nav__label">Sign In</span>
|
||||
<div class="cm-header-nav__brand">
|
||||
<a href="#" data-path="/home" class="cm-header-nav__brand-link">
|
||||
<span class="cm-header-nav__brand-text">{{ config.site_name }}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="cm-header-nav__links">
|
||||
<div class="cm-header-nav__links-inner">
|
||||
<a href="#" data-path="/" class="cm-header-nav__link" data-nav-id="home" target="_blank" rel="noopener noreferrer"><span class="material-icons-outlined">home</span><span class="cm-header-nav__label">Home</span></a>
|
||||
<a href="#" data-path="/campaigns" class="cm-header-nav__link" data-nav-id="campaigns"><span class="material-icons-outlined">send</span><span class="cm-header-nav__label">Campaigns</span></a>
|
||||
<a href="#" data-path="/map" class="cm-header-nav__link" data-nav-id="map"><span class="material-icons-outlined">place</span><span class="cm-header-nav__label">Map</span></a>
|
||||
<a href="#" data-path="/shifts" class="cm-header-nav__link" data-nav-id="shifts"><span class="material-icons-outlined">event</span><span class="cm-header-nav__label">Shifts</span></a>
|
||||
<a href="#" data-path="/events" class="cm-header-nav__link" data-nav-id="events" target="_blank" rel="noopener noreferrer"><span class="material-icons-outlined">event</span><span class="cm-header-nav__label">Events</span></a>
|
||||
<a href="#" data-path="/gallery" class="cm-header-nav__link" data-nav-id="gallery"><span class="material-icons-outlined">play_circle</span><span class="cm-header-nav__label">Gallery</span></a>
|
||||
<a href="#" data-path="/pricing" class="cm-header-nav__link" data-nav-id="pricing"><span class="material-icons-outlined">attach_money</span><span class="cm-header-nav__label">Pricing</span></a>
|
||||
<a href="#" data-path="/shop" class="cm-header-nav__link" data-nav-id="shop"><span class="material-icons-outlined">shopping_bag</span><span class="cm-header-nav__label">Shop</span></a>
|
||||
<a href="#" data-path="/donate" class="cm-header-nav__link" data-nav-id="donate"><span class="material-icons-outlined">favorite_border</span><span class="cm-header-nav__label">Donate</span></a>
|
||||
<a href="/" class="cm-header-nav__link" data-nav-id="landing"><span class="material-icons-outlined">language</span><span class="cm-header-nav__label">Website</span></a>
|
||||
<a href="/docs/" class="cm-header-nav__link" data-nav-id="docs"><span class="material-icons-outlined">menu_book</span><span class="cm-header-nav__label">Docs</span></a>
|
||||
<a href="#" data-path="/app" class="cm-header-nav__link">
|
||||
<span class="material-icons-outlined">dashboard</span>
|
||||
<span class="cm-header-nav__label">Admin</span>
|
||||
</a>
|
||||
</div>
|
||||
<button class="cm-header-nav__hamburger" aria-label="Open navigation menu">
|
||||
<span class="material-icons-outlined">menu</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="cm-header-nav__mobile-drawer" id="cm-mobile-drawer">
|
||||
<div class="cm-header-nav__mobile-header">
|
||||
<span class="cm-header-nav__brand-text">{{ config.site_name }}</span>
|
||||
<button class="cm-header-nav__mobile-close" aria-label="Close navigation menu">
|
||||
<span class="material-icons-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="cm-header-nav__mobile-links">
|
||||
<a href="#" data-path="/" class="cm-header-nav__mobile-link" data-nav-id="home" target="_blank" rel="noopener noreferrer"><span class="material-icons-outlined">home</span><span>Home</span></a>
|
||||
<a href="#" data-path="/campaigns" class="cm-header-nav__mobile-link" data-nav-id="campaigns"><span class="material-icons-outlined">send</span><span>Campaigns</span></a>
|
||||
<a href="#" data-path="/map" class="cm-header-nav__mobile-link" data-nav-id="map"><span class="material-icons-outlined">place</span><span>Map</span></a>
|
||||
<a href="#" data-path="/shifts" class="cm-header-nav__mobile-link" data-nav-id="shifts"><span class="material-icons-outlined">event</span><span>Shifts</span></a>
|
||||
<a href="#" data-path="/events" class="cm-header-nav__mobile-link" data-nav-id="events" target="_blank" rel="noopener noreferrer"><span class="material-icons-outlined">event</span><span>Events</span></a>
|
||||
<a href="#" data-path="/gallery" class="cm-header-nav__mobile-link" data-nav-id="gallery"><span class="material-icons-outlined">play_circle</span><span>Gallery</span></a>
|
||||
<a href="#" data-path="/pricing" class="cm-header-nav__mobile-link" data-nav-id="pricing"><span class="material-icons-outlined">attach_money</span><span>Pricing</span></a>
|
||||
<a href="#" data-path="/shop" class="cm-header-nav__mobile-link" data-nav-id="shop"><span class="material-icons-outlined">shopping_bag</span><span>Shop</span></a>
|
||||
<a href="#" data-path="/donate" class="cm-header-nav__mobile-link" data-nav-id="donate"><span class="material-icons-outlined">favorite_border</span><span>Donate</span></a>
|
||||
<a href="/" class="cm-header-nav__mobile-link" data-nav-id="landing"><span class="material-icons-outlined">language</span><span>Website</span></a>
|
||||
<a href="/docs/" class="cm-header-nav__mobile-link" data-nav-id="docs"><span class="material-icons-outlined">menu_book</span><span>Docs</span></a>
|
||||
<a href="#" data-path="/app" class="cm-header-nav__mobile-link">
|
||||
<span class="material-icons-outlined">dashboard</span>
|
||||
<span>Admin</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cm-header-nav__mobile-overlay" id="cm-mobile-overlay"></div>
|
||||
<script>
|
||||
// Resolve nav link hrefs based on the current browser hostname.
|
||||
// localhost → http://localhost:{ADMIN_PORT}
|
||||
// subdomain.example.org → {proto}://app.example.org
|
||||
(function() {
|
||||
var h = location.hostname;
|
||||
var base;
|
||||
@@ -50,76 +70,188 @@
|
||||
else { parts.unshift('app'); }
|
||||
base = location.protocol + '//' + parts.join('.');
|
||||
}
|
||||
var links = document.querySelectorAll('.cm-header-nav__link[data-path]');
|
||||
var links = document.querySelectorAll('[data-path]');
|
||||
for (var i = 0; i < links.length; i++) {
|
||||
links[i].setAttribute('href', base + links[i].getAttribute('data-path'));
|
||||
}
|
||||
// Highlight active nav link based on current path
|
||||
var path = location.pathname;
|
||||
var activeLink = null;
|
||||
if (path.indexOf('/docs') === 0) activeLink = 'docs';
|
||||
document.querySelectorAll('.cm-header-nav__link[data-nav-id], .cm-header-nav__mobile-link[data-nav-id]').forEach(function(el) {
|
||||
if (el.getAttribute('data-nav-id') === activeLink) {
|
||||
el.classList.add('cm-header-nav__link--active');
|
||||
}
|
||||
});
|
||||
// Hamburger toggle
|
||||
var hamburger = document.querySelector('.cm-header-nav__hamburger');
|
||||
var drawer = document.getElementById('cm-mobile-drawer');
|
||||
var overlay = document.getElementById('cm-mobile-overlay');
|
||||
var closeBtn = document.querySelector('.cm-header-nav__mobile-close');
|
||||
function openDrawer() { drawer.classList.add('open'); overlay.classList.add('open'); }
|
||||
function closeDrawer() { drawer.classList.remove('open'); overlay.classList.remove('open'); }
|
||||
if (hamburger) hamburger.addEventListener('click', openDrawer);
|
||||
if (closeBtn) closeBtn.addEventListener('click', closeDrawer);
|
||||
if (overlay) overlay.addEventListener('click', closeDrawer);
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
/* Override MkDocs Material announce bar container */
|
||||
.md-banner {
|
||||
background: #6f42c1 !important;
|
||||
background: linear-gradient(135deg, #005a9c 0%, #007acc 100%) !important;
|
||||
color: #ffffff !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
/* Hide the dismiss (X) button that Material adds for announce.dismiss */
|
||||
.md-banner__button {
|
||||
display: none !important;
|
||||
}
|
||||
.cm-header-nav {
|
||||
background: #6f42c1;
|
||||
min-height: 40px;
|
||||
background: linear-gradient(135deg, #005a9c 0%, #007acc 100%);
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
z-index: 10;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.cm-header-nav__inner {
|
||||
.cm-header-nav a {
|
||||
color: rgba(255, 255, 255, 0.85) !important;
|
||||
}
|
||||
.cm-header-nav__brand-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
gap: 10px;
|
||||
text-decoration: none !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.cm-header-nav__brand-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #fff !important;
|
||||
}
|
||||
.cm-header-nav__links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.cm-header-nav__links-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.cm-header-nav__link {
|
||||
color: rgba(255, 255, 255, 0.85) !important;
|
||||
text-decoration: none !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 16px;
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
font-size: 14px;
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
padding-bottom: 2px;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.cm-header-nav__link:hover {
|
||||
background: rgba(255,255,255,0.15);
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
transform: translateY(-1px);
|
||||
color: #fff !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
.cm-header-nav__link:active {
|
||||
transform: translateY(0);
|
||||
.cm-header-nav__link--active,
|
||||
.cm-header-nav__link--active:hover {
|
||||
color: #fff !important;
|
||||
font-weight: 600;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.cm-header-nav__link .material-icons {
|
||||
.cm-header-nav__link .material-icons-outlined {
|
||||
font-size: 16px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.cm-header-nav__hamburger {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
color: #fff;
|
||||
}
|
||||
.cm-header-nav__hamburger .material-icons-outlined {
|
||||
font-size: 24px;
|
||||
}
|
||||
/* Mobile drawer */
|
||||
.cm-header-nav__mobile-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: -280px;
|
||||
width: 280px;
|
||||
height: 100vh;
|
||||
background: #0d1b2a;
|
||||
z-index: 10001;
|
||||
transition: right 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.cm-header-nav__mobile-drawer.open {
|
||||
right: 0;
|
||||
}
|
||||
.cm-header-nav__mobile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
background: #1b2838;
|
||||
}
|
||||
.cm-header-nav__mobile-close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: rgba(255,255,255,0.85);
|
||||
padding: 4px;
|
||||
}
|
||||
.cm-header-nav__mobile-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
.cm-header-nav__mobile-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 24px;
|
||||
color: rgba(255,255,255,0.85) !important;
|
||||
text-decoration: none !important;
|
||||
font-size: 15px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.cm-header-nav__mobile-link:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: #fff !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
.cm-header-nav__mobile-link--active {
|
||||
color: #fff !important;
|
||||
font-weight: 600;
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
.cm-header-nav__mobile-link .material-icons-outlined {
|
||||
font-size: 18px;
|
||||
}
|
||||
.cm-header-nav__mobile-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 10000;
|
||||
}
|
||||
.cm-header-nav__mobile-overlay.open {
|
||||
display: block;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.cm-header-nav { padding: 0 8px; }
|
||||
.cm-header-nav__label { display: none; }
|
||||
.cm-header-nav__link { padding: 8px 10px; }
|
||||
.cm-header-nav__inner { gap: 4px; }
|
||||
.cm-header-nav { padding: 0 16px; }
|
||||
.cm-header-nav__links-inner { display: none; }
|
||||
.cm-header-nav__hamburger { display: block; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
301
mkdocs/docs/stylesheets/docs-comments.css
Normal file
301
mkdocs/docs/stylesheets/docs-comments.css
Normal file
@@ -0,0 +1,301 @@
|
||||
/* ==========================================================================
|
||||
Docs Comments Widget — MkDocs Material Theme Integration
|
||||
Uses Material CSS custom properties for automatic dark/light theme support
|
||||
========================================================================== */
|
||||
|
||||
.dc-widget {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--md-default-fg-color--lightest, rgba(0,0,0,0.07));
|
||||
}
|
||||
|
||||
.dc-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 1rem;
|
||||
color: var(--md-default-fg-color, inherit);
|
||||
}
|
||||
|
||||
/* --- Comment List --- */
|
||||
|
||||
.dc-comments-list {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.dc-comments-list.dc-loading {
|
||||
color: var(--md-default-fg-color--light, #666);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dc-empty {
|
||||
color: var(--md-default-fg-color--light, #666);
|
||||
font-style: italic;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.dc-error {
|
||||
color: var(--md-accent-fg-color, #e53935);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* --- Individual Comment --- */
|
||||
|
||||
.dc-comment {
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: var(--md-code-bg-color, rgba(0,0,0,0.04));
|
||||
border: 1px solid var(--md-default-fg-color--lightest, rgba(0,0,0,0.07));
|
||||
}
|
||||
|
||||
.dc-comment__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dc-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dc-avatar--initials {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--md-primary-fg-color, #6f42c1);
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.dc-comment__author {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--md-default-fg-color, inherit);
|
||||
}
|
||||
|
||||
.dc-comment__badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--md-default-fg-color--lightest, rgba(0,0,0,0.07));
|
||||
color: var(--md-default-fg-color--light, #666);
|
||||
}
|
||||
|
||||
.dc-comment__time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--md-default-fg-color--light, #999);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.dc-comment__body {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
color: var(--md-default-fg-color, inherit);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.dc-comment__body a {
|
||||
color: var(--md-accent-fg-color, var(--md-primary-fg-color));
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dc-inline-code {
|
||||
font-family: var(--md-code-font-family, 'JetBrains Mono', monospace);
|
||||
font-size: 0.85em;
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: 3px;
|
||||
background: var(--md-code-bg-color, rgba(0,0,0,0.04));
|
||||
}
|
||||
|
||||
/* --- Form --- */
|
||||
|
||||
.dc-form {
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background: var(--md-code-bg-color, rgba(0,0,0,0.04));
|
||||
border: 1px solid var(--md-default-fg-color--lightest, rgba(0,0,0,0.07));
|
||||
}
|
||||
|
||||
.dc-form__row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.dc-form__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--md-default-fg-color, inherit);
|
||||
}
|
||||
|
||||
.dc-input,
|
||||
.dc-textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid var(--md-default-fg-color--lightest, rgba(0,0,0,0.12));
|
||||
border-radius: 6px;
|
||||
background: var(--md-default-bg-color, #fff);
|
||||
color: var(--md-default-fg-color, inherit);
|
||||
transition: border-color 0.15s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dc-input:focus,
|
||||
.dc-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--md-primary-fg-color, #6f42c1);
|
||||
box-shadow: 0 0 0 2px rgba(111, 66, 193, 0.15);
|
||||
}
|
||||
|
||||
.dc-textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.dc-form__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dc-form__note {
|
||||
font-size: 0.75rem;
|
||||
color: var(--md-default-fg-color--light, #999);
|
||||
margin: 0.5rem 0 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Honeypot — visually hidden */
|
||||
.dc-honeypot {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
padding: 0 !important;
|
||||
margin: -1px !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
/* --- Buttons --- */
|
||||
|
||||
.dc-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dc-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dc-btn--primary {
|
||||
background: var(--md-primary-fg-color, #6f42c1);
|
||||
color: var(--md-primary-bg-color, #fff);
|
||||
}
|
||||
|
||||
.dc-btn--primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.dc-btn--outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--md-primary-fg-color, #6f42c1);
|
||||
color: var(--md-primary-fg-color, #6f42c1);
|
||||
}
|
||||
|
||||
.dc-btn--outline:hover:not(:disabled) {
|
||||
background: rgba(111, 66, 193, 0.08);
|
||||
}
|
||||
|
||||
.dc-btn--text {
|
||||
background: transparent;
|
||||
color: var(--md-default-fg-color--light, #999);
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.dc-btn--text:hover {
|
||||
color: var(--md-default-fg-color, inherit);
|
||||
}
|
||||
|
||||
/* --- Messages --- */
|
||||
|
||||
.dc-form-message {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
margin-top: 0.5rem;
|
||||
animation: dc-fade-in 0.2s ease;
|
||||
}
|
||||
|
||||
.dc-form-message--error {
|
||||
background: rgba(229, 57, 53, 0.1);
|
||||
color: #e53935;
|
||||
border: 1px solid rgba(229, 57, 53, 0.2);
|
||||
}
|
||||
|
||||
.dc-form-message--success {
|
||||
background: rgba(76, 175, 80, 0.1);
|
||||
color: #4caf50;
|
||||
border: 1px solid rgba(76, 175, 80, 0.2);
|
||||
}
|
||||
|
||||
@keyframes dc-fade-in {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* --- Responsive --- */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.dc-form__row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dc-comment__time {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
order: 10;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.dc-form__actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.dc-btn {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,32 @@
|
||||
Testing page.
|
||||
|
||||
|
||||
<div class="photo-block" data-photo-id="1" data-size="large" data-caption="" data-link-to-gallery="true" data-alignment="center">Loading...</div>
|
||||
|
||||
|
||||
|
||||
<div class="photo-card-block" data-photo-id="1" data-photo-title="vlcsnap-2026-01-09-15h39m52s898.png" data-photo-format="png" data-photo-width="1920" data-photo-height="1040" data-photo-views="0" style="max-width: 480px; margin: 0 auto;">
|
||||
<a href="http://app.org/gallery?expanded=photo-1" style="display: block; text-decoration: none; color: inherit; border-radius: 12px; overflow: hidden; background: #1b2838; box-shadow: 0 4px 12px rgba(0,0,0,0.3);">
|
||||
<div style="position: relative; padding-bottom: 66.67%; background: #0d1b2a; overflow: hidden;">
|
||||
<img src="http://app.orgdata:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22480%22%20height%3D%22320%22%20viewBox%3D%220%200%20480%20320%22%3E%3Crect%20fill%3D%22%230d1b2a%22%20width%3D%22480%22%20height%3D%22320%22%2F%3E%3Ccircle%20cx%3D%22240%22%20cy%3D%22160%22%20r%3D%2232%22%20fill%3D%22rgba(46%2C125%2C50%2C0.6)%22%2F%3E%3Crect%20x%3D%22224%22%20y%3D%22144%22%20width%3D%2232%22%20height%3D%2232%22%20rx%3D%224%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%222%22%2F%3E%3Ccircle%20cx%3D%22234%22%20cy%3D%22154%22%20r%3D%223%22%20fill%3D%22%23fff%22%2F%3E%3Cpath%20d%3D%22M256%20176l-10-10L224%20176%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%222%22%2F%3E%3C%2Fsvg%3E" alt="vlcsnap-2026-01-09-15h39m52s898.png" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover;" />
|
||||
<span style="position: absolute; top: 8px; left: 8px; background: #2e7d32; color: #fff; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 4px;">PNG</span>
|
||||
<span style="position: absolute; bottom: 8px; right: 8px; background: rgba(0,0,0,0.8); color: #fff; font-size: 12px; font-weight: 500; padding: 2px 6px; border-radius: 4px;">1920×1040</span>
|
||||
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 56px; height: 56px; background: rgba(0,0,0,0.5); border-radius: 50%; display: flex; align-items: center; justify-content: center;">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 12px 16px;">
|
||||
<div style="color: #fff; font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">vlcsnap-2026-01-09-15h39m52s898.png</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 6px;">
|
||||
<span style="color: #8899aa; font-size: 13px;">0 views</span>
|
||||
<span style="color: #43cea2; font-size: 13px; font-weight: 500;">View →</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="cm-product-mlrk53ro" style="text-align:center;padding:32px 20px;background:linear-gradient(135deg,#1a1a2e,#16213e);border-radius:12px;margin:16px 0;max-width:420px;margin-left:auto;margin-right:auto;">
|
||||
<div style="width:80px;height:80px;border-radius:12px;background:linear-gradient(135deg,#9d4edd,#722ed1);display:flex;align-items:center;justify-content:center;margin:0 auto 16px;"><span style="font-size:36px;color:#fff;">🛒</span></div>
|
||||
<div style="display:inline-block;padding:2px 10px;border-radius:4px;background:#1890ff;color:#fff;font-size:11px;font-weight:600;margin-bottom:8px;">DIGITAL</div>
|
||||
|
||||
Reference in New Issue
Block a user