Compare commits

..

No commits in common. "1f240ad518e01b298c5befe01f5eea2c842285d2" and "2ae7d8b968781d0ad15dde977ba7ae96910b8f15" have entirely different histories.

168 changed files with 274 additions and 17907 deletions

View File

@ -188,13 +188,6 @@ MEDIA_API_PORT=4100
MEDIA_API_PUBLIC_URL=http://media-api:4100
# Used during admin Docker build to set the media API endpoint for Vite
VITE_MEDIA_API_URL=http://changemaker-media-api:4100
# HLS adaptive bitrate transcoding. When 'true', uploaded videos are queued
# for FFmpeg transcoding into 360p/720p/1080p HLS variants and the player
# prefers HLS over the MP4 range-request stream. When 'false' (default),
# uploads are tagged SKIPPED and the player falls back to MP4 — no DB or
# disk impact, fully reversible. The worker is always registered so existing
# PENDING jobs from a prior run still process if you flip the flag back on.
ENABLE_HLS_TRANSCODE=false
MEDIA_ROOT=/media/library
MEDIA_UPLOADS=/media/uploads
MAX_UPLOAD_SIZE_GB=10

View File

@ -298,7 +298,6 @@ Most features are toggled via **SiteSettings** in the database (admin Settings p
```bash
# .env feature flags (env-level)
ENABLE_MEDIA_FEATURES=true # Media manager
ENABLE_HLS_TRANSCODE=true # HLS adaptive bitrate transcoding (off by default)
ENABLE_PAYMENTS=true # Stripe integration
ENABLE_SMS=true # SMS campaigns
ENABLE_CHAT=true # Rocket.Chat
@ -490,13 +489,9 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
**Files:**
- `api/src/modules/media/` — Fastify media API (videos, reactions, jobs, analytics)
- `api/src/modules/media/services/` — FFprobe, thumbnail, **HLS transcode** services
- `api/src/modules/media/routes/` — Video CRUD, actions, schedule, analytics, tracking, upload, **HLS streaming**
- `api/src/modules/media/services/` — FFprobe, video analytics service
- `api/src/modules/media/routes/` — Video CRUD, actions, schedule, analytics, tracking, upload
- `api/src/services/video-schedule-queue.service.ts` — BullMQ queue for scheduled publishing
- `api/src/services/hls-transcode-queue.service.ts` — BullMQ queue for HLS adaptive bitrate transcoding (concurrency 1, in-process worker)
- `api/src/modules/media/routes/hls.routes.ts` — Master/variant playlist + segment serving with signed URLs
- `api/scripts/backfill-hls.ts` — Backfill HLS for pre-existing videos (`npm run backfill:hls`)
- `admin/src/lib/use-hls.ts` — React hook attaching hls.js (Chrome/FF/Edge) or native (Safari/iOS)
- `admin/src/lib/media-api.ts` — Dedicated axios instance for Media API
- `admin/src/pages/media/LibraryPage.tsx` — Video library with quick actions + calendar
- `admin/src/pages/media/AnalyticsDashboardPage.tsx` — Global analytics dashboard
@ -504,15 +499,7 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
- `admin/src/pages/public/MediaGalleryPage.tsx` — Public video gallery
- `admin/src/components/media/` — VideoCard, VideoActions, modals, charts
**Features:** Video CRUD with FFprobe metadata, quick actions, scheduled publishing (BullMQ + timezones), analytics (GDPR-compliant), public tracking endpoints, keyboard shortcuts, **HLS adaptive bitrate streaming (360p/720p/1080p, MP4 fallback)**.
**HLS adaptive bitrate streaming:**
- On upload, a BullMQ `hls-transcode` job runs FFmpeg to produce a master playlist + 3 keyframe-aligned variants under `/media/local/hls/{videoId}/`. Concurrency is 1; the worker runs in-process with the media-api Fastify server.
- Player prefers HLS over MP4 when `Video.hlsStatus === 'READY'`. MP4 streaming routes stay as the always-on fallback for un-transcoded videos and for hover-preview cards (where 200ms hls.js init defeats the UX — `PublicVideoCard` stays MP4).
- `useHls()` hook lazy-imports hls.js (~75 KB gzipped, never enters main bundle), uses native HLS on Safari/iOS, gives up after 2 NETWORK_ERROR retries so the MP4 fallback can kick in.
- Manifest URLs are HMAC-signed (`?sig=&exp=&uid=`) per existing `signMediaPath()` pattern. Variant playlists rewrite their segment URIs server-side at fetch time so each segment carries a fresh signature.
- Feature flag: `ENABLE_HLS_TRANSCODE` (default `false`). When off, uploads are tagged `SKIPPED` and the player falls back to MP4 — fully reversible. The worker stays registered so existing `PENDING` jobs still process if the flag flips back on.
- Backfill: `docker compose exec api npm run backfill:hls` enqueues all `hlsStatus IS NULL` videos. Bypasses the flag (operator opt-in). At ~2 min per 1080p video, throughput is ~30/hour.
**Features:** Video CRUD with FFprobe metadata, quick actions, scheduled publishing (BullMQ + timezones), analytics (GDPR-compliant), public tracking endpoints, keyboard shortcuts
**Routes:**
- Admin: `/app/media/library`, `/app/media/analytics`, `/app/media/shared`, `/app/media/jobs`
@ -783,27 +770,6 @@ Check in order:
### Database/Redis Connection Failures
Check container status (`docker compose ps`), verify credentials in `.env`, check logs (`docker compose logs <service> --tail 50`). Test DB: `docker compose exec api npx prisma db execute --stdin <<< "SELECT 1"`. Test Redis: `docker compose exec redis-changemaker redis-cli -a $REDIS_PASSWORD ping`.
### Video Stuck in HLS PROCESSING / FAILED with EACCES
**Symptom:** A video shows `hlsStatus = 'PROCESSING'` for many minutes; or `'FAILED'` with `hls_transcode_error LIKE '%EACCES%'`. Player keeps falling back to MP4.
Check in order:
1. **First-run perms.** If `hls_transcode_error` contains `EACCES: permission denied, mkdir '/media/local/hls/<id>'`, the bind-mount got created as `root:root` but the Node process runs as `node` (UID 1000). One-time fix:
```
docker compose exec -u 0 media-api chown -R 1000:1000 /media/local/hls
```
Then reset and re-enqueue:
```
docker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 -c "UPDATE videos SET hls_status = NULL, hls_transcode_error = NULL WHERE hls_status = 'FAILED';"
docker compose exec api npm run backfill:hls
```
2. **Worker running:** `docker compose logs media-api --tail 100 | grep -i hls` — expect `[hls]` lines for the queue worker startup and per-job progress.
3. **FFmpeg in container:** `docker compose exec media-api ffmpeg -version` — should print FFmpeg version. (Already in `Dockerfile.media`.)
4. **Queue depth:** `docker compose exec redis-changemaker redis-cli -a $REDIS_PASSWORD LLEN bull:hls-transcode:wait` — non-zero means jobs are queued behind a slow one.
5. **Disk space at output:** `docker compose exec media-api df -h /media/local/hls` — transcoding can consume several GB per video.
6. **Failure record:** `docker compose exec api npx prisma studio` → Video table → check `hlsTranscodeError`.
To force a re-transcode of a failed video, set `hlsStatus = NULL` in the DB and run `npm run backfill:hls`.
---
## V1 Reference (Legacy)

View File

@ -33,7 +33,6 @@
"grapesjs-tabs": "^1.0.6",
"grapesjs-touch": "^0.1.1",
"grapesjs-typed": "^2.0.1",
"hls.js": "^1.6.16",
"html5-qrcode": "^2.3.8",
"jwt-decode": "^4.0.0",
"leaflet": "^1.9.4",
@ -2635,12 +2634,6 @@
"node": ">= 0.4"
}
},
"node_modules/hls.js": {
"version": "1.6.16",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
"license": "Apache-2.0"
},
"node_modules/html-entities": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz",

View File

@ -34,7 +34,6 @@
"grapesjs-tabs": "^1.0.6",
"grapesjs-touch": "^0.1.1",
"grapesjs-typed": "^2.0.1",
"hls.js": "^1.6.16",
"html5-qrcode": "^2.3.8",
"jwt-decode": "^4.0.0",
"leaflet": "^1.9.4",

View File

@ -3,7 +3,6 @@ import { Alert, Spin } from 'antd';
import { PlayCircleOutlined } from '@ant-design/icons';
import { getAuthCallbacks } from '@/lib/api';
import { signedMediaUrl } from '@/lib/media-url';
import { useHls } from '@/lib/use-hls';
export interface VideoMetadata {
id: number;
@ -16,8 +15,6 @@ export interface VideoMetadata {
quality: string | null;
streamUrl: string;
thumbnailUrl: string | null;
hlsStatus?: 'PENDING' | 'PROCESSING' | 'READY' | 'FAILED' | 'SKIPPED' | null;
hlsManifestUrl?: string | null;
createdAt: string;
}
@ -71,13 +68,6 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Attach HLS when manifest is ready. Must be called unconditionally on
// every render (rules of hooks) — even before the loading/error early
// returns. The hook is a no-op when manifestUrl is null.
const hlsManifestUrl = metadata?.hlsStatus === 'READY' ? metadata.hlsManifestUrl ?? null : null;
const { error: hlsError } = useHls(videoRef, hlsManifestUrl);
const useMp4Src = !hlsManifestUrl || !!hlsError;
// Expose control methods via ref
useImperativeHandle(ref, () => ({
play: () => {
@ -160,9 +150,7 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
const data = await response.json();
// For admin previews of unpublished media, sign stream/thumbnail URLs
// (the legacy ?token=<JWT> path was removed 2026-04-12). The HLS
// manifest URL is already signed server-side by the metadata route, so
// we leave it untouched.
// (the legacy ?token=<JWT> path was removed 2026-04-12).
if (isAdmin) {
if (data.streamUrl) data.streamUrl = await signedMediaUrl(data.streamUrl);
if (data.thumbnailUrl) data.thumbnailUrl = await signedMediaUrl(data.thumbnailUrl);
@ -224,10 +212,6 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
? (metadata.height / metadata.width) * 100
: 56.25; // Default to 16:9
// (HLS attachment + MP4 fallback flag are computed at the top of the
// component, before the loading/error early returns, to satisfy the rules
// of hooks. See useMp4Src above.)
return (
<div
style={{
@ -240,7 +224,7 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
>
<video
ref={videoRef}
src={useMp4Src ? metadata.streamUrl : undefined}
src={metadata.streamUrl}
poster={poster || metadata.thumbnailUrl || undefined}
autoPlay={autoplay}
controls={controls}

View File

@ -3,7 +3,6 @@ import { useEffect, useRef, useState } from 'react';
import type { Video } from '@/types/media';
import { mediaApi } from '@/lib/media-api';
import { useSignedMediaUrl } from '@/lib/media-url';
import { useHls } from '@/lib/use-hls';
interface VideoViewerModalProps {
video: Video | null;
@ -17,16 +16,6 @@ export default function VideoViewerModal({ video, open, onClose }: VideoViewerMo
const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
const lastWatchTime = useRef<number>(0);
const streamUrl = useSignedMediaUrl(video ? `/media/videos/${video.id}/stream` : null);
// Sign the HLS manifest URL too so admin previews of unpublished videos
// can play HLS. The hook is a no-op for nulls.
const hlsManifestUrl = useSignedMediaUrl(
video && video.hlsStatus === 'READY'
? `/media/videos/${video.id}/hls/master.m3u8`
: null,
);
const { error: hlsError } = useHls(videoRef, hlsManifestUrl ?? null);
// Fall back to MP4 src when HLS isn't ready or hls.js fatal-errored.
const useMp4Src = !hlsManifestUrl || !!hlsError;
useEffect(() => {
if (open && video) {
@ -178,7 +167,7 @@ export default function VideoViewerModal({ video, open, onClose }: VideoViewerMo
>
<video
ref={videoRef}
src={useMp4Src ? streamUrl : undefined}
src={streamUrl}
controls
autoPlay
style={{

View File

@ -1,150 +0,0 @@
import { useEffect, useRef, useState, type RefObject } from 'react';
/**
* useHls attach an HLS source to a `<video>` element.
*
* Strategy:
* - If the browser natively plays application/vnd.apple.mpegurl (Safari/iOS),
* just set videoEl.src and let the platform handle it. No JS bundle cost.
* - Otherwise dynamic-import hls.js (Vite chunk-splits this so the
* ~75 KB gzipped library never enters the main bundle), instantiate it,
* attach to the video element, and load the manifest.
*
* Falls back gracefully: when `manifestUrl` is null or `enabled` is false,
* the hook is a no-op so the caller can still set `<video src={mp4Url}>`.
*
* On a fatal hls.js error, `error` is populated and the caller can switch to
* the MP4 fallback URL by re-rendering with `manifestUrl=null`.
*/
export interface UseHlsOptions {
enabled?: boolean;
}
export interface UseHlsResult {
ready: boolean;
error: Error | null;
}
export function useHls(
videoRef: RefObject<HTMLVideoElement | null>,
manifestUrl: string | null,
opts: UseHlsOptions = {},
): UseHlsResult {
const { enabled = true } = opts;
const [ready, setReady] = useState(false);
const [error, setError] = useState<Error | null>(null);
// Track the cleanup function so successive renders don't leak hls.js instances.
const cleanupRef = useRef<(() => void) | null>(null);
useEffect(() => {
setReady(false);
setError(null);
// Tear down any previous attachment.
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
if (!enabled || !manifestUrl) return;
const video = videoRef.current;
if (!video) return;
let cancelled = false;
// Native HLS path (Safari/iOS).
if (video.canPlayType('application/vnd.apple.mpegurl')) {
const onLoaded = () => setReady(true);
const onErr = () => setError(new Error('Native HLS playback error'));
video.addEventListener('loadedmetadata', onLoaded);
video.addEventListener('error', onErr);
video.src = manifestUrl;
cleanupRef.current = () => {
video.removeEventListener('loadedmetadata', onLoaded);
video.removeEventListener('error', onErr);
// Don't clear src — caller may want to swap to MP4.
};
return () => {
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
};
}
// hls.js path. Dynamic import keeps this out of the main bundle.
(async () => {
try {
const mod = await import('hls.js');
if (cancelled) return;
const Hls = mod.default;
if (!Hls.isSupported()) {
setError(new Error('HLS not supported in this browser'));
return;
}
const hls = new Hls({
maxBufferLength: 30,
maxMaxBufferLength: 60,
// Treat slow 3G gracefully: don't immediately escalate quality.
startLevel: -1,
});
// Track NETWORK_ERROR recoveries so a permanently-404 manifest gives
// up instead of looping — this is the signal callers use to trigger
// their MP4 fallback. 2 retries handles transient network blips.
let networkRetries = 0;
const MAX_NETWORK_RETRIES = 2;
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (!cancelled) setReady(true);
});
hls.on(Hls.Events.ERROR, (_evt, data) => {
if (!data.fatal) return;
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
if (networkRetries < MAX_NETWORK_RETRIES) {
networkRetries++;
hls.startLoad();
return;
}
setError(new Error(`HLS network error: ${data.details}`));
hls.destroy();
cleanupRef.current = null;
return;
case Hls.ErrorTypes.MEDIA_ERROR:
hls.recoverMediaError();
return;
default:
setError(new Error(`HLS fatal error: ${data.details}`));
hls.destroy();
cleanupRef.current = null;
}
});
hls.loadSource(manifestUrl);
cleanupRef.current = () => {
try {
hls.destroy();
} catch {
/* ignore */
}
};
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err : new Error('Failed to load hls.js'));
}
}
})();
return () => {
cancelled = true;
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
};
}, [enabled, manifestUrl, videoRef]);
return { ready, error };
}

View File

@ -1,11 +1,10 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Typography, Button, Tag, Spin, App, Input, Grid } from 'antd';
import { ShoppingCartOutlined, ArrowLeftOutlined, PlayCircleOutlined } from '@ant-design/icons';
import axios from 'axios';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useHls } from '@/lib/use-hls';
import type { Product, ProductType } from '@/types/api';
const { Title, Text, Paragraph } = Typography;
@ -214,17 +213,25 @@ export default function ProductDetailPage() {
)}
{/* Video */}
{hasVideo && product.videoId && (
{hasVideo && (
<div style={{ marginTop: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<PlayCircleOutlined />
<Text strong>Product Video</Text>
</div>
<ProductVideoPlayer
videoId={product.videoId}
streamUrl={product.videoStreamUrl!}
<video
controls
preload="metadata"
poster={product.videoThumbnailUrl || undefined}
/>
style={{
width: '100%',
borderRadius: 8,
background: '#000',
maxHeight: 360,
}}
>
<source src={product.videoStreamUrl!} type="video/mp4" />
</video>
</div>
)}
</div>
@ -284,38 +291,3 @@ export default function ProductDetailPage() {
</div>
);
}
// Product video player. Optimistically attempts the public HLS manifest; if
// the video isn't transcoded yet, hls.js fails fast (after 2 retries) and we
// fall back to the MP4 stream. This avoids needing the API to expose
// hlsStatus on the Product serializer for v1.
function ProductVideoPlayer({
videoId,
streamUrl,
poster,
}: {
videoId: number;
streamUrl: string;
poster?: string;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const hlsManifestUrl = `/media/public/${videoId}/hls/master.m3u8`;
const { error: hlsError } = useHls(videoRef, hlsManifestUrl);
const useMp4Src = !!hlsError;
return (
<video
ref={videoRef}
src={useMp4Src ? streamUrl : undefined}
controls
preload="metadata"
poster={poster}
style={{
width: '100%',
borderRadius: 8,
background: '#000',
maxHeight: 360,
}}
/>
);
}

View File

@ -26,7 +26,6 @@ import {
import { useNavigate } from 'react-router-dom';
import { mediaPublicApi, getOrCreateSessionId } from '@/lib/media-public-api';
import { MediaAuthProvider } from '@/contexts/MediaAuthContext';
import { useHls } from '@/lib/use-hls';
import LiveChat from '@/components/media/LiveChat';
import axios from 'axios';
@ -47,9 +46,6 @@ interface ShortVideo {
commentCount: number;
width: number | null;
height: number | null;
// HLS adaptive bitrate state. When 'READY', the player uses the HLS manifest
// for smoother streaming over restricted tunnels. Otherwise falls back to MP4.
hlsStatus?: 'PENDING' | 'PROCESSING' | 'READY' | 'FAILED' | 'SKIPPED' | null;
}
interface ShortsResponse {
@ -567,25 +563,10 @@ export default function ShortsPage() {
background: '#000',
}}
>
{/* Prefetch links for next videos.
When HLS is ready, prefetching the master manifest (~1 KB) warms the
browser cache so swiping forward starts playback in <100ms. For
MP4-only shorts we keep the original prefetch of the stream URL. */}
{shorts.slice(currentIndex + 1, currentIndex + 3).map((s) => {
if (!s.videoUrl) return null;
if (s.hlsStatus === 'READY') {
return (
<link
key={`prefetch-hls-${s.id}`}
rel="prefetch"
as="fetch"
href={`/media/public/${s.id}/hls/master.m3u8`}
crossOrigin="anonymous"
/>
);
}
return <link key={`prefetch-${s.id}`} rel="prefetch" href={`/media/public/${s.id}/stream`} />;
})}
{/* Prefetch links for next videos */}
{shorts.slice(currentIndex + 1, currentIndex + 3).map((s) => (
s.videoUrl && <link key={`prefetch-${s.id}`} rel="prefetch" href={`/media/public/${s.id}/stream`} />
))}
{shorts.map((short, index) => {
const distance = Math.abs(index - currentIndex);
@ -672,15 +653,23 @@ export default function ShortsPage() {
{/* Render tier: Full (active + adjacent), Simplified (medium), Placeholder (far) */}
{isNear ? (
<HlsAwareShortVideo
short={short}
isActive={isActive}
onRefChange={(el) => handleVideoRef(index, el)}
<video
ref={(el) => handleVideoRef(index, el)}
src={`/media/public/${short.id}/stream`}
poster={short.thumbnailPath ? `/media/public/${short.id}/thumbnail` : undefined}
loop={!autoplay}
playsInline
muted={muted}
isZoomed={isZoomed}
preload={isActive ? 'auto' : 'metadata'}
onClick={handleTogglePlay}
onEnded={isActive ? handleVideoEnded : undefined}
style={{
width: '100%',
height: '100%',
objectFit: isZoomed ? 'cover' : 'contain',
cursor: 'pointer',
background: '#000',
}}
/>
) : isMedium ? (
<img
@ -1275,65 +1264,3 @@ function ActionButton({
</div>
);
}
// HLS-aware video element for the shorts feed. Uses HLS when the short has
// hlsStatus='READY', otherwise falls back to the MP4 stream URL. Owns its
// own ref so useHls can attach hls.js cleanly, and forwards the underlying
// HTMLVideoElement to the parent's videoRefs Map via onRefChange.
interface HlsAwareShortVideoProps {
short: ShortVideo;
isActive: boolean;
onRefChange: (el: HTMLVideoElement | null) => void;
loop: boolean;
muted: boolean;
isZoomed: boolean;
onClick: () => void;
onEnded?: () => void;
}
function HlsAwareShortVideo({
short,
isActive,
onRefChange,
loop,
muted,
isZoomed,
onClick,
onEnded,
}: HlsAwareShortVideoProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const hlsManifestUrl = short.hlsStatus === 'READY'
? `/api/public/${short.id}/hls/master.m3u8`
: null;
const { error: hlsError } = useHls(videoRef, hlsManifestUrl);
const useMp4Src = !hlsManifestUrl || !!hlsError;
// Register/deregister with the parent's refs Map.
useEffect(() => {
onRefChange(videoRef.current);
return () => onRefChange(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<video
ref={videoRef}
src={useMp4Src ? `/media/public/${short.id}/stream` : undefined}
poster={short.thumbnailPath ? `/media/public/${short.id}/thumbnail` : undefined}
loop={loop}
playsInline
muted={muted}
preload={isActive ? 'auto' : 'metadata'}
onClick={onClick}
onEnded={onEnded}
style={{
width: '100%',
height: '100%',
objectFit: isZoomed ? 'cover' : 'contain',
cursor: 'pointer',
background: '#000',
}}
/>
);
}

View File

@ -33,11 +33,6 @@ export interface Video {
publishedAt?: string | null;
isShort?: boolean;
accessLevel?: 'free' | 'member' | 'premium';
// HLS adaptive bitrate state. Populated when the video has been transcoded
// and the HLS manifest is ready to serve. Players prefer this over the MP4
// stream when present.
hlsStatus?: 'PENDING' | 'PROCESSING' | 'READY' | 'FAILED' | 'SKIPPED' | null;
hlsManifestUrl?: string | null;
}
// Video Analytics interfaces

View File

@ -157,20 +157,3 @@ export function getVideoThumbnailUrl(videoId: number): string {
export function getVideoMetadataUrl(videoId: number): string {
return `/media/videos/${videoId}/metadata`;
}
/**
* Build HLS master manifest URL for an admin context.
* Sign this via useSignedMediaUrl before binding to a player to authorize
* unpublished or access-gated content.
*/
export function getVideoHlsManifestUrl(videoId: number): string {
return `/media/videos/${videoId}/hls/master.m3u8`;
}
/**
* Build HLS master manifest URL for the public gallery.
* No signing needed the public endpoint gates by isPublished + access tier.
*/
export function getPublicHlsManifestUrl(videoId: number): string {
return `/media/public/${videoId}/hls/master.m3u8`;
}

View File

@ -13,8 +13,7 @@
"prisma:migrate": "prisma migrate dev",
"prisma:migrate:deploy": "prisma migrate deploy",
"prisma:seed": "tsx prisma/seed.ts",
"prisma:studio": "prisma studio",
"backfill:hls": "tsx scripts/backfill-hls.ts"
"prisma:studio": "prisma studio"
},
"dependencies": {
"@fastify/cors": "^11.2.0",

View File

@ -1,13 +0,0 @@
-- CreateEnum
CREATE TYPE "HlsStatus" AS ENUM ('PENDING', 'PROCESSING', 'READY', 'FAILED', 'SKIPPED');
-- AlterTable
ALTER TABLE "videos" ADD COLUMN "hls_job_id" TEXT,
ADD COLUMN "hls_manifest_path" TEXT,
ADD COLUMN "hls_status" "HlsStatus",
ADD COLUMN "hls_transcode_error" TEXT,
ADD COLUMN "hls_transcoded_at" TIMESTAMP(3),
ADD COLUMN "hls_variants" JSONB;
-- CreateIndex
CREATE INDEX "idx_videos_hls_status" ON "videos"("hls_status");

View File

@ -1485,15 +1485,6 @@ enum DirectoryType {
highlights
}
// HLS adaptive bitrate transcoding state for the Video model.
enum HlsStatus {
PENDING
PROCESSING
READY
FAILED
SKIPPED
}
enum ResourceCategory {
gpu_ai
gpu_encode
@ -1809,17 +1800,6 @@ model Video {
// Uploader tracking
uploaderId String? @map("uploader_id")
// HLS adaptive bitrate transcoding state.
// null = never queued; PENDING after upload; PROCESSING when worker picks up;
// READY when master.m3u8 + variants exist on disk; FAILED on transcode error;
// SKIPPED when ENABLE_HLS_TRANSCODE was off at enqueue time.
hlsStatus HlsStatus? @map("hls_status")
hlsManifestPath String? @map("hls_manifest_path") // /media/local/hls/{id}/master.m3u8
hlsTranscodedAt DateTime? @map("hls_transcoded_at")
hlsTranscodeError String? @map("hls_transcode_error")
hlsVariants Json? @map("hls_variants") // [{height, bitrate, path}, ...]
hlsJobId String? @map("hls_job_id")
// Relations
uploader User? @relation("VideoUploader", fields: [uploaderId], references: [id])
locker User? @relation("VideoLocker", fields: [lockedById], references: [id])
@ -1860,7 +1840,6 @@ model Video {
@@index([category, isPublished], map: "idx_videos_category_published")
@@index([isShort, isPublished, isLocked], map: "idx_videos_short_published")
@@index([uploaderId], map: "idx_videos_uploader")
@@index([hlsStatus], map: "idx_videos_hls_status")
@@map("videos")
}

View File

@ -1,67 +0,0 @@
/**
* Backfill HLS transcoding for existing videos.
*
* Finds every Video record that has never been queued for HLS (hlsStatus is
* NULL) and is otherwise transcodable, then enqueues a transcode job for
* each. Idempotent re-runs only pick up still-NULL rows. Skips invalid or
* zero-duration videos.
*
* Bypasses the ENABLE_HLS_TRANSCODE flag (calls forceSubmitTranscode)
* because the flag is meant to gate the *upload-time* enqueue; once an
* operator runs this script they're explicitly asking for transcoding.
*
* Usage:
* docker compose exec api tsx scripts/backfill-hls.ts
* # or after building:
* docker compose exec api node dist/scripts/backfill-hls.js
*/
import { prisma } from '../src/config/database';
import { hlsTranscodeQueueService } from '../src/services/hls-transcode-queue.service';
import { logger } from '../src/utils/logger';
async function main() {
const candidates = await prisma.video.findMany({
where: {
hlsStatus: null,
isValid: true,
durationSeconds: { gt: 0 },
width: { gt: 0 },
height: { gt: 0 },
},
select: { id: true, filename: true, durationSeconds: true },
orderBy: { id: 'asc' },
});
if (candidates.length === 0) {
logger.info('[backfill-hls] No videos require HLS transcoding.');
process.exit(0);
}
logger.info(`[backfill-hls] Enqueueing ${candidates.length} video(s) for HLS transcoding`);
let enqueued = 0;
for (const video of candidates) {
try {
const jobId = await hlsTranscodeQueueService.forceSubmitTranscode(video.id);
enqueued++;
logger.info(`[backfill-hls] Enqueued video ${video.id} (${video.filename}) → job ${jobId}`);
} catch (err) {
logger.error(`[backfill-hls] Failed to enqueue video ${video.id}: ${err instanceof Error ? err.message : String(err)}`);
}
}
logger.info(`[backfill-hls] Done. ${enqueued}/${candidates.length} jobs enqueued. Worker concurrency is 1, so total wall time depends on per-video transcode duration (~2 min per 1080p video).`);
// Give BullMQ a moment to flush, then exit cleanly.
await new Promise((r) => setTimeout(r, 500));
await hlsTranscodeQueueService.close();
await prisma.$disconnect();
process.exit(0);
}
main().catch(async (err) => {
logger.error(`[backfill-hls] Fatal: ${err instanceof Error ? err.stack : String(err)}`);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});

View File

@ -188,12 +188,6 @@ const envSchema = z.object({
MEDIA_ROOT: z.string().default('/media/library'),
MEDIA_UPLOADS: z.string().default('/media/uploads'),
MAX_UPLOAD_SIZE_GB: z.coerce.number().default(10),
// HLS adaptive bitrate transcoding. When false, uploads are not enqueued
// for transcoding (the worker stays registered so PENDING jobs from a
// previous run still process if the flag is flipped back on). MP4 range-
// request streaming continues to work as a fallback for un-transcoded
// videos regardless of this flag.
ENABLE_HLS_TRANSCODE: z.string().default('false'),
// Container Registry (remote — gitea.bnkops.com)
GITEA_REGISTRY: z.string().default('gitea.bnkops.com/admin'),

View File

@ -21,9 +21,7 @@ import { shortsRoutes } from './modules/media/routes/shorts.routes';
import { upvoteRoutes } from './modules/media/routes/upvote.routes';
import { videoScheduleQueueService } from './services/video-schedule-queue.service';
import { videoFetchQueueService } from './services/video-fetch-queue.service';
import { hlsTranscodeQueueService } from './services/hls-transcode-queue.service';
import { fetchRoutes } from './modules/media/routes/fetch.routes';
import { hlsRoutes } from './modules/media/routes/hls.routes';
import { playlistsPublicRoutes } from './modules/media/routes/playlists-public.routes';
import { playlistsUserRoutes } from './modules/media/routes/playlists-user.routes';
import { playlistsAdminRoutes } from './modules/media/routes/playlists-admin.routes';
@ -57,7 +55,6 @@ process.on('SIGTERM', async () => {
logger.info('SIGTERM received, shutting down gracefully...');
await videoScheduleQueueService.close();
await videoFetchQueueService.close();
await hlsTranscodeQueueService.close();
fastify.close(() => {
logger.info('Media API server closed');
process.exit(0);
@ -138,7 +135,6 @@ const start = async () => {
await fastify.register(uploadRoutes, { prefix: '/api/videos' });
await fastify.register(videoActionsRoutes, { prefix: '/api/videos' });
await fastify.register(videoScheduleRoutes, { prefix: '/api/videos' });
await fastify.register(hlsRoutes, { prefix: '/api' });
await fastify.register(videoTrackingRoutes, { prefix: '/api/track' });
await fastify.register(reactionsRoutes, { prefix: '/api/reactions' });
await fastify.register(publicRoutes, { prefix: '/api' });
@ -188,12 +184,6 @@ const start = async () => {
videoFetchQueueService.startWorker();
logger.info('Video fetch queue worker initialized');
// Start HLS transcode worker (always on; the ENABLE_HLS_TRANSCODE flag
// gates enqueue, not worker registration, so existing PENDING jobs from
// a prior run still process if the flag was flipped back on).
hlsTranscodeQueueService.startWorker();
logger.info('HLS transcode queue worker initialized');
if (env.ENABLE_MEDIA_FEATURES !== 'true') {
logger.warn('Media features are disabled (ENABLE_MEDIA_FEATURES=false)');
}

View File

@ -1,383 +0,0 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { createReadStream } from 'fs';
import { readFile, access, stat } from 'fs/promises';
import path from 'path';
import jwt from 'jsonwebtoken';
import { UserRole, UserStatus } from '@prisma/client';
import { prisma } from '../../../config/database';
import { env } from '../../../config/env';
import { logger } from '../../../utils/logger';
import { hasAnyRole, MEDIA_ROLES, getUserRoles } from '../../../utils/roles';
import { signMediaPath, verifyMediaSignature } from '../../../utils/signed-url';
const HLS_ROOT = '/media/local/hls';
// 2-hour TTL on segment URLs — longer than typical viewing session, so a
// player that has the manifest cached doesn't have to re-sign mid-playback.
const SEGMENT_TTL_SECONDS = 2 * 60 * 60;
// Master/variant playlists share the same TTL for consistency.
const MANIFEST_TTL_SECONDS = SEGMENT_TTL_SECONDS;
// Whitelist sanitizer for path components in URLs (variant + filename).
const SAFE_PATH_RE = /^[a-zA-Z0-9._-]+$/;
/**
* Identify whether a request is from an authenticated admin/media-role user.
* Mirrors the logic in video-streaming.routes.ts so admin HLS access works
* the same way (Bearer JWT or signed URL params).
*/
async function isAdminRequest(request: FastifyRequest): Promise<boolean> {
try {
let userId: string | undefined;
const authHeader = request.headers.authorization;
const query = request.query as Record<string, string | undefined>;
if (authHeader?.startsWith('Bearer ')) {
const payload = jwt.verify(authHeader.substring(7), env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as {
id: string;
role: UserRole;
roles?: UserRole[];
};
if (!hasAnyRole(payload, MEDIA_ROLES)) return false;
userId = payload.id;
} else if (query.sig && query.exp && query.uid) {
const result = verifyMediaSignature(request.url, query);
if (!result.valid) return false;
userId = result.userId;
}
if (!userId) return false;
const user = await prisma.user.findUnique({
where: { id: userId },
select: { status: true, role: true, roles: true },
});
if (!user || user.status !== UserStatus.ACTIVE) return false;
return hasAnyRole({ role: user.role as UserRole, roles: getUserRoles(user) }, MEDIA_ROLES);
} catch {
return false;
}
}
/**
* Sanitize a manifest line: rewrite a relative URI (`360p/index.m3u8` or
* `seg_00001.ts`) into an absolute path beneath the given basePath, with a
* fresh signed-URL query string. Lines starting with `#` or empty are
* passed through untouched.
*
* The path emitted to the player is the *client-side* `/media/*` path that
* nginx rewrites to `/api/*` before reaching this server. Signatures are
* computed against the *server-side* `/api/*` path because that's what
* `verifyMediaSignature(request.url, ...)` sees on the inbound request.
*/
function rewriteManifestLines(
manifestText: string,
clientBasePath: string, // e.g. `/media/videos/123/hls` (browser-facing)
serverBasePath: string, // e.g. `/api/videos/123/hls` (post-nginx-rewrite, used for signing)
prefixSegmentsWith: string, // e.g. `360p/` for variant playlists; '' for master
uid: string,
ttlSeconds: number,
): string {
return manifestText
.split('\n')
.map((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return line;
const clientUrl = `${clientBasePath}/${prefixSegmentsWith}${trimmed}`;
const serverPath = `${serverBasePath}/${prefixSegmentsWith}${trimmed}`;
const signed = signMediaPath(serverPath, uid, ttlSeconds);
const sep = clientUrl.includes('?') ? '&' : '?';
return `${clientUrl}${sep}sig=${signed.sig}&exp=${signed.exp}&uid=${signed.uid}`;
})
.join('\n');
}
/** Lookup video + HLS state from DB, returning a lightweight record. */
async function loadVideoForHls(videoId: number, scope: 'admin' | 'public') {
if (scope === 'public') {
return prisma.video.findFirst({
where: { id: videoId, isPublished: true, isLocked: false },
select: {
id: true,
accessLevel: true,
hlsStatus: true,
hlsManifestPath: true,
},
});
}
return prisma.video.findUnique({
where: { id: videoId },
select: {
id: true,
accessLevel: true,
hlsStatus: true,
hlsManifestPath: true,
},
});
}
/**
* Subscription check for non-free videos on public endpoints. Mirrors
* public.routes.ts content-gating logic.
*/
async function checkPublicAccess(
request: FastifyRequest,
reply: FastifyReply,
accessLevel: string | null | undefined,
): Promise<boolean> {
if (!accessLevel || accessLevel === 'free') return true;
const userId = (request as any).user?.id;
if (!userId) {
reply.code(403).send({
message: 'This content requires a subscription',
accessLevel,
requiresAuth: true,
});
return false;
}
const subscription = await prisma.userSubscription.findFirst({
where: { userId, status: 'active' },
include: { plan: true },
});
if (!subscription) {
reply.code(403).send({
message: 'This content requires an active subscription',
accessLevel,
requiresSubscription: true,
});
return false;
}
if (accessLevel === 'premium' && (subscription.plan?.tier ?? 0) < 2) {
reply.code(403).send({
message: 'This content requires a premium subscription',
accessLevel,
requiresUpgrade: true,
});
return false;
}
return true;
}
/** Resolve a path inside the HLS root, blocking traversal. */
function resolveHlsPath(...parts: string[]): string {
const candidate = path.resolve(path.join(HLS_ROOT, ...parts));
if (!candidate.startsWith(path.resolve(HLS_ROOT))) {
throw new Error('Path traversal blocked');
}
return candidate;
}
export async function hlsRoutes(fastify: FastifyInstance) {
// ───────────────────────────────────────────────────────────────────
// Admin: master, variant, segment
// ───────────────────────────────────────────────────────────────────
fastify.get<{ Params: { id: string } }>(
'/videos/:id/hls/master.m3u8',
async (request, reply) => {
if (!(await isAdminRequest(request))) {
return reply.code(401).send({ message: 'Authentication required' });
}
return serveMaster(request, reply, 'admin');
},
);
fastify.get<{ Params: { id: string; variant: string } }>(
'/videos/:id/hls/:variant/index.m3u8',
async (request, reply) => {
if (!(await isAdminRequest(request))) {
return reply.code(401).send({ message: 'Authentication required' });
}
return serveVariant(request, reply, 'admin');
},
);
fastify.get<{ Params: { id: string; variant: string; filename: string } }>(
'/videos/:id/hls/:variant/:filename',
async (request, reply) => {
// Segments are only authorized via the signed URL embedded in the
// variant playlist — we do NOT also accept Bearer auth here (keeps the
// hot path tiny and avoids a DB lookup per segment).
const query = request.query as Record<string, string | undefined>;
const result = verifyMediaSignature(request.url, query);
if (!result.valid) {
return reply.code(403).send({ message: 'Invalid or expired signature' });
}
return serveSegment(request, reply);
},
);
// ───────────────────────────────────────────────────────────────────
// Public: master, variant, segment (gated by publish + access level)
// ───────────────────────────────────────────────────────────────────
fastify.get<{ Params: { id: string } }>(
'/public/:id/hls/master.m3u8',
async (request, reply) => {
return serveMaster(request, reply, 'public');
},
);
fastify.get<{ Params: { id: string; variant: string } }>(
'/public/:id/hls/:variant/index.m3u8',
async (request, reply) => {
return serveVariant(request, reply, 'public');
},
);
fastify.get<{ Params: { id: string; variant: string; filename: string } }>(
'/public/:id/hls/:variant/:filename',
async (request, reply) => {
const query = request.query as Record<string, string | undefined>;
const result = verifyMediaSignature(request.url, query);
if (!result.valid) {
return reply.code(403).send({ message: 'Invalid or expired signature' });
}
return serveSegment(request, reply);
},
);
}
// ─────────────────────────────────────────────────────────────────────
// Handlers
// ─────────────────────────────────────────────────────────────────────
async function serveMaster(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply,
scope: 'admin' | 'public',
) {
const videoId = parseInt(request.params.id);
if (isNaN(videoId)) {
return reply.code(400).send({ message: 'Invalid video ID' });
}
const video = await loadVideoForHls(videoId, scope);
if (!video || video.hlsStatus !== 'READY' || !video.hlsManifestPath) {
return reply.code(404).send({ message: 'HLS manifest not available for this video' });
}
if (scope === 'public') {
if (!(await checkPublicAccess(request, reply, video.accessLevel))) return; // reply already sent
}
// Read master.m3u8 from disk.
let masterPath: string;
try {
masterPath = resolveHlsPath(String(videoId), 'master.m3u8');
await access(masterPath);
} catch (err) {
logger.warn(`HLS master.m3u8 missing on disk for video ${videoId}: ${err}`);
return reply.code(404).send({ message: 'HLS manifest file missing' });
}
const masterText = await readFile(masterPath, 'utf-8');
// The master's variant URIs look like "360p/index.m3u8". Rewrite each to
// an absolute, server-signed URL pointing at our variant endpoint. We emit
// browser-facing `/media/*` paths (rewritten to `/api/*` by nginx) but
// sign against the server-side `/api/*` path the verifier will see.
const clientBase = scope === 'admin'
? `/media/videos/${videoId}/hls`
: `/media/public/${videoId}/hls`;
const serverBase = scope === 'admin'
? `/api/videos/${videoId}/hls`
: `/api/public/${videoId}/hls`;
const uid = scope === 'admin'
? ((request.query as Record<string, string | undefined>).uid ?? 'admin')
: `public-${videoId}`;
const rewritten = rewriteManifestLines(masterText, clientBase, serverBase, '', uid, MANIFEST_TTL_SECONDS);
reply
.header('Content-Type', 'application/vnd.apple.mpegurl')
.header('Cache-Control', 'no-store')
.send(rewritten);
}
async function serveVariant(
request: FastifyRequest<{ Params: { id: string; variant: string } }>,
reply: FastifyReply,
scope: 'admin' | 'public',
) {
const videoId = parseInt(request.params.id);
const variant = request.params.variant;
if (isNaN(videoId) || !SAFE_PATH_RE.test(variant)) {
return reply.code(400).send({ message: 'Invalid params' });
}
const video = await loadVideoForHls(videoId, scope);
if (!video || video.hlsStatus !== 'READY') {
return reply.code(404).send({ message: 'HLS manifest not available' });
}
if (scope === 'public') {
if (!(await checkPublicAccess(request, reply, video.accessLevel))) return;
}
let variantPath: string;
try {
variantPath = resolveHlsPath(String(videoId), variant, 'index.m3u8');
await access(variantPath);
} catch {
return reply.code(404).send({ message: 'Variant playlist not found' });
}
const variantText = await readFile(variantPath, 'utf-8');
const clientBase = scope === 'admin'
? `/media/videos/${videoId}/hls`
: `/media/public/${videoId}/hls`;
const serverBase = scope === 'admin'
? `/api/videos/${videoId}/hls`
: `/api/public/${videoId}/hls`;
const uid = scope === 'admin'
? ((request.query as Record<string, string | undefined>).uid ?? 'admin')
: `public-${videoId}`;
const rewritten = rewriteManifestLines(
variantText,
clientBase,
serverBase,
`${variant}/`,
uid,
SEGMENT_TTL_SECONDS,
);
reply
.header('Content-Type', 'application/vnd.apple.mpegurl')
.header('Cache-Control', 'no-store')
.send(rewritten);
}
async function serveSegment(
request: FastifyRequest<{ Params: { id: string; variant: string; filename: string } }>,
reply: FastifyReply,
) {
const videoId = parseInt(request.params.id);
const { variant, filename } = request.params;
if (isNaN(videoId) || !SAFE_PATH_RE.test(variant) || !SAFE_PATH_RE.test(filename)) {
return reply.code(400).send({ message: 'Invalid params' });
}
let segmentPath: string;
try {
segmentPath = resolveHlsPath(String(videoId), variant, filename);
await access(segmentPath);
} catch {
return reply.code(404).send({ message: 'Segment not found' });
}
const stats = await stat(segmentPath);
const isPlaylist = filename.endsWith('.m3u8');
const contentType = isPlaylist ? 'application/vnd.apple.mpegurl' : 'video/mp2t';
reply
.header('Content-Type', contentType)
.header('Content-Length', stats.size)
// Segments are content-addressed in the sense that {videoId}/{variant}/{name}
// never changes content; safe to cache aggressively at the browser. The
// signature in the URL keeps cache keys per-session.
.header('Cache-Control', 'private, max-age=3600');
return reply.send(createReadStream(segmentPath));
}

View File

@ -7,7 +7,6 @@ import { randomUUID } from 'crypto';
import { prisma } from '../../../config/database';
import { extractVideoMetadata, validateVideoFile } from '../services/ffprobe.service';
import { ThumbnailService } from '../services/thumbnail.service';
import { hlsTranscodeQueueService } from '../../../services/hls-transcode-queue.service';
import { logger } from '../../../utils/logger';
import { z } from 'zod';
import { requireAdminRole } from '../middleware/auth';
@ -127,13 +126,6 @@ async function uploadVideo(request: FastifyRequest, reply: FastifyReply) {
logger.error(`Failed to generate thumbnail for video ${video.id}:`, thumbnailError);
}
// Enqueue HLS transcode (no-op when ENABLE_HLS_TRANSCODE=false; sets SKIPPED).
try {
await hlsTranscodeQueueService.submitTranscode(video.id);
} catch (hlsErr) {
logger.error(`Failed to enqueue HLS transcode for video ${video.id}:`, hlsErr);
}
return reply.code(201).send({
message: 'Video uploaded successfully',
video,
@ -255,13 +247,6 @@ async function uploadBatch(request: FastifyRequest, reply: FastifyReply) {
logger.error(`Failed to generate thumbnail for video ${video.id}:`, thumbnailError);
}
// Enqueue HLS transcode (no-op when flag off).
try {
await hlsTranscodeQueueService.submitTranscode(video.id);
} catch (hlsErr) {
logger.error(`Failed to enqueue HLS transcode for video ${video.id}:`, hlsErr);
}
results.push({
filename: file.filename,
success: true,

View File

@ -9,7 +9,7 @@ import { prisma } from '../../../config/database';
import { env } from '../../../config/env';
import { logger } from '../../../utils/logger';
import { hasAnyRole, MEDIA_ROLES, getUserRoles } from '../../../utils/roles';
import { signMediaPath, verifyMediaSignature } from '../../../utils/signed-url';
import { verifyMediaSignature } from '../../../utils/signed-url';
/**
* Check if the request is from an authenticated admin user.
@ -284,43 +284,6 @@ export async function videoStreamingRoutes(fastify: FastifyInstance) {
? `/media/videos/${video.id}/thumbnail`
: null;
// HLS manifest URL — only present when transcoding has completed.
// We emit browser-facing `/media/*` paths (rewritten by nginx to
// `/api/*` and proxied to media-api). For admin previews we sign
// against the post-rewrite server-side path so the verifier matches.
let hlsManifestUrl: string | null = null;
if (video.hlsStatus === 'READY' && video.hlsManifestPath) {
const clientPath = admin
? `/media/videos/${video.id}/hls/master.m3u8`
: `/media/public/${video.id}/hls/master.m3u8`;
const serverPath = admin
? `/api/videos/${video.id}/hls/master.m3u8`
: `/api/public/${video.id}/hls/master.m3u8`;
if (admin) {
let uid = 'admin';
try {
const authHeader = request.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const payload = jwt.verify(
authHeader.substring(7),
env.JWT_ACCESS_SECRET,
{ algorithms: ['HS256'] },
) as { id?: string };
if (payload.id) uid = payload.id;
} else {
const q = request.query as Record<string, string | undefined>;
if (q.uid) uid = q.uid;
}
} catch { /* keep default */ }
const signed = signMediaPath(serverPath, uid, 2 * 60 * 60);
hlsManifestUrl = `${clientPath}?sig=${signed.sig}&exp=${signed.exp}&uid=${signed.uid}`;
} else {
// Public manifest: nginx-rewritten path; the public master route
// is unsigned (gated by isPublished + access level on the server).
hlsManifestUrl = clientPath;
}
}
// Return public metadata
return {
id: video.id,
@ -333,8 +296,6 @@ export async function videoStreamingRoutes(fastify: FastifyInstance) {
quality: video.quality,
streamUrl,
thumbnailUrl,
hlsStatus: video.hlsStatus,
hlsManifestUrl,
createdAt: video.createdAt,
};
} catch (error) {

View File

@ -1,211 +0,0 @@
import { spawn } from 'child_process';
import fs from 'fs/promises';
import path from 'path';
import { logger } from '../../../utils/logger';
const HLS_ROOT = '/media/local/hls';
const FFMPEG_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour cap; long-form video can exceed default 30s
export interface HlsVariant {
name: string; // '360p' | '720p' | '1080p'
height: number; // short-side height in pixels
bitrate: number; // target video bitrate in kbps
path: string; // relative path under {videoId}/, e.g. '360p/index.m3u8'
}
export interface HlsTranscodeOptions {
videoId: number;
sourcePath: string;
durationSeconds: number;
sourceWidth: number;
sourceHeight: number;
/** Optional progress callback, called with 0-100 as transcoding advances. */
onProgress?: (percent: number) => void | Promise<void>;
}
export interface HlsTranscodeResult {
manifestPath: string; // absolute path to master.m3u8 on disk
manifestRelativePath: string; // e.g. {videoId}/master.m3u8 (relative to HLS_ROOT)
variants: HlsVariant[];
}
// Bitrate ladder. Each rung is { name, height, videoKbps, maxKbps, bufKbps, audioKbps }.
// Ladder is keyed off short-side height so a 1080×1920 vertical short still
// gets 360p/720p/1080p *short-side* renditions (width follows via scale=-2:H).
const LADDER = [
{ name: '360p', height: 360, videoKbps: 800, maxKbps: 856, bufKbps: 1200, audioKbps: 96 },
{ name: '720p', height: 720, videoKbps: 2800, maxKbps: 2996, bufKbps: 4200, audioKbps: 128 },
{ name: '1080p', height: 1080, videoKbps: 5000, maxKbps: 5350, bufKbps: 7500, audioKbps: 128 },
];
/**
* Transcode a source video to HLS adaptive bitrate using a single FFmpeg
* invocation. One decode pass produces all variants in parallel, with
* keyframes aligned every 2s so hls.js can switch renditions cleanly.
*
* Output layout:
* /media/local/hls/{videoId}/master.m3u8
* /media/local/hls/{videoId}/360p/index.m3u8
* /media/local/hls/{videoId}/360p/seg_00000.ts
* ...
*/
export async function transcodeToHls(opts: HlsTranscodeOptions): Promise<HlsTranscodeResult> {
const { videoId, sourcePath, durationSeconds, sourceWidth, sourceHeight, onProgress } = opts;
// Pick variants up to the source's short-side resolution. Always include 360p.
const shortSide = Math.min(sourceWidth, sourceHeight);
const variants = LADDER.filter((v, i) => i === 0 || v.height <= shortSide);
const outDir = path.join(HLS_ROOT, String(videoId));
await fs.rm(outDir, { recursive: true, force: true });
await fs.mkdir(outDir, { recursive: true });
// Build the filter_complex graph: split video N ways, scale each.
const splitTargets = variants.map((_, i) => `[v${i}]`).join('');
const scaleFilters = variants
.map((v, i) => `[v${i}]scale=-2:${v.height}[v${i}o]`)
.join('; ');
const filterComplex = `[0:v]split=${variants.length}${splitTargets}; ${scaleFilters}`;
// Per-stream encode args.
const streamArgs: string[] = [];
variants.forEach((v, i) => {
streamArgs.push(
'-map', `[v${i}o]`,
`-c:v:${i}`, 'libx264',
`-b:v:${i}`, `${v.videoKbps}k`,
`-maxrate:v:${i}`, `${v.maxKbps}k`,
`-bufsize:v:${i}`, `${v.bufKbps}k`,
);
});
// Audio: map source audio once per variant so each rendition has its own audio track.
variants.forEach((v, i) => {
streamArgs.push('-map', 'a:0?', `-c:a:${i}`, 'aac', `-b:a:${i}`, `${v.audioKbps}k`, '-ac', '2');
});
// var_stream_map associates video+audio streams per variant.
const varStreamMap = variants
.map((v, i) => `v:${i},a:${i},name:${v.name}`)
.join(' ');
const ffmpegArgs = [
'-hide_banner',
'-y',
'-i', sourcePath,
'-filter_complex', filterComplex,
...streamArgs,
'-preset', 'veryfast',
'-profile:v', 'main',
'-sc_threshold', '0',
'-g', '48',
'-keyint_min', '48',
'-force_key_frames', 'expr:gte(t,n_forced*2)',
'-hls_time', '4',
'-hls_playlist_type', 'vod',
'-hls_flags', 'independent_segments',
'-hls_segment_filename', path.join(outDir, '%v', 'seg_%05d.ts'),
'-master_pl_name', 'master.m3u8',
'-var_stream_map', varStreamMap,
path.join(outDir, '%v', 'index.m3u8'),
];
// Pre-create per-variant subdirs so FFmpeg can write segments.
for (const v of variants) {
await fs.mkdir(path.join(outDir, v.name), { recursive: true });
}
logger.info(`[hls] Starting transcode for video ${videoId} (${variants.length} variants: ${variants.map(v => v.name).join(', ')})`);
await runFfmpeg(ffmpegArgs, durationSeconds, onProgress);
// FFmpeg's HLS muxer writes per-variant playlists into directories named
// by the var_stream_map's name. Confirm master.m3u8 exists.
const masterPath = path.join(outDir, 'master.m3u8');
try {
await fs.access(masterPath);
} catch {
// Cleanup partial output.
await fs.rm(outDir, { recursive: true, force: true });
throw new Error('FFmpeg completed but master.m3u8 was not produced');
}
const result: HlsTranscodeResult = {
manifestPath: masterPath,
manifestRelativePath: path.join(String(videoId), 'master.m3u8'),
variants: variants.map(v => ({
name: v.name,
height: v.height,
bitrate: v.videoKbps,
path: path.join(v.name, 'index.m3u8'),
})),
};
logger.info(`[hls] Transcode complete for video ${videoId}`);
return result;
}
/**
* Spawn FFmpeg with a hard timeout and stderr-based progress parsing.
* FFmpeg writes "time=HH:MM:SS.cc" lines to stderr periodically; we parse
* those and call onProgress with 0-100.
*/
function runFfmpeg(
args: string[],
durationSeconds: number,
onProgress?: (percent: number) => void | Promise<void>,
): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn('ffmpeg', args);
let stderrTail = '';
let lastReportedPercent = -1;
const timeout = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error(`FFmpeg timeout after ${FFMPEG_TIMEOUT_MS / 1000}s`));
}, FFMPEG_TIMEOUT_MS);
child.stderr.on('data', (data: Buffer) => {
const text = data.toString();
// Keep a tail for error reporting on non-zero exit.
stderrTail = (stderrTail + text).slice(-4000);
if (!onProgress || durationSeconds <= 0) return;
// Parse "time=HH:MM:SS.cc" — newest occurrence in this chunk.
const matches = text.match(/time=(\d+):(\d+):(\d+(?:\.\d+)?)/g);
if (!matches || matches.length === 0) return;
const last = matches[matches.length - 1];
const m = last.match(/time=(\d+):(\d+):(\d+(?:\.\d+)?)/);
if (!m) return;
const elapsed = Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3]);
const percent = Math.min(99, Math.floor((elapsed / durationSeconds) * 100));
if (percent > lastReportedPercent) {
lastReportedPercent = percent;
// Fire-and-forget; queue can swallow errors here.
Promise.resolve(onProgress(percent)).catch(() => {});
}
});
child.on('close', (code) => {
clearTimeout(timeout);
if (code === 0) {
resolve();
} else {
reject(new Error(`FFmpeg exited with code ${code}: ${stderrTail.slice(-1000)}`));
}
});
child.on('error', (err) => {
clearTimeout(timeout);
reject(err);
});
});
}
/**
* Remove the HLS output directory for a video (e.g. on transcode failure or video deletion).
*/
export async function cleanupHlsOutput(videoId: number): Promise<void> {
const dir = path.join(HLS_ROOT, String(videoId));
await fs.rm(dir, { recursive: true, force: true });
}

