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 <img src="${item.thumbnail}" alt="${item.title} thumbnail image" />
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
115const afs = new AFS({
116 // Required Selectors
117 containerSelector: '#list',
118 itemSelector: '.item',
119 filterButtonSelector: '.afs-btn-filter',
120 searchInputSelector: '.afs-filter-search',
121 counterSelector: '.afs-filter-counter',
122
123 // CSS Classes
124 activeClass: 'active',
125 hiddenClass: 'hidden',
126 transitionClass: 'afs-transition',
127
128 // Filter & Search Configuration
129 filterMode: 'OR', // or 'AND'
130 groupMode: 'AND', // or 'OR'
131 searchKeys: ['title', 'authors', 'tags'],
132 debounceTime: 200, // search input delay
133
134 // Date Handling
135 dateFormat: 'YYYY-MM-DD',
136 dateFilter: {
137 enabled: true,
138 format: 'YYYY-MM-DDThh:mm:ss'
139 },
140
141 // Counter Configuration
142 counter: {
143 template: 'Showing {visible} of {total}',
144 showFiltered: true,
145 filteredTemplate: '({filtered} filtered)',
146 noResultsTemplate: 'No items found',
147 formatter: (num) => num.toLocaleString()
148 },
149
150 sort: {
151 enabled: true,
152 buttonSelector: '.afs-btn-sort'
153 },
154
155 filter: {
156 enabled: true,
157 buttonSelector: '.afs-btn-filter',
158 mode: 'AND',
159 activeClass: 'afs-active',
160 hiddenClass: 'afs-hidden'
161 },
162
163 // Animation Configuration
164 animation: {
165 type: 'fade',
166 duration: 200,
167 easing: 'ease-out',
168 inClass: 'afs-animation-enter',
169 outClass: 'afs-animation-leave'
170 },
171
172 // Lifecycle Options
173 responsive: true,
174 preserveState: false,
175 stateExpiry: 86400000, // 24 hours
176 observeDOM: false,
177
178 // Style Configuration
179 styles: {
180 colors: {
181 primary: '#000',
182 background: '#e5e7eb',
183 text: '#000',
184 textHover: '#fff'
185 }
186 }
187});
188
189afs.dateFilter.addDateRange({
190 key: 'date',
191 container: document.querySelector('#date-filter'),
192 format: 'YYYY-MM-DD',
193 minDate: new Date(startDate.match(/[0-9]{4}-[0-9]{2}-[0-9]{2}/)[0]),
194 maxDate: new Date(endDate.match(/[0-9]{4}-[0-9]{2}-[0-9]{2}/)[0])
195});
196
197afs.filter.setGroupMode('AND');
198
199document.querySelectorAll('button.custom-sort:not([data-sort-key="shuffle"])').forEach((elt) => {
200 elt.addEventListener('click', (e) => {
201 e.preventDefault();
202 e.stopPropagation();
203 const btn = e.target.closest('button');
204 document.querySelectorAll('button.custom-sort:not([data-sort-key="'+btn.getAttribute('data-sort-key')+'"])').forEach((s) => {
205 s.classList.remove('sort-active');
206 });
207 if (btn.classList.contains('sort-active')) {
208 if (btn.getAttribute('data-sort-direction') === 'asc') {
209 btn.setAttribute('data-sort-direction', 'desc');
210 btn.querySelector('img').src = './images/sort-desc.svg';
211 } else {
212 btn.setAttribute('data-sort-direction', 'asc');
213 btn.querySelector('img').src = './images/sort-asc.svg';
214 }
215 afs.sort.sort(btn.getAttribute('data-sort-key'), btn.getAttribute('data-sort-direction'));
216 } else {
217 btn.classList.add('sort-active');
218 afs.sort.sort(btn.getAttribute('data-sort-key'), btn.getAttribute('data-sort-direction'));
219 }
220 });
221});
222
223document.querySelector('[data-sort-key="shuffle"]').addEventListener('click', (e) => {
224 afs.sort.shuffle();
225});