templates for self-hosting game jams (or any other kind of jam tbh)
1import { AFS } from './afs.modern.js';
2import games from '../data/games.json' with {type: 'json'};
3
4// Basic variable replacements
5const numJoined = 0;
6
7// SEE HERE FOR DATE FORMAT INFO:
8// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format
9const startDate = '2026-02-01T00:00:00';
10const endDate = '2026-02-28T23:59:59';
11
12// Date formatting stuff. You probably only need to touch locale and time zone.
13const locale = 'en-US';
14const timeZone = 'America/Chicago';
15const dateOptions = {
16 month: 'long',
17 day: 'numeric',
18 year: 'numeric',
19 hour: 'numeric',
20 minute: '2-digit',
21 timeZone: timeZone
22};
23
24// DON'T EDIT BELOW THIS LINE
25// unless you know what you're doing.
26
27const start = new Date(startDate);
28const end = new Date(endDate);
29
30const startDateElt = document.getElementById('startDate');
31const endDateElt = document.getElementById('endDate');
32const dateElt = document.getElementById('dates');
33
34const joinedElt = document.getElementById('joinedCount');
35const entriesElt = document.getElementById('entriesCount');
36
37const daysElt = document.getElementById('days');
38const hoursElt = document.getElementById('hours');
39const minutesElt = document.getElementById('minutes');
40const secondsElt = document.getElementById('seconds');
41
42const startString = start.toLocaleString(locale, dateOptions);
43const endString = end.toLocaleString(locale, dateOptions);
44
45const list = document.getElementById('list');
46
47const allTags = games.map(g => g.tags).reduce((g1, g2) => {
48 return g1.concat(g2);
49}).filter((t, i, arr) => { return arr.indexOf(t) === i }).sort();
50
51const dayMult = 24*60*60;
52const hourMult = 60*60;
53const minuteMult = 60;
54
55if (joinedElt) joinedElt.textContent = numJoined;
56if (entriesElt) entriesElt.textContent = games.length;
57
58if (startDateElt) startDateElt.textContent = startString;
59if (endDateElt) endDateElt.textContent = endString;
60
61const countdownTick = () => {
62 const now = Date.now();
63 let diff;
64 if (now < start.getTime()) {
65 // Jam hasn't started yet
66 diff = (start.getTime() - now) / 1000; // get total # of seconds
67 } else if (now < end.getTime()) {
68 // Jam has started but not ended
69 diff = (end.getTime() - now) / 1000;
70 } else {
71 // Jam has ended
72 dates.innerHTML = `The jam is now over. It ran from <b>${startString}</b> to <b>${endString}</b>. <a href="submissions.html">View ${numEntries} ${numEntries !== 1 ? 'entries' : 'entry'}`
73 }
74
75 if (diff) {
76 const days = Math.floor(diff / dayMult);
77 diff = diff - (days * dayMult);
78 const hours = Math.floor(diff / hourMult);
79 diff = diff - (hours * hourMult);
80 const minutes = Math.floor(diff / minuteMult);
81 diff = diff - (minutes * minuteMult);
82 const seconds = Math.floor(diff);
83 daysElt.textContent = days;
84 hoursElt.textContent = hours;
85 minutesElt.textContent = minutes;
86 secondsElt.textContent = seconds;
87 }
88}
89
90if (document.querySelector('.clock')) {
91 countdownTick();
92 setInterval(() => {
93 countdownTick();
94 }, 1000);
95}
96
97if (list) {
98 list.innerHTML = games.map((item, i) => {
99 return `<div class="item" id="item-${i}" data-authors="${item.authors.map(a => a.name)}" data-categories="${item.tags.map(t => 'tags:'+t).join(" ")} ${item.platforms.map(p => 'platforms:'+p).join(" ")}" data-title="${item.title}" data-date="${item.submitTime}">
100 <div class="thumb">
101 <a href="${item.page}"><img src="${item.thumbnail}" alt="${item.title} thumbnail image" /></a>
102 </div>
103 <h3><a href="${item.page}">${item.title}</a></h3>
104 <div class="authors">${item.authors.map((auth) => { return `<a href="${auth.link}" target="_blank">${auth.name}</a>`; }).join(", ")}</div>
105 <div class="blurb">${item.blurb}</div>
106</div>`;
107 }).join("");
108
109 const tagsElt = document.getElementById('tags');
110 tagsElt.innerHTML = `<button class="afs-btn-filter" data-filter="*">all</button>`+allTags.map((t) => {
111 return `<button class="afs-btn-filter" data-filter="tags:${t}">#${t}</button>`
112 }).join("");
113
114 const afs = new AFS({
115 // Required Selectors
116 containerSelector: '#list',
117 itemSelector: '.item',
118 filterButtonSelector: '.afs-btn-filter',
119 searchInputSelector: '.afs-filter-search',
120 counterSelector: '.afs-filter-counter',
121
122 // CSS Classes
123 activeClass: 'active',
124 hiddenClass: 'hidden',
125 transitionClass: 'afs-transition',
126
127 // Filter & Search Configuration
128 filterMode: 'OR', // or 'AND'
129 groupMode: 'AND', // or 'OR'
130 searchKeys: ['title', 'authors', 'tags'],
131 debounceTime: 200, // search input delay
132
133 // Date Handling
134 dateFormat: 'YYYY-MM-DD',
135 dateFilter: {
136 enabled: true,
137 format: 'YYYY-MM-DDThh:mm:ss'
138 },
139
140 // Counter Configuration
141 counter: {
142 template: 'Showing {visible} of {total}',
143 showFiltered: true,
144 filteredTemplate: '({filtered} filtered)',
145 noResultsTemplate: 'No items found',
146 formatter: (num) => num.toLocaleString()
147 },
148
149 sort: {
150 enabled: true,
151 buttonSelector: '.afs-btn-sort'
152 },
153
154 filter: {
155 enabled: true,
156 buttonSelector: '.afs-btn-filter',
157 mode: 'AND',
158 activeClass: 'afs-active',
159 hiddenClass: 'afs-hidden'
160 },
161
162 // Animation Configuration
163 animation: {
164 type: 'fade',
165 duration: 200,
166 easing: 'ease-out',
167 inClass: 'afs-animation-enter',
168 outClass: 'afs-animation-leave'
169 },
170
171 // Lifecycle Options
172 responsive: true,
173 preserveState: false,
174 stateExpiry: 86400000, // 24 hours
175 observeDOM: false,
176
177 // Style Configuration
178 styles: {
179 colors: {
180 primary: '#000',
181 background: '#e5e7eb',
182 text: '#000',
183 textHover: '#fff'
184 }
185 }
186 });
187
188 afs.dateFilter.addDateRange({
189 key: 'date',
190 container: document.querySelector('#date-filter'),
191 format: 'YYYY-MM-DD',
192 minDate: new Date(startDate.match(/[0-9]{4}-[0-9]{2}-[0-9]{2}/)[0]),
193 maxDate: new Date(endDate.match(/[0-9]{4}-[0-9]{2}-[0-9]{2}/)[0])
194 });
195
196 afs.filter.setGroupMode('AND');
197
198 document.querySelectorAll('button.custom-sort:not([data-sort-key="shuffle"])').forEach((elt) => {
199 elt.addEventListener('click', (e) => {
200 e.preventDefault();
201 e.stopPropagation();
202 const btn = e.target.closest('button');
203 document.querySelectorAll('button.custom-sort:not([data-sort-key="'+btn.getAttribute('data-sort-key')+'"])').forEach((s) => {
204 s.classList.remove('sort-active');
205 });
206 if (btn.classList.contains('sort-active')) {
207 if (btn.getAttribute('data-sort-direction') === 'asc') {
208 btn.setAttribute('data-sort-direction', 'desc');
209 btn.querySelector('img').src = './images/sort-desc.svg';
210 } else {
211 btn.setAttribute('data-sort-direction', 'asc');
212 btn.querySelector('img').src = './images/sort-asc.svg';
213 }
214 afs.sort.sort(btn.getAttribute('data-sort-key'), btn.getAttribute('data-sort-direction'));
215 } else {
216 btn.classList.add('sort-active');
217 afs.sort.sort(btn.getAttribute('data-sort-key'), btn.getAttribute('data-sort-direction'));
218 }
219 });
220 });
221
222 document.querySelector('[data-sort-key="shuffle"]').addEventListener('click', (e) => {
223 afs.sort.shuffle();
224 });
225}