View File

@ -1,238 +0,0 @@
import { Queue, Worker, type Job } from 'bullmq';
import Redis from 'ioredis';
import { env } from '../config/env';
import { prisma } from '../config/database';
import {
transcodeToHls,
cleanupHlsOutput,
type HlsTranscodeResult,
} from '../modules/media/services/hls-transcode.service';
import { logger } from '../utils/logger';
interface HlsTranscodeJobData {
videoId: number;
}
interface HlsTranscodeJobResult {
videoId: number;
variants: Array<{ name: string; height: number; bitrate: number; path: string }>;
}
const QUEUE_NAME = 'hls-transcode';
class HlsTranscodeQueueService {
private queue: Queue<HlsTranscodeJobData, HlsTranscodeJobResult>;
private worker: Worker<HlsTranscodeJobData, HlsTranscodeJobResult> | null = null;
private redis: Redis | null = null;
constructor() {
this.queue = new Queue(QUEUE_NAME, {
connection: { url: env.REDIS_URL },
defaultJobOptions: {
attempts: 2,
backoff: { type: 'exponential', delay: 60_000 },
removeOnComplete: { age: 7 * 24 * 60 * 60, count: 200 },
removeOnFail: { age: 30 * 24 * 60 * 60 },
},
});
}
private getRedis(): Redis {
if (!this.redis) {
this.redis = new Redis(env.REDIS_URL);
}
return this.redis;
}
/** Append a log line for a job (Redis list with 24h TTL + pubsub for SSE). */
private async appendJobLog(jobId: string, line: string): Promise<void> {
const key = `hls-log:${jobId}`;
const redis = this.getRedis();
await redis.rpush(key, line);
await redis.expire(key, 86400);
await redis.publish(`hls-log-stream:${jobId}`, line);
}
/** Get accumulated log lines for a job. */
async getJobLog(jobId: string): Promise<string[]> {
const key = `hls-log:${jobId}`;
return this.getRedis().lrange(key, 0, -1);
}
/**
* Start the in-process worker. Concurrency is 1 because FFmpeg saturates
* the available cores; running two transcodes in parallel just slows both
* down and risks OOM.
*/
startWorker(): void {
this.worker = new Worker<HlsTranscodeJobData, HlsTranscodeJobResult>(
QUEUE_NAME,
async (job) => this.processJob(job),
{
connection: { url: env.REDIS_URL },
concurrency: 1,
},
);
this.worker.on('completed', (job) => {
logger.info(`[hls] job ${job.id} completed for video ${job.data.videoId}`);
});
this.worker.on('failed', (job, err) => {
logger.error(`[hls] job ${job?.id} failed: ${err.message}`);
});
logger.info('[hls] HLS transcode queue worker started');
}
private async processJob(job: Job<HlsTranscodeJobData>): Promise<HlsTranscodeJobResult> {
const { videoId } = job.data;
const jobId = job.id!;
await this.appendJobLog(jobId, `Starting HLS transcode for video ${videoId}`);
const video = await prisma.video.findUnique({ where: { id: videoId } });
if (!video) {
throw new Error(`Video ${videoId} not found`);
}
if (!video.path) {
throw new Error(`Video ${videoId} has no source path`);
}
if (!video.durationSeconds || video.durationSeconds <= 0) {
throw new Error(`Video ${videoId} has no duration`);
}
if (!video.width || !video.height) {
throw new Error(`Video ${videoId} has no dimensions`);
}
await prisma.video.update({
where: { id: videoId },
data: {
hlsStatus: 'PROCESSING',
hlsJobId: jobId,
hlsTranscodeError: null,
},
});
let result: HlsTranscodeResult;
try {
result = await transcodeToHls({
videoId,
sourcePath: video.path,
durationSeconds: video.durationSeconds,
sourceWidth: video.width,
sourceHeight: video.height,
onProgress: async (percent) => {
await job.updateProgress(percent);
// Coarse log lines so the UI can stream progress without flooding.
if (percent % 10 === 0) {
await this.appendJobLog(jobId, `Transcoding: ${percent}%`);
}
},
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.appendJobLog(jobId, `FAILED: ${message}`);
// Best-effort cleanup; transcodeToHls already rms partial output on failure.
await cleanupHlsOutput(videoId).catch(() => {});
await prisma.video.update({
where: { id: videoId },
data: {
hlsStatus: 'FAILED',
hlsTranscodeError: message.slice(0, 1000),
},
});
throw err; // BullMQ will retry per attempts/backoff config.
}
await prisma.video.update({
where: { id: videoId },
data: {
hlsStatus: 'READY',
hlsManifestPath: result.manifestRelativePath,
hlsTranscodedAt: new Date(),
hlsTranscodeError: null,
hlsVariants: result.variants as unknown as object,
},
});
await this.appendJobLog(jobId, `Transcode complete: ${result.variants.map(v => v.name).join(', ')}`);
await this.getRedis().publish(`hls-log-stream:${jobId}`, '__DONE__');
return {
videoId,
variants: result.variants,
};
}
/**
* Submit a transcode job. When ENABLE_HLS_TRANSCODE=false, this is a no-op
* that marks the video as SKIPPED (so the upload flow stays synchronous and
* the operator can opt in later via the backfill script).
*/
async submitTranscode(videoId: number): Promise<{ jobId: string | null; skipped: boolean }> {
if (env.ENABLE_HLS_TRANSCODE !== 'true') {
await prisma.video.update({
where: { id: videoId },
data: { hlsStatus: 'SKIPPED' },
});
return { jobId: null, skipped: true };
}
const job = await this.queue.add('transcode', { videoId });
await prisma.video.update({
where: { id: videoId },
data: { hlsStatus: 'PENDING', hlsJobId: job.id ?? null },
});
logger.info(`[hls] enqueued transcode job ${job.id} for video ${videoId}`);
return { jobId: job.id ?? null, skipped: false };
}
/**
* Force-enqueue a transcode job, bypassing the ENABLE_HLS_TRANSCODE flag.
* Used by the backfill script so an admin can run the backfill against
* existing videos without flipping the flag for new uploads.
*/
async forceSubmitTranscode(videoId: number): Promise<string> {
const job = await this.queue.add('transcode', { videoId });
await prisma.video.update({
where: { id: videoId },
data: { hlsStatus: 'PENDING', hlsJobId: job.id ?? null },
});
logger.info(`[hls] (forced) enqueued transcode job ${job.id} for video ${videoId}`);
return job.id!;
}
/** Get a single job by ID. */
async getJob(jobId: string) {
const job = await this.queue.getJob(jobId);
if (!job) return null;
const state = await job.getState();
return {
id: job.id,
data: job.data,
state,
progress: job.progress as number,
returnvalue: job.returnvalue,
failedReason: job.failedReason,
timestamp: job.timestamp,
finishedOn: job.finishedOn,
processedOn: job.processedOn,
};
}
async close(): Promise<void> {
if (this.worker) {
await this.worker.close();
}
await this.queue.close();
if (this.redis) {
await this.redis.quit();
}
logger.info('[hls] HLS transcode queue closed');
}
}
export const hlsTranscodeQueueService = new HlsTranscodeQueueService();

View File

@ -372,19 +372,6 @@ class VideoFetchQueueService {
await this.appendJobLog(jobId, `Thumbnail generation failed (non-fatal): ${thumbnailErr instanceof Error ? thumbnailErr.message : 'Unknown error'}`);
}
// Enqueue HLS transcode (lazy import to avoid module-cycle with media-server bootstrap).
try {
const { hlsTranscodeQueueService } = await import('./hls-transcode-queue.service');
const result = await hlsTranscodeQueueService.submitTranscode(video.id);
if (result.skipped) {
await this.appendJobLog(jobId, 'HLS transcode skipped (flag off)');
} else {
await this.appendJobLog(jobId, `HLS transcode enqueued (job ${result.jobId})`);
}
} catch (hlsErr) {
await this.appendJobLog(jobId, `HLS enqueue failed (non-fatal): ${hlsErr instanceof Error ? hlsErr.message : 'Unknown error'}`);
}
resolve({ videoId: video.id, title });
} catch (err) {
reject(err);

View File

@ -196,7 +196,6 @@ services:
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000,http://localhost:3100}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- ENABLE_MEDIA_FEATURES=${ENABLE_MEDIA_FEATURES:-true}
- ENABLE_HLS_TRANSCODE=${ENABLE_HLS_TRANSCODE:-false}
- MEDIA_ROOT=/media/local
- MEDIA_UPLOADS=/media/uploads
- MAX_UPLOAD_SIZE_GB=${MAX_UPLOAD_SIZE_GB:-10}
@ -207,17 +206,15 @@ services:
- ${MEDIA_ROOT:-./media}/local/thumbnails:/media/local/thumbnails:rw
- ${MEDIA_ROOT:-./media}/local/photos:/media/local/photos:rw
- ${MEDIA_ROOT:-./media}/local/documents:/media/local/documents:rw
- ${MEDIA_ROOT:-./media}/local/hls:/media/local/hls:rw
- ${MEDIA_ROOT:-./media}/public:/media/public:rw
deploy:
resources:
# Bumped to 4/2G for in-process HLS FFmpeg transcoding headroom.
limits:
cpus: '4'
memory: 2G
cpus: '2'
memory: 1G
reservations:
cpus: '0.5'
memory: 512M
cpus: '0.25'
memory: 256M
depends_on:
v2-postgres:
condition: service_healthy

View File

@ -203,7 +203,6 @@ services:
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000,http://localhost:3100}
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
- ENABLE_MEDIA_FEATURES=${ENABLE_MEDIA_FEATURES:-true}
- ENABLE_HLS_TRANSCODE=${ENABLE_HLS_TRANSCODE:-false}
- MEDIA_ROOT=/media/local
- MEDIA_UPLOADS=/media/uploads
- MAX_UPLOAD_SIZE_GB=${MAX_UPLOAD_SIZE_GB:-10}
@ -216,19 +215,15 @@ services:
- ${MEDIA_ROOT:-./media}/local/thumbnails:/media/local/thumbnails:rw
- ${MEDIA_ROOT:-./media}/local/photos:/media/local/photos:rw
- ${MEDIA_ROOT:-./media}/local/documents:/media/local/documents:rw
- ${MEDIA_ROOT:-./media}/local/hls:/media/local/hls:rw
- ${MEDIA_ROOT:-./media}/public:/media/public:rw
deploy:
resources:
# Bumped from 2/1G to 4/2G to give FFmpeg HLS transcoding (in-process
# via hls-transcode-queue worker, concurrency 1) headroom without
# starving the API thread. Revisit if upload spikes saturate cores.
limits:
cpus: '4'
memory: 2G
cpus: '2'
memory: 1G
reservations:
cpus: '0.5'
memory: 512M
cpus: '0.25'
memory: 256M
depends_on:
v2-postgres:
condition: service_healthy

View File

@ -3,14 +3,14 @@
"name": "changemaker.lite",
"description": "Changemaker-lite is the current active development branch of Changemaker, focused on streamlining core services. These improvements will be merged into the master branch once ready.",
"html_url": "http://gitea.bnkops.com/admin/changemaker.lite",
"language": "TypeScript",
"language": "HTML",
"stars_count": 0,
"forks_count": 0,
"open_issues_count": 0,
"updated_at": "2026-04-30T14:17:57-06:00",
"updated_at": "2026-04-16T20:55:22-06: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-04-30T14:17:57-06:00"
"last_build_update": "2026-04-16T20:55:22-06:00"
}

View File

@ -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": 119511,
"forks_count": 19822,
"open_issues_count": 10854,
"updated_at": "2026-05-01T00:39:46Z",
"stars_count": 115550,
"forks_count": 19277,
"open_issues_count": 10258,
"updated_at": "2026-04-18T20:36:34Z",
"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-04-29T03:29:13Z"
"last_build_update": "2026-04-18T01:34:30Z"
}

View File

@ -4,13 +4,13 @@
"description": "VS Code in the browser",
"html_url": "https://github.com/coder/code-server",
"language": "TypeScript",
"stars_count": 77342,
"forks_count": 6640,
"open_issues_count": 141,
"updated_at": "2026-04-30T23:32:22Z",
"stars_count": 77176,
"forks_count": 6620,
"open_issues_count": 138,
"updated_at": "2026-04-18T19:11:47Z",
"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-04-27T17:59:20Z"
"last_build_update": "2026-04-17T19:14:57Z"
}

View File

@ -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": 29830,
"forks_count": 1891,
"stars_count": 29626,
"forks_count": 1889,
"open_issues_count": 1,
"updated_at": "2026-05-01T00:16:56Z",
"updated_at": "2026-04-18T20:32:38Z",
"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-04-30T22:57:10Z"
"last_build_update": "2026-04-18T12:22:41Z"
}

View File

@ -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": 55256,
"forks_count": 6641,
"open_issues_count": 2774,
"updated_at": "2026-04-30T23:56:38Z",
"stars_count": 54976,
"forks_count": 6589,
"open_issues_count": 2824,
"updated_at": "2026-04-18T20:39:30Z",
"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-04-30T18:15:01Z"
"last_build_update": "2026-04-18T20:40:15Z"
}

View File

@ -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": 19860,
"forks_count": 2033,
"open_issues_count": 97,
"updated_at": "2026-04-30T19:42:11Z",
"stars_count": 19565,
"forks_count": 2002,
"open_issues_count": 101,
"updated_at": "2026-04-18T19:55:06Z",
"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-04-30T03:11:27Z"
"last_build_update": "2026-04-18T04:46:18Z"
}

View File

@ -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": 2018,
"forks_count": 251,
"stars_count": 1972,
"forks_count": 246,
"open_issues_count": 22,
"updated_at": "2026-04-30T20:13:55Z",
"updated_at": "2026-04-17T18:24:10Z",
"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",

View File

@ -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": 186286,
"forks_count": 57265,
"open_issues_count": 1563,
"updated_at": "2026-05-01T00:40:12Z",
"stars_count": 184582,
"forks_count": 56921,
"open_issues_count": 1514,
"updated_at": "2026-04-18T20:37:25Z",
"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-04-30T23:52:46Z"
"last_build_update": "2026-04-18T18:20:27Z"
}

View File

@ -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": 62889,
"forks_count": 4751,
"open_issues_count": 693,
"updated_at": "2026-04-30T22:56:22Z",
"stars_count": 62762,
"forks_count": 4728,
"open_issues_count": 670,
"updated_at": "2026-04-18T18:15:04Z",
"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-04-30T14:59:37Z"
"last_build_update": "2026-04-18T18:27:20Z"
}

View File

@ -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": 170419,
"forks_count": 15911,
"open_issues_count": 3124,
"updated_at": "2026-04-30T23:28:09Z",
"stars_count": 169357,
"forks_count": 15664,
"open_issues_count": 2984,
"updated_at": "2026-04-18T20:28:17Z",
"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-04-30T23:43:41Z"
"last_build_update": "2026-04-18T18:56:35Z"
}

View File

@ -4,13 +4,13 @@
"description": "Documentation that simply works",
"html_url": "https://github.com/squidfunk/mkdocs-material",
"language": "Python",
"stars_count": 26641,
"forks_count": 4068,
"stars_count": 26571,
"forks_count": 4070,
"open_issues_count": 1,
"updated_at": "2026-04-30T22:40:30Z",
"updated_at": "2026-04-18T17:06:20Z",
"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-04-23T13:38:16Z"
"last_build_update": "2026-04-16T14:42:25Z"
}

View File

@ -13,10 +13,7 @@
<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>
@ -72,10 +69,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>

View File

@ -109,10 +109,7 @@
<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>
@ -168,10 +165,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>

View File

@ -129,10 +129,7 @@
<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>
@ -188,10 +185,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

View File

@ -3,14 +3,14 @@
"name": "changemaker.lite",
"description": "Changemaker-lite is the current active development branch of Changemaker, focused on streamlining core services. These improvements will be merged into the master branch once ready.",
"html_url": "http://gitea.bnkops.com/admin/changemaker.lite",
"language": "TypeScript",
"language": "HTML",
"stars_count": 0,
"forks_count": 0,
"open_issues_count": 0,
"updated_at": "2026-04-30T14:17:57-06:00",
"updated_at": "2026-04-16T20:55:22-06: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-04-30T14:17:57-06:00"
"last_build_update": "2026-04-16T20:55:22-06:00"
}

View File

@ -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": 119511,
"forks_count": 19822,
"open_issues_count": 10854,
"updated_at": "2026-05-01T00:39:46Z",
"stars_count": 115550,
"forks_count": 19277,
"open_issues_count": 10258,
"updated_at": "2026-04-18T20:36:34Z",
"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-04-29T03:29:13Z"
"last_build_update": "2026-04-18T01:34:30Z"
}

View File

@ -4,13 +4,13 @@
"description": "VS Code in the browser",
"html_url": "https://github.com/coder/code-server",
"language": "TypeScript",
"stars_count": 77342,
"forks_count": 6640,
"open_issues_count": 141,
"updated_at": "2026-04-30T23:32:22Z",
"stars_count": 77176,
"forks_count": 6620,
"open_issues_count": 138,
"updated_at": "2026-04-18T19:11:47Z",
"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-04-27T17:59:20Z"
"last_build_update": "2026-04-17T19:14:57Z"
}

View File

@ -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": 29830,
"forks_count": 1891,
"stars_count": 29626,
"forks_count": 1889,
"open_issues_count": 1,
"updated_at": "2026-05-01T00:16:56Z",
"updated_at": "2026-04-18T20:32:38Z",
"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-04-30T22:57:10Z"
"last_build_update": "2026-04-18T12:22:41Z"
}

View File

@ -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": 55256,
"forks_count": 6641,
"open_issues_count": 2774,
"updated_at": "2026-04-30T23:56:38Z",
"stars_count": 54976,
"forks_count": 6589,
"open_issues_count": 2824,
"updated_at": "2026-04-18T20:39:30Z",
"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-04-30T18:15:01Z"
"last_build_update": "2026-04-18T20:40:15Z"
}

View File

@ -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": 19860,
"forks_count": 2033,
"open_issues_count": 97,
"updated_at": "2026-04-30T19:42:11Z",
"stars_count": 19565,
"forks_count": 2002,
"open_issues_count": 101,
"updated_at": "2026-04-18T19:55:06Z",
"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-04-30T03:11:27Z"
"last_build_update": "2026-04-18T04:46:18Z"
}

View File

@ -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": 2018,
"forks_count": 251,
"stars_count": 1972,
"forks_count": 246,
"open_issues_count": 22,
"updated_at": "2026-04-30T20:13:55Z",
"updated_at": "2026-04-17T18:24:10Z",
"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",

View File

@ -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": 186286,
"forks_count": 57265,
"open_issues_count": 1563,
"updated_at": "2026-05-01T00:40:12Z",
"stars_count": 184582,
"forks_count": 56921,
"open_issues_count": 1514,
"updated_at": "2026-04-18T20:37:25Z",
"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-04-30T23:52:46Z"
"last_build_update": "2026-04-18T18:20:27Z"
}

View File

@ -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": 62889,
"forks_count": 4751,
"open_issues_count": 693,
"updated_at": "2026-04-30T22:56:22Z",
"stars_count": 62762,
"forks_count": 4728,
"open_issues_count": 670,
"updated_at": "2026-04-18T18:15:04Z",
"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-04-30T14:59:37Z"
"last_build_update": "2026-04-18T18:27:20Z"
}

View File

@ -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": 170419,
"forks_count": 15911,
"open_issues_count": 3124,
"updated_at": "2026-04-30T23:28:09Z",
"stars_count": 169357,
"forks_count": 15664,
"open_issues_count": 2984,
"updated_at": "2026-04-18T20:28:17Z",
"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-04-30T23:43:41Z"
"last_build_update": "2026-04-18T18:56:35Z"
}

View File

@ -4,13 +4,13 @@
"description": "Documentation that simply works",
"html_url": "https://github.com/squidfunk/mkdocs-material",
"language": "Python",
"stars_count": 26641,
"forks_count": 4068,
"stars_count": 26571,
"forks_count": 4070,
"open_issues_count": 1,
"updated_at": "2026-04-30T22:40:30Z",
"updated_at": "2026-04-18T17:06:20Z",
"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-04-23T13:38:16Z"
"last_build_update": "2026-04-16T14:42:25Z"
}

View File

@ -131,10 +131,7 @@
<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>
@ -190,10 +187,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>

View File

@ -133,10 +133,7 @@
<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>
@ -192,10 +189,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>

View File

@ -131,10 +131,7 @@
<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>
@ -190,10 +187,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>

View File

@ -131,10 +131,7 @@
<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>
@ -190,10 +187,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>

View File

@ -133,10 +133,7 @@
<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>
@ -192,10 +189,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>

View File

@ -133,10 +133,7 @@
<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>
@ -192,10 +189,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>

View File

@ -133,10 +133,7 @@
<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>
@ -192,10 +189,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>

View File

@ -133,10 +133,7 @@
<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>
@ -192,10 +189,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>

View File

@ -133,10 +133,7 @@
<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>
@ -192,10 +189,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>

View File

@ -124,10 +124,7 @@
<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>
@ -183,10 +180,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>

View File

@ -133,10 +133,7 @@
<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>
@ -192,10 +189,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>
@ -1314,8 +1308,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
@ -2117,68 +2109,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
<a href="../../events/" class="md-nav__link">
<span class="md-ellipsis">
Events
</span>
<span class="md-nav__icon md-icon"></span>
</a>
</li>
@ -2433,8 +2363,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {

View File

@ -133,10 +133,7 @@
<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>
@ -192,10 +189,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>
@ -1314,8 +1308,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
@ -2062,68 +2054,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
<a href="../../events/" class="md-nav__link">
<span class="md-ellipsis">
Events
</span>
<span class="md-nav__icon md-icon"></span>
</a>
</li>
@ -2378,8 +2308,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {

View File

@ -133,10 +133,7 @@
<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>
@ -192,10 +189,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>
@ -1314,8 +1308,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
@ -1989,68 +1981,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
<a href="../events/" class="md-nav__link">
<span class="md-ellipsis">
Events
</span>
<span class="md-nav__icon md-icon"></span>
</a>
</li>
@ -2305,8 +2235,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {

View File

@ -133,10 +133,7 @@
<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>
@ -192,10 +189,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>
@ -1314,8 +1308,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
@ -2062,68 +2054,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
<a href="../../events/" class="md-nav__link">
<span class="md-ellipsis">
Events
</span>
<span class="md-nav__icon md-icon"></span>
</a>
</li>
@ -2378,8 +2308,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {

View File

@ -133,10 +133,7 @@
<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>
@ -192,10 +189,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>
@ -1314,8 +1308,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
@ -2062,68 +2054,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
<a href="../../events/" class="md-nav__link">
<span class="md-ellipsis">
Events
</span>
<span class="md-nav__icon md-icon"></span>
</a>
</li>
@ -2378,8 +2308,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {

View File

@ -133,10 +133,7 @@
<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>
@ -192,10 +189,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>
@ -1314,8 +1308,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
@ -2084,68 +2076,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
<a href="../../events/" class="md-nav__link">
<span class="md-ellipsis">
Events
</span>
<span class="md-nav__icon md-icon"></span>
</a>
</li>
@ -2400,8 +2330,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {

View File

@ -133,10 +133,7 @@
<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>
@ -192,10 +189,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>
@ -1314,8 +1308,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
@ -1967,68 +1959,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
<a href="../events/" class="md-nav__link">
<span class="md-ellipsis">
Events
</span>
<span class="md-nav__icon md-icon"></span>
</a>
</li>
@ -2283,8 +2213,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {

View File

@ -133,10 +133,7 @@
<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>
@ -192,10 +189,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>
@ -1314,8 +1308,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
@ -2062,68 +2054,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
<a href="../../events/" class="md-nav__link">
<span class="md-ellipsis">
Events
</span>
<span class="md-nav__icon md-icon"></span>
</a>
</li>
@ -2378,8 +2308,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {

View File

@ -133,10 +133,7 @@
<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>
@ -192,10 +189,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>
@ -1314,8 +1308,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
@ -2464,68 +2456,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
<a href="../../events/" class="md-nav__link">
<span class="md-ellipsis">
Events
</span>
<span class="md-nav__icon md-icon"></span>
</a>
</li>
@ -2780,8 +2710,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {

View File

@ -133,10 +133,7 @@
<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>
@ -192,10 +189,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>
@ -1314,8 +1308,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
@ -1910,68 +1902,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
<a href="../events/" class="md-nav__link">
<span class="md-ellipsis">
Events
</span>
<span class="md-nav__icon md-icon"></span>
</a>
</li>
@ -2226,8 +2156,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -133,10 +133,7 @@
<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>
@ -192,10 +189,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>
@ -1314,8 +1308,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
@ -1848,68 +1840,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
<a href="events/" class="md-nav__link">
<span class="md-ellipsis">
Events
</span>
<span class="md-nav__icon md-icon"></span>
</a>
</li>
@ -2164,8 +2094,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
@ -2744,11 +2672,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<p>Products, donations, subscription plans, and Stripe configuration.</p>
</li>
<li>
<p><span class="twemoji lg middle"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 19H5V8h14m-3-7v2H8V1H6v2H5c-1.1 0-2 .9-2 2v14a2 2 0 0 0 2 2h14c1.11 0 2-.89 2-2V5a2 2 0 0 0-2-2h-1V1m-7.12 11H7.27l2.92 2.11-1.11 3.45L12 15.43l2.92 2.13-1.12-3.44L16.72 12h-3.6L12 8.56z"/></svg></span> <strong><a href="events/">Events</a></strong></p>
<hr />
<p>Ticketed events with paid, free, and donation tiers; QR check-in scanner.</p>
</li>
<li>
<p><span class="twemoji lg middle"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13 19h1a1 1 0 0 1 1 1h7v2h-7a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1H2v-2h7a1 1 0 0 1 1-1h1v-2H4a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-7zM4 3h16a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1m5 4h1V5H9zm0 8h1v-2H9zM5 5v2h2V5zm0 8v2h2v-2z"/></svg></span> <strong><a href="services/">Services</a></strong></p>
<hr />
<p>Tunnel management, monitoring, and third-party integrations.</p>

View File

@ -133,10 +133,7 @@
<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>
@ -192,10 +189,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>
@ -1314,8 +1308,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
@ -2133,68 +2125,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
<a href="../../events/" class="md-nav__link">
<span class="md-ellipsis">
Events
</span>
<span class="md-nav__icon md-icon"></span>
</a>
</li>
@ -2449,8 +2379,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {

View File

@ -133,10 +133,7 @@
<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>
@ -192,10 +189,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>
@ -1314,8 +1308,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
@ -2144,68 +2136,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
<a href="../../events/" class="md-nav__link">
<span class="md-ellipsis">
Events
</span>
<span class="md-nav__icon md-icon"></span>
</a>
</li>
@ -2460,8 +2390,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {

View File

@ -133,10 +133,7 @@
<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>
@ -192,10 +189,7 @@
<div class="cm-header-nav__mobile-divider"></div>
<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>
@ -1314,8 +1308,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
@ -2122,68 +2114,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
<a href="../../events/" class="md-nav__link">
<span class="md-ellipsis">
Events
</span>
<span class="md-nav__icon md-icon"></span>
</a>
</li>
@ -2438,8 +2368,6 @@ body.cm-search-active .md-header--cm-hidden .md-search__output {

Some files were not shown because too many files have changed in this diff Show More