Compare commits

...

10 Commits

Author SHA1 Message Date
Ben Visness 032d6c435e Temporarily remove icons 2024-02-10 22:08:02 -06:00
Ben Visness 16e4b0327f Opengraph and banner 2024-02-10 22:06:43 -06:00
Ben Visness 97e6c74c52 Many change for great good yes 2024-02-10 20:54:13 -06:00
Ben Visness c5e458be8c Lay out top section 2024-02-09 15:02:58 -06:00
Asaf Gartner b5d4fe9ba2 Learning jam scaffolding 2024-02-08 22:21:01 +02:00
Ben Visness d896298117 go mod tidy 2024-02-04 10:49:53 -06:00
Asaf Gartner 845a2d377c Clear href when no calendars selected 2024-01-28 19:52:49 +02:00
Asaf Gartner 79dcef9b7f Changed url so thunderbird picks a better name for the calendar 2024-01-28 19:36:55 +02:00
Asaf Gartner d347b42e44 Added CALNAME prop 2024-01-28 19:27:25 +02:00
Asaf Gartner 8bc4b5a66c Added calendars 2024-01-28 19:12:59 +02:00
27 changed files with 1341 additions and 7 deletions

2
go.mod
View File

@ -11,6 +11,7 @@ require (
github.com/aws/aws-sdk-go-v2/credentials v1.3.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.13.0
github.com/aws/smithy-go v1.7.0
github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f
github.com/frustra/bbcode v0.0.0-20201127003707-6ef347fbe1c8
github.com/go-stack/stack v1.8.0
github.com/google/uuid v1.2.0
@ -56,6 +57,7 @@ require (
github.com/mitchellh/reflectwalk v1.0.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/teambition/rrule-go v1.7.2 // indirect
go.uber.org/atomic v1.10.0 // indirect
golang.org/x/net v0.6.0 // indirect
golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7 // indirect

4
go.sum
View File

@ -88,6 +88,8 @@ github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55k
github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f h1:feGUUxxvOtWVOhTko8Cbmp33a+tU0IMZxMEmnkoAISQ=
github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f/go.mod h1:2MKFUgfNMULRxqZkadG1Vh44we3y5gJAtTBlVsx1BKQ=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
@ -275,6 +277,8 @@ github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300 h1:XQdibLKagjdevRB6vA
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300/go.mod h1:FNa/dfN95vAYCNFrIKRrlRo+MBLbwmR9Asa5f2ljmBI=
github.com/teacat/noire v1.1.0 h1:5IgJ1H8jodiSSYnrVadV2JjbAnEgCCjYUQxSUuaQ7Sg=
github.com/teacat/noire v1.1.0/go.mod h1:cetGlnqr+9yKJcFgRgYXOWJY66XIrrjUsGBwNlNNtAk=
github.com/teambition/rrule-go v1.7.2 h1:goEajFWYydfCgavn2m/3w5U+1b3PGqPUHx/fFSVfTy0=
github.com/teambition/rrule-go v1.7.2/go.mod h1:mBJ1Ht5uboJ6jexKdNUJg2NcwP8uUMNvStWXlJD3MvU=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/wellington/go-libsass v0.9.2 h1:6Ims04UDdBs6/CGSVK5JC8FNikR5ssrsMMKE/uaO5Q8=
github.com/wellington/go-libsass v0.9.2/go.mod h1:mxgxgam0N0E+NAUMHLcu20Ccfc3mVpDkyrLDayqfiTs=

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

383
src/calendar/calendar.go Normal file
View File

@ -0,0 +1,383 @@
package calendar
import (
"bytes"
"context"
"crypto/sha1"
"io"
"net/http"
"sort"
"strings"
"sync"
"time"
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/jobs"
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/utils"
"github.com/emersion/go-ical"
)
type RawCalendarData struct {
Name string
Url string
Data []byte
Hash [sha1.Size]byte
}
type CalendarEvent struct {
ID string
Name string
Desc string
StartTime time.Time
EndTime time.Time
Duration time.Duration
CalName string
}
var unifiedCalendar *ical.Calendar
var rawCalendarData = make([]*RawCalendarData, 0)
var cachedICals = make(map[string][]byte)
var httpClient = http.Client{}
// NOTE(asaf): Passing an empty array for selectedCals returns all cals
func GetICal(selectedCals []string) ([]byte, error) {
if unifiedCalendar == nil {
return nil, oops.New(nil, "No calendar")
}
sort.Strings(selectedCals)
cacheKey := strings.Join(selectedCals, "##")
cachedICal, ok := cachedICals[cacheKey]
if ok {
return cachedICal, nil
}
var cal *ical.Calendar
if len(selectedCals) == 0 {
cal = unifiedCalendar
} else {
cal = newHMNCalendar()
for _, child := range unifiedCalendar.Children {
include := true
if child.Name == ical.CompEvent {
calName, _ := child.Props.Text(ical.PropComment)
if calName != "" {
found := false
for _, s := range selectedCals {
if calName == s {
found = true
}
}
if !found {
include = false
}
}
}
if include {
cal.Children = append(cal.Children, child)
}
}
}
var calBytes []byte
if len(cal.Children) > 0 {
var buffer bytes.Buffer
err := ical.NewEncoder(&buffer).Encode(cal)
if err != nil {
return nil, oops.New(err, "Failed to encode calendar to iCal")
}
calBytes = buffer.Bytes()
} else {
calBytes = emptyCalendarString()
}
cachedICals[cacheKey] = calBytes
return calBytes, nil
}
func GetFutureEvents() []CalendarEvent {
if unifiedCalendar == nil {
return nil
}
futureEvents := make([]CalendarEvent, 0)
eventObjects := unifiedCalendar.Events()
now := time.Now()
lastTime := now.Add(time.Hour * 24 * 365)
for _, ev := range eventObjects {
summary, err := ev.Props.Text(ical.PropSummary)
if err != nil {
logging.Error().Err(err).Msg("Failed to get summary for calendar event")
continue
}
startTime, err := ev.DateTimeStart(nil)
if err != nil {
logging.Error().Err(err).Str("Event name", summary).Msg("Failed to get start time for calendar event")
continue
}
var evTimes []time.Time
set, err := ev.RecurrenceSet(nil)
if err != nil {
logging.Error().Err(err).Str("Event name", summary).Msg("Failed to get recurrence set for calendar event")
continue
}
if set != nil {
evTimes = set.Between(now, lastTime, true)
} else if startTime.After(now) {
evTimes = []time.Time{startTime}
}
if len(evTimes) == 0 {
continue
}
desc, err := ev.Props.Text(ical.PropDescription)
if err != nil {
logging.Error().Err(err).Str("Event name", summary).Msg("Failed to get description for calendar event")
continue
}
calName, _ := ev.Props.Text(ical.PropComment)
uid, err := ev.Props.Text(ical.PropUID)
if err != nil {
logging.Error().Err(err).Str("Event name", summary).Msg("Failed to get uid for calendar event")
continue
}
endTime, err := ev.DateTimeStart(nil)
if err != nil {
logging.Error().Err(err).Str("Event name", summary).Msg("Failed to get end time for calendar event")
continue
}
evDuration := endTime.Sub(startTime)
for _, t := range evTimes {
futureEvents = append(futureEvents, CalendarEvent{
ID: uid,
Name: summary,
Desc: desc,
StartTime: t,
EndTime: t.Add(evDuration),
Duration: evDuration,
CalName: calName,
})
}
}
sort.Slice(futureEvents, func(i, j int) bool {
return futureEvents[i].StartTime.Before(futureEvents[j].StartTime)
})
return futureEvents
}
func MonitorCalendars(ctx context.Context) jobs.Job {
log := logging.ExtractLogger(ctx).With().Str("calendar goroutine", "calendar monitor").Logger()
if len(config.Config.Calendars) == 0 {
log.Info().Msg("No calendars specified in config")
return jobs.Noop()
}
ctx = logging.AttachLoggerToContext(&log, ctx)
job := jobs.New()
go func() {
defer func() {
log.Info().Msg("Shutting down calendar monitor")
job.Done()
}()
log.Info().Msg("Running calendar monitor")
monitorTimer := time.NewTimer(time.Second)
for {
select {
case <-monitorTimer.C:
err := func() (err error) {
defer utils.RecoverPanicAsError(&err)
ReloadCalendars(ctx)
return nil
}()
if err != nil {
logging.Error().Err(err).Msg("Panicked in MonitorCalendars")
}
monitorTimer.Reset(time.Minute)
case <-ctx.Done():
return
}
}
}()
return job
}
func ReloadCalendars(ctx context.Context) {
log := logging.ExtractLogger(ctx)
// Download calendars
calChan := make(chan RawCalendarData, len(config.Config.Calendars))
var wg sync.WaitGroup
wg.Add(len(config.Config.Calendars))
for _, c := range config.Config.Calendars {
go func(cal config.CalendarSource) {
defer func() {
wg.Done()
logging.LogPanics(log)
}()
calUrl := cal.Url
req, err := http.NewRequestWithContext(ctx, "GET", calUrl, nil)
if err != nil {
log.Error().Err(err).Msg("Failed to create request for calendar fetch")
return
}
res, err := httpClient.Do(req)
if err != nil {
log.Error().Err(err).Str("Url", calUrl).Msg("Failed to fetch calendar")
return
}
if res.StatusCode > 299 || !strings.HasPrefix(res.Header.Get("Content-Type"), "text/calendar") {
log.Error().Str("Url", calUrl).Str("Status", res.Status).Msg("Failed to fetch calendar")
io.ReadAll(res.Body)
res.Body.Close()
return
}
data, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
log.Error().Err(err).Str("Url", calUrl).Msg("Failed to fetch calendar")
return
}
calChan <- RawCalendarData{Name: cal.Name, Url: calUrl, Data: data}
}(c)
}
wg.Wait()
newRawCalendarData := make([]*RawCalendarData, 0, len(config.Config.Calendars))
Collect:
for {
select {
case d := <-calChan:
newRawCalendarData = append(newRawCalendarData, &d)
default:
break Collect
}
}
// Diff calendars
same := true
for _, n := range newRawCalendarData {
n.Hash = sha1.Sum(n.Data)
}
sort.Slice(newRawCalendarData, func(i, j int) bool {
return newRawCalendarData[i].Name < newRawCalendarData[j].Name
})
if len(newRawCalendarData) != len(rawCalendarData) {
same = false
} else {
for i := range newRawCalendarData {
newData := newRawCalendarData[i]
oldData := rawCalendarData[i]
if newData.Name != oldData.Name {
same = false
break
}
if newData.Hash != oldData.Hash {
same = false
break
}
}
}
if same {
return
}
// Unify calendars and clear cache
rawCalendarData = newRawCalendarData
cachedICals = make(map[string][]byte)
unified := newHMNCalendar()
var timezones []string
for _, calData := range rawCalendarData {
decoder := ical.NewDecoder(bytes.NewReader(calData.Data))
calNameProp := ical.NewProp(ical.PropComment)
calNameProp.SetText(calData.Name)
for {
cal, err := decoder.Decode()
if err == io.EOF {
break
} else if err != nil {
log.Error().Err(err).Str("Url", calData.Url).Msg("Failed to parse calendar")
break
}
for _, child := range cal.Children {
if child.Name == ical.CompTimezone {
tzid, err := child.Props.Text(ical.PropTimezoneID)
if err != nil {
found := false
for _, s := range timezones {
if s == tzid {
found = true
}
}
if found {
continue
} else {
timezones = append(timezones, tzid)
}
} else {
continue
}
}
if child.Name == ical.CompEvent {
child.Props.Set(calNameProp)
}
unified.Children = append(unified.Children, child)
}
}
}
unifiedCalendar = unified
}
func newHMNCalendar() *ical.Calendar {
cal := ical.NewCalendar()
prodID := ical.NewProp(ical.PropProductID)
prodID.SetText("Handmade Network")
cal.Props.Set(prodID)
version := ical.NewProp(ical.PropVersion)
version.SetText("1.0")
cal.Props.Set(version)
name := ical.NewProp("X-WR-CALNAME")
name.SetText("Handmade Network")
cal.Props.Set(name)
return cal
}
// NOTE(asaf): The ical library we're using doesn't like encoding empty calendars, so we have to do this manually.
func emptyCalendarString() []byte {
empty := `BEGIN:VCALENDAR
VERSION:1.0
PRODID:Handmade Network
X-WR-CALNAME:Handmade Network
END:VCALENDAR
`
return []byte(empty)
}

View File

@ -78,6 +78,8 @@ var Config = HMNConfig{
BaseUrl: "https://api.twitch.tv/helix",
BaseIDUrl: "https://id.twitch.tv/oauth2",
},
Calendars: []CalendarSource{
},
EpisodeGuide: EpisodeGuide{
CineraOutputPath: "./annotations/",
Projects: map[string]string{"hero": "code", "riscy": "riscy", "bitwise": "bitwise"},

View File

@ -32,6 +32,7 @@ type HMNConfig struct {
EpisodeGuide EpisodeGuide
DevConfig DevConfig
PreviewGeneration PreviewGenerationConfig
Calendars []CalendarSource
}
type PostgresConfig struct {
@ -101,6 +102,11 @@ type MatrixConfig struct {
AnnouncementsRoomID string
}
type CalendarSource struct {
Name string
Url string
}
type EpisodeGuide struct {
CineraOutputPath string
Projects map[string]string // NOTE(asaf): Maps from slugs to default episode guide topic

View File

@ -62,6 +62,17 @@ var WRJ2023 = Jam{
UrlSlug: "2023",
}
var LJ2024 = Jam{
Event: Event{
StartTime: time.Date(2024, 3, 15, 17, 0, 0, 0, time.UTC),
EndTime: time.Date(2024, 3, 24, 20, 0, 0, 0, time.UTC),
},
Name: "Learning Jam 2024",
Slug: "LJ2024",
UrlSlug: "learning-2024",
}
// Conferences
var HMS2022 = Event{
StartTime: time.Date(2022, 11, 16, 0, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
EndTime: time.Date(2022, 11, 18, 0, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
@ -77,7 +88,7 @@ var HMBoston2023 = Event{
EndTime: time.Date(2023, 8, 4, 0, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
}
var AllJams = []Jam{WRJ2021, WRJ2022, VJ2023, WRJ2023}
var AllJams = []Jam{WRJ2021, WRJ2022, VJ2023, WRJ2023, LJ2024}
func CurrentJam() *Jam {
now := time.Now()

View File

@ -143,7 +143,6 @@ func TestFeed(t *testing.T) {
func TestProjectIndex(t *testing.T) {
AssertRegexMatch(t, BuildProjectIndex(1, ""), RegexProjectIndex, nil)
AssertRegexMatch(t, BuildProjectIndex(2, ""), RegexProjectIndex, map[string]string{"page": "2"})
AssertRegexMatch(t, BuildProjectIndex(1, "test"), RegexProjectIndex, map[string]string{"category": "test"})
AssertRegexMatch(t, BuildProjectIndex(2, "test"), RegexProjectIndex, map[string]string{"page": "2", "category": "test"})
assert.Panics(t, func() { BuildProjectIndex(0, "") })
@ -427,6 +426,16 @@ func TestJamFeed2023(t *testing.T) {
AssertSubdomain(t, BuildJamFeed2023(), "")
}
func TestJamIndex2024_Learning(t *testing.T) {
AssertRegexMatch(t, BuildJamIndex2024_Learning(), RegexJamIndex2024_Learning, nil)
AssertSubdomain(t, BuildJamIndex2024_Learning(), "")
}
func TestJamFeed2024_Learning(t *testing.T) {
AssertRegexMatch(t, BuildJamFeed2024_Learning(), RegexJamFeed2024_Learning, nil)
AssertSubdomain(t, BuildJamFeed2024_Learning(), "")
}
func TestTimeMachine(t *testing.T) {
AssertRegexMatch(t, BuildTimeMachine(), RegexTimeMachine, nil)
AssertSubdomain(t, BuildTimeMachine(), "")

View File

@ -126,6 +126,20 @@ func BuildJamRecap2023_Visibility() string {
return Url("/jam/visibility-2023/recap", nil)
}
var RegexJamIndex2024_Learning = regexp.MustCompile("^/jam/learning-2024$")
func BuildJamIndex2024_Learning() string {
defer CatchPanic()
return Url("/jam/learning-2024", nil)
}
var RegexJamFeed2024_Learning = regexp.MustCompile("^/jam/learning-2024/feed$")
func BuildJamFeed2024_Learning() string {
defer CatchPanic()
return Url("/jam/learning-2024/feed", nil)
}
func BuildJamIndexAny(slug string) string {
defer CatchPanic()
return Url(fmt.Sprintf("/jam/%s", slug), nil)
@ -171,6 +185,20 @@ func BuildTimeMachineFormDone() string {
return Url("/timemachine/thanks", nil)
}
var RegexCalendarIndex = regexp.MustCompile("^/calendar$")
func BuildCalendarIndex() string {
defer CatchPanic()
return Url("/calendar", nil)
}
var RegexCalendarICal = regexp.MustCompile("^/Handmade Network.ical$")
func BuildCalendarICal() string {
defer CatchPanic()
return Url("/Handmade Network.ical", nil)
}
// QUESTION(ben): Can we change these routes?
var RegexLoginAction = regexp.MustCompile("^/login$")

View File

@ -8,6 +8,7 @@ import (
"strconv"
"strings"
"git.handmade.network/hmn/hmn/src/calendar"
"git.handmade.network/hmn/hmn/src/hmndata"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models"
@ -527,6 +528,16 @@ func EducationArticleToTemplate(a *models.EduArticle) EduArticle {
return res
}
func CalendarEventToTemplate(ev *calendar.CalendarEvent) CalendarEvent {
return CalendarEvent{
Name: ev.Name,
Desc: ev.Desc,
StartTime: ev.StartTime.UTC(),
EndTime: ev.EndTime.UTC(),
CalName: ev.CalName,
}
}
func maybeString(s *string) string {
if s == nil {
return ""

View File

@ -0,0 +1,92 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="ph3 ph0-ns">
<h2>Future events</h2>
<div class="cal_toggles mb2 flex flex-row g2">
{{ range .Calendars }}
<label class="db br3 pv1 ph2 pointer b--gray ba" for="{{ . }}">
<input id="{{ . }}" autocomplete="off" type="checkbox" value="{{ . }}" checked />
<span>{{ . }}</span>
</label>
{{ end }}
</div>
<div class="">
<a class="ical_link" href="{{ .BaseICalUrl }}">Copy iCal Url</a>
</div>
<div class="flex flex-column g2">
{{ range .Events }}
<div data-calname="{{ .CalName }}" class="cal_event timeline-item pa3 br3">
<div>{{ timehtmlcontent .StartTime }}</div>
<div>
<strong class="f4 c--theme">{{ .Name }}</strong>
</div>
{{ with .Desc }}
<div class="mb2">{{ . }}</div>
{{ end }}
{{ with .CalName }}
<div class="dib br2 ba b--gray ph1 f7">{{ . }}</div>
{{ end }}
</div>
{{ end }}
</div>
</div>
<script>
let events = document.querySelectorAll(".cal_event");
let toggles = document.querySelectorAll(".cal_toggles input");
let icalLink = document.querySelector(".ical_link");
let baseICalUrl = icalLink.href;
function refreshEvents() {
let cals = {};
for (let i = 0; i < toggles.length; ++i) {
cals[toggles[i].id] = toggles[i].checked;
}
for (let i = 0; i < events.length; ++i) {
let ev = events[i];
let calName = ev.getAttribute("data-calname");
if (cals[calName]) {
ev.style.display = "block";
} else {
ev.style.display = "none";
}
}
let icalFilter = [];
let hasAll = true;
for (let i = 0; i < toggles.length; ++i) {
if (toggles[i].checked) {
icalFilter.push(toggles[i].id);
} else {
hasAll = false;
}
}
icalLink.disabled = false;
if (hasAll) {
icalLink.href = baseICalUrl;
} else if (icalFilter.length == 0) {
icalLink.removeAttribute("href");
} else {
icalFilter.sort();
let url = new URL(baseICalUrl);
let params = new URLSearchParams();
for (let i = 0; i < icalFilter.length; ++i) {
params.append(icalFilter[i], "true");
}
url.search = params.toString();
icalLink.href = url.toString();
}
}
for (let i = 0; i < toggles.length; ++i) {
toggles[i].addEventListener("input", refreshEvents);
}
icalLink.addEventListener("click", function(ev) {
ev.preventDefault();
navigator.clipboard.writeText(icalLink.href);
});
</script>
{{ end }}

View File

@ -91,6 +91,7 @@
<a href="{{ .Header.FishbowlUrl }}">Fishbowls</a>
<a href="{{ .Header.PodcastUrl }}">Podcast</a>
<a href="https://guide.handmade-seattle.com/s" target="_blank">Handmade Dev Show</a>
{{/*<a href="{{ .Header.CalendarUrl }}">Calendar</a>*/}}
</div>
</div>
<div class="root-item">
@ -183,9 +184,18 @@
}
}
const dtf = new Intl.DateTimeFormat([], {
dateStyle: "full",
timeStyle: "short",
});
for (const time of document.querySelectorAll('time')) {
const d = new Date(Date.parse(time.dateTime));
time.title = d.toLocaleString();
if (time.getAttribute("data-type") == "content") {
time.textContent = dtf.format(d);
} else {
time.title = dtf.format(d);
}
}
}
});

View File

@ -0,0 +1,74 @@
{{ template "jam_2024_learning_base.html" . }}
{{ define "content-top" }}
<div class="flex flex-column items-center pa3 g3">
<img class="mw5" src="{{ static "visjam2023/logo.svg" }}">
<div class="f1 fw7">The Learning Jam</div>
<div class="flex flex-row g5 lh-solid">
<div>
<div class="fw7 f3">Learn</div>
<div class="fw3 f4">March 15-17, 2024</div>
</div>
<div>
<div class="fw7 f3">Teach</div>
<div class="fw3 f4">March 22-24, 2024</div>
</div>
</div>
<div class="mt3">
<a href="{{ .DiscordInviteUrl }}" class="db ph2 pv1 button-simple">Join the Discord</a>
</div>
</div>
{{ end }}
{{ define "content" }}
<div class="flex flex-column g3 items-center pa3">
<div class="w6">
<h2 class="c--theme-gradient-light">Recent activity</h2>
<div class="flex flex-column g3">
{{ if .TimelineItems }}
{{ range .TimelineItems }}
<div class="flex flex-column g2 bg--rich-gray pa2">
<div class="flex flex-row g2">
{{ if .OwnerAvatarUrl }}
<a class="flex flex-shrink-0 br-100 square items-center justify-center overflow-hidden bg--gray" href="{{ .OwnerUrl }}">
<img class="w2" src="{{ .OwnerAvatarUrl }}" />
</a>
{{ end }}
<div class="flex flex-column">
<div class="fw6">{{ .OwnerName }}</div>
<div class="f7">{{ timehtml (relativedate .Date) .Date }}</div>
</div>
</div>
{{ if .Description }}
<div>{{ trim .Description }}</div>
{{ end }}
{{ range .EmbedMedia }}
<div class="flex flex-column {{ if eq .Type mediaembed }}wide-screen{{ end }} justify-stretch iframe-fill">
{{ if eq .Type mediaimage }}
<img src="{{ .AssetUrl }}">
{{ else if eq .Type mediavideo }}
{{ if .ThumbnailUrl }}
<video src="{{ .AssetUrl }}" poster="{{ .ThumbnailUrl }}" preload="none" controls>
{{ else }}
<video src="{{ .AssetUrl }}" preload="metadata" controls>
{{ end }}
{{ else if eq .Type mediaaudio }}
<audio src="{{ .AssetUrl }}" controls>
{{ else if eq .Type mediaembed }}
{{ .EmbedHTML }}
{{ else }}
<div class="">
<a href="{{ .AssetUrl }}" target="_blank">{{ .Filename }} ({{ filesize .FileSize }})</a>
</div>
{{ end }}
</div>
{{ end }}
</div>
{{ end }}
{{ else }}
Be the first!
{{ end }}
</div>
</div>
</div>
{{ end }}

View File

@ -0,0 +1,155 @@
{{ template "jam_2024_learning_base.html" . }}
{{ define "extrahead" }}
<style>
.jam-logo {
max-width: 18rem;
}
.jam-title {
/* align with the width of the logo */
font-size: 2.76rem;
font-weight: 700;
/* align with the text's actual bounding box */
line-height: 1.18;
margin-top: -0.18em;
margin-left: -0.03em;
margin-right: -0.03em;
}
/* not small */
@media screen and (min-width: 35em) {
.jam-logo {
max-width: 24rem;
}
.jam-title {
font-size: 3.7rem;
}
}
</style>
{{ end }}
{{ define "content-top" }}
<div class="mw7 margin-center">
<div class="flex flex-column pv4 tc">
<img class="jam-logo margin-center" src="{{ static "learningjam2024/logo.svg" }}">
<div class="margin-center mt3 mt4-ns mb4">
<div class="jam-title mb3">Learning Jam</div>
<div class="flex flex-column flex-row-ns justify-between lh-solid g3 g0-ns">
<div>
<div class="fw7 f3 mb1">Learn</div>
<div class="fw3 f4">March 15-17, 2024</div>
</div>
<div>
<div class="fw7 f3 mb1">Teach</div>
<div class="fw3 f4">March 22-24, 2024</div>
</div>
</div>
</div>
{{ if lt .DaysUntilStart 0 }}
<div class="flex flex-row g3 mt2">
{{ if .SubmittedProjectUrl }}
<a class="db ph3 pv2 f4 button-simple" href="{{ .SubmittedProjectUrl }}">Share your progress</a>
{{ else }}
<a class="db ph3 pv2 f4 button-simple" href="{{ .ProjectSubmissionUrl }}">Create your project</a>
{{ end }}
<a class="db ph3 pv2 f4 button-simple" href="{{ .JamFeedUrl }}">Recent activity</a>
</div>
{{ end }}
<div class="flex g3 justify-center">
<a href="{{ .DiscordInviteUrl }}" class="btn--jam">Join the Discord</a>
</div>
</div>
</div>
{{ end }}
{{ define "content" }}
<style>
.participate-icon {
--mask-url: url("{{ static "learningjam2024/logo.svg" }}");
}
</style>
<div class="mw7 margin-center flex flex-column">
<div class="ph3 pv4 bb b--rich-gray">
<h2 class="dib c--theme-gradient-light">What is a Learning Jam?</h2>
<div class="post-content">
<p>
The <b class="c--theme-gradient-light">Learning Jam</b> is an opportunity for you to learn something new.
</p>
<p>
Unlike traditional game jams, and unlike our previous programming jams, the goal of the <b class="c--theme-gradient-light">Learning Jam</b> is <em>knowledge</em>. It's an opportunity to throw yourself at a topic and learn everything you can about it—and then, to turn around and teach it to others.
</p>
<p>
The jam takes place over <b>two weekends</b>. On the first weekend, you'll learn as much as you can about your topic of choice. On the second, you'll teach it to others in whatever form you like—writeup, video, Minecraft mod, whatever.
</p>
</div>
</div>
<div class="ph3 pv4 bb b--rich-gray">
<h2 class="mb3 dib c--theme-gradient-light">How to participate</h2>
<div class="flex flex-column g2">
<div class="pa3 flex flex-column flex-row-ns g3 items-center bg--rich-gray">
<!-- <div class="flex-shrink-0 svg-mask participate-icon bg--theme-gradient-light">
<img class="w4 invisible" src="{{ static "learningjam2024/logo.svg" }}" />
</div> -->
<div class="post-content">
<h3 class="f4">Choose a topic.</h3>
<p>
Decide what you'll spend your weekend learning about. Maybe there's an area of programming you've been meaning to dig into, or maybe there's a specialization in your field that you've been curious about. Maybe you just need a reason to read papers for a weekend.
</p>
<p>
You're welcome to work in teams or work solo.
</p>
</div>
</div>
<div class="pa3 flex flex-column flex-row-ns g3 items-center bg--rich-gray">
<!-- <div class="flex-shrink-0 svg-mask participate-icon bg--theme-gradient-light">
<img class="w4 invisible" src="{{ static "learningjam2024/logo.svg" }}" />
</div> -->
<div class="post-content">
<h3 class="f4">Mar 15-17: Learn!</h3>
<p>
Create a Handmade Network project to track your progress. Then go down the rabbit hole. Absorb as much information as you can in a weekend.
</p>
<p>
As you learn, we encourage you to share updates on Discord using the <b>!til</b> command. These updates will be published as part of your submission.
</p>
</div>
</div>
<div class="pa3 flex flex-column flex-row-ns g3 items-center bg--rich-gray">
<!-- <div class="flex-shrink-0 svg-mask participate-icon bg--theme-gradient-light">
<img class="w4 invisible" src="{{ static "learningjam2024/logo.svg" }}" />
</div> -->
<div class="post-content">
<h3 class="f4">Mar 22-24: Teach!</h3>
<p>
Share what you learned with the rest of the community. You can present the information in any form you like—whatever helps you communicate most effectively.
</p>
<p>
Your Handmade Network project is your final submission. Make sure the description gives adequate context, and either attach or link to your final presentation.
</p>
</div>
</div>
</div>
</div>
<div class="ph3 pv4 bb b--rich-gray">
<h2 class="dib c--theme-gradient-light">Why?</h2>
<div class="post-content">
<p>
The Handmade Network's goal is to change the software industry by building back up from new foundations. But in order to do that, we need to understand those foundations.
</p>
<p>
Our other programming jams are great opportunities to try building new things. But what should those new things be? We want to give the community a chance to focus just on learning and research, to gather information and share it with others, to boost the entire community's knowledge on a topic.
</p>
<p>
We hope that the results of the <b class="c--theme-gradient-light">Learning Jam</b> inspire new ideas for the community to explore in the Visibility and Wheel Reinvention jams later in the year. There is so much bad software out in the world, and this is the first step toward reinventing it.
</p>
</div>
</div>
<div>
<div class="mv5 h3 fill-current">
<a href="{{ .Header.HMNHomepageUrl }}">{{ svg "hmn_circuit" }}</a>
</div>
</div>
</div>
{{ end }}

View File

@ -16,6 +16,11 @@
<p>Since 2020, we have been running programming jams to encourage community members to explore new ideas and start projects. You can view all the past submissions and results here.</p>
<div class="jam-grid g3">
<a href="{{ .LJ2024Url }}">
<div class="br2 overflow-hidden flex">
<img src="{{ static "learningjam2024/TwitterCard.png" }}">
</div>
</a>
<a href="{{ .WRJ2023Url }}">
<div class="br2 overflow-hidden flex">
<img src="{{ static "wheeljam2023/TwitterCard.png" }}">

View File

@ -2,6 +2,7 @@
{{ define "extrahead" }}
<script src="{{ static "js/templates.js" }}"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
{{ end }}
{{ define "content" }}
@ -111,6 +112,108 @@
</div>
*/}}
<div class="mb3 ph3 ph0-ns">
<style>
#jam-banner {
background: linear-gradient(to bottom right, #003c83, #019AD2);
color: white !important;
text-align: center;
}
#jam-banner .jam-logo {
height: 7.25rem;
}
#jam-banner h3 {
font-family: 'Inter', sans-serif;
font-weight: 700;
font-size: 2.31rem;
/* line-height: 0.8; */
margin: 0;
}
#jam-title-container {
line-height: 1.25;
}
#jam-details {
font-family: 'Inter', sans-serif;
font-size: 1.2rem;
/* line-height: 0.8; */
margin-top: 0.2rem;
}
#jam-learn-more {
font-size: 1rem;
}
@media screen and (min-width: 30rem) {
#jam-banner {
text-align: left;
}
#jam-banner h3 {
font-size: 2.2rem;
}
#jam-banner .jam-logo {
height: 5.25rem;
}
}
</style>
<a id="jam-banner" class="pv3 ph3 ph4-l br3 flex flex-column flex-row-ns items-center" href="{{ .JamUrl }}">
<img class="jam-logo" src="{{ static "learningjam2024/logo.svg" }}">
<div id="jam-title-container" class="flex flex-column pl3-m pl4-l pv3 pv0-ns">
<h3 id="jam-title">Learning Jam</h3>
<div id="jam-details">
<div class="db dn-ns">
<div>
March 15-17. March 22-24.
</div>
<div>
{{ if gt .JamDaysUntilEnd 0 }}
{{ if eq .JamDaysUntilStart 0 }}
<b>Happening now.</b>
{{ else if eq .JamDaysUntilStart 1 }}
<b>Starting tomorrow.</b>
{{ else }}
<b>In {{ .JamDaysUntilStart }} days.</b>
{{ end }}
{{ else }}
<b>See the results.</b>
{{ end }}
</div>
</div>
<div class="dn db-ns">
<div>
March 15-17.
</div>
<div>
March 22-24.
{{ if gt .JamDaysUntilEnd 0 }}
{{ if eq .JamDaysUntilStart 0 }}
<b>Happening now.</b>
{{ else if eq .JamDaysUntilStart 1 }}
<b>Starting tomorrow.</b>
{{ else }}
<b>In {{ .JamDaysUntilStart }} days.</b>
{{ end }}
{{ else }}
<b>See the results.</b>
{{ end }}
</div>
</div>
</div>
</div>
<div class="flex-grow-1"></div>
<div id="jam-learn-more">
Learn more
<div class="dib svgicon">{{ svg "chevron-right" }}</div>
</div>
</a>
</div>
{{/*
<div class="mb3 ph3 ph0-ns">
<style>
#jam-banner {
@ -177,6 +280,7 @@
</div>
</a>
</div>
*/}}
{{/*
<div class="mb3 ph3 ph0-ns">

View File

@ -0,0 +1,221 @@
{{/*
This is a copy-paste from base.html because we want to preserve the unique
style of this page no matter what future changes we make to the base.
*/}}
<!DOCTYPE html{{ if .OpenGraphItems }} prefix="og: http://ogp.me/ns#"{{ end }}>
<html lang="en-US">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" sizes="16x16" href="{{ static "learningjam2024/favicon-16x16.png" }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ static "learningjam2024/favicon-32x32.png" }}">
{{ if .CanonicalLink }}<link rel="canonical" href="{{ .CanonicalLink }}">{{ end }}
{{ range .OpenGraphItems }}
{{ if .Property }}
<meta property="{{ .Property }}" content="{{ .Value }}" />
{{ else }}
<meta name="{{ .Name }}" content="{{ .Value }}" />
{{ end }}
{{ end }}
{{ if .Title }}
<title>{{ .Title }} | Handmade Network</title>
{{ else }}
<title>Handmade Network</title>
{{ end }}
<meta name="theme-color" content="#003C83">
<script src="{{ static "js/templates.js" }}"></script>
<link rel="stylesheet" href="{{ static "fonts/mohave/stylesheet.css" }}">
<link href='https://fonts.googleapis.com/css?family=Fira+Sans:300,400,500,600' rel='stylesheet' type='text/css'>
<link href='https://fonts.googleapis.com/css?family=Fira+Mono:300,400,500,700' rel='stylesheet' type='text/css'>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
<link rel="stylesheet" type="text/css" href="{{ static "style.css" }}">
<style>
:root {
--theme-gradient-dark: linear-gradient(to bottom right, #003c83, #019AD2);
--theme-gradient-light: linear-gradient(to bottom right, #8BD5FF, #5899FF);
--white: #fff;
--bg-button: rgba(255, 255, 255, 0);
--bg-button-hover: rgba(255, 255, 255, 0.1);
--charcoal: #2F2F2F;
--gray: #CBCBCB;
--rich-gray: #494949;
--spacing-0: 0;
--spacing-1: .25rem;
--spacing-2: .5rem;
--spacing-3: 1rem;
--spacing-4: 2rem;
--spacing-5: 4rem;
--spacing-6: 8rem;
--spacing-7: 16rem;
--border-radius-2: 0.25rem;
}
body {
font-family: "Inter", "Fira Sans", sans-serif;
font-size: 1rem; /* remove this override when base stylesheet is less dumb */
}
h1, h2, h3, h4, h5, h6 {
font-weight: 700;
}
.post-content p {
/* stupid override, should be done by .post-content instead of .content */
margin: 0.6rem 0;
}
.bg--theme-gradient-dark {
background: var(--theme-gradient-dark);
}
.bg--theme-gradient-light {
background: var(--theme-gradient-light);
}
.bg--charcoal {
background: var(--charcoal);
}
.bg--rich-gray {
background: var(--rich-gray);
}
.bg--gray {
background: var(--gray);
}
.b--rich-gray {
border-color: var(--rich-gray);
}
.c--theme-gradient-dark {
background: var(--theme-gradient-dark);
background-clip: text;
color: transparent;
}
.c--theme-gradient-light {
background: var(--theme-gradient-light);
background-clip: text;
color: transparent;
}
.btn--jam {
border: 1px solid var(--white);
border-radius: var(--border-radius-2);
padding: var(--spacing-2) var(--spacing-3);
background-color: var(--bg-button);
}
.btn--jam:hover {
background-color: var(--bg-button-hover);
}
.button-simple {
background: var(--bg-button);
border: 1px solid var(--white);
}
.button-simple:hover {
background: var(--bg-button-hover);
}
.c-white {
color: var(--white);
}
.invisible {
visibility: hidden;
}
.svg-mask {
mask: var(--mask-url);
}
.fill-current {
fill: currentColor;
}
.square {
aspect-ratio: 1 / 1;
}
.wide-screen {
aspect-ratio: 16 / 9;
}
.iframe-fill iframe {
flex-grow: 1;
}
.hmn-logo {
font-family: 'MohaveHMN', sans-serif;
text-transform: uppercase;
font-size: 1.6rem;
padding: 0.2rem 0.8rem;
}
.header-nav {
padding-right: 0.6rem;
}
.user-avatar-header {
width: 1.8rem;
border-radius: 999px;
background-color: rgba(0, 0, 0, 0.4);
}
/* not small */
@media screen and (min-width: 35em) {
}
</style>
{{ template "extrahead" . }}
</head>
<body class="bg--charcoal flex flex-column">
<div class="c-white bg--theme-gradient-dark">
<div class="bg-black-20 flex flex-row items-center">
<a href="{{ .Header.HMNHomepageUrl }}" class="hmn-logo flex-shrink-0">
Handmade
</a>
<div class="flex-grow-1 flex-shrink-1"></div>
<div class="header-nav flex flex-row items-center g3 lh-solid f6">
<a href="{{ .Header.ProjectIndexUrl }}">Projects</a>
<a href="{{ .Header.JamsUrl }}">Jams</a>
<a class="db" href="{{ or .Header.UserProfileUrl .LoginPageUrl }}">
<img class="user-avatar-header" src="{{ .UserAvatarUrl }}">
</a>
</div>
</div>
{{ block "content-top" . }}{{ end }}
</div>
<div class="bg--charcoal c-white flex-grow-1">
{{ block "content" . }}{{ end }}
</div>
<script>
const dtf = new Intl.DateTimeFormat([], {
dateStyle: "full",
timeStyle: "short",
});
for (const time of document.querySelectorAll('time')) {
const d = new Date(Date.parse(time.dateTime));
if (time.getAttribute("data-type") == "content") {
time.textContent = dtf.format(d);
} else {
time.title = dtf.format(d);
}
}
</script>
</body>
</html>

View File

@ -252,6 +252,10 @@ var HMNTemplateFuncs = template.FuncMap{
iso := t.UTC().Format(time.RFC3339)
return template.HTML(fmt.Sprintf(`<time datetime="%s">%s</time>`, iso, formatted))
},
"timehtmlcontent": func(t time.Time) template.HTML {
iso := t.UTC().Format(time.RFC3339)
return template.HTML(fmt.Sprintf(`<time data-type="content" datetime="%s"></time>`, iso))
},
"noescape": func(str string) template.HTML {
return template.HTML(str)
},

View File

@ -20,6 +20,7 @@ type BaseData struct {
CurrentProjectUrl string
LoginPageUrl string
ProjectCSSUrl string
DiscordInviteUrl string
Project Project
User *User
@ -55,6 +56,7 @@ type Header struct {
ConferencesUrl string
JamsUrl string
EducationUrl string
CalendarUrl string
Project *ProjectHeader
}
@ -415,3 +417,11 @@ type EduArticle struct {
Content template.HTML
}
type CalendarEvent struct {
Name string
Desc string
StartTime time.Time
EndTime time.Time
CalName string
}

View File

@ -51,6 +51,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
CurrentProjectUrl: c.UrlContext.BuildHomepage(),
LoginPageUrl: hmnurl.BuildLoginPage(c.FullUrl()),
ProjectCSSUrl: hmnurl.BuildProjectCSS(project.Color1),
DiscordInviteUrl: "https://discord.gg/hmn",
Project: templates.ProjectToTemplate(&project, c.UrlContext.BuildHomepage()),
User: templateUser,
@ -79,6 +80,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
ConferencesUrl: hmnurl.BuildConferences(),
JamsUrl: hmnurl.BuildJamsIndex(),
EducationUrl: hmnurl.BuildEducationIndex(),
CalendarUrl: hmnurl.BuildCalendarIndex(),
},
Footer: templates.Footer{
HomepageUrl: hmnurl.BuildHomepage(),

55
src/website/calendar.go Normal file
View File

@ -0,0 +1,55 @@
package website
import (
"net/http"
"git.handmade.network/hmn/hmn/src/calendar"
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/templates"
)
func CalendarIndex(c *RequestContext) ResponseData {
type CalData struct {
templates.BaseData
Calendars []string
Events []templates.CalendarEvent
BaseICalUrl string
}
events := calendar.GetFutureEvents()
templateEvents := make([]templates.CalendarEvent, 0, len(events))
for _, ev := range events {
templateEvents = append(templateEvents, templates.CalendarEventToTemplate(&ev))
}
calNames := []string{}
for _, c := range config.Config.Calendars {
calNames = append(calNames, c.Name)
}
calendarData := CalData{
BaseData: getBaseDataAutocrumb(c, "Calendar"),
Calendars: calNames,
Events: templateEvents,
BaseICalUrl: hmnurl.BuildCalendarICal(),
}
var res ResponseData
res.MustWriteTemplate("calendar_index.html", calendarData, c.Perf)
return res
}
func CalendarICal(c *RequestContext) ResponseData {
query := c.Req.URL.Query()
cals := make([]string, 0, len(query))
for key := range query {
cals = append(cals, key)
}
calBytes, err := calendar.GetICal(cals)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, err)
}
var res ResponseData
res.Write(calBytes)
return res
}

View File

@ -27,6 +27,7 @@ func JamsIndex(c *RequestContext) ResponseData {
WRJ2022Url string
VJ2023Url string
WRJ2023Url string
LJ2024Url string
}
res.MustWriteTemplate("jams_index.html", TemplateData{
@ -37,6 +38,143 @@ func JamsIndex(c *RequestContext) ResponseData {
WRJ2022Url: hmnurl.BuildJamIndex2022(),
VJ2023Url: hmnurl.BuildJamIndex2023_Visibility(),
WRJ2023Url: hmnurl.BuildJamIndex2023(),
LJ2024Url: hmnurl.BuildJamIndex2024_Learning(),
}, c.Perf)
return res
}
func JamIndex2024_Learning(c *RequestContext) ResponseData {
var res ResponseData
daysUntilStart := daysUntil(hmndata.LJ2024.StartTime)
daysUntilEnd := daysUntil(hmndata.LJ2024.EndTime)
baseData := getBaseDataAutocrumb(c, hmndata.LJ2024.Name)
baseData.OpenGraphItems = []templates.OpenGraphItem{
{Property: "og:site_name", Value: "Handmade Network"},
{Property: "og:type", Value: "website"},
{Property: "og:image", Value: hmnurl.BuildPublic("learningjam2024/2024LJOpenGraph.png", true)},
{Property: "og:description", Value: "A two-weekend jam where you dive deep into a topic, then teach it to the rest of the community."},
{Property: "og:url", Value: hmnurl.BuildJamIndex2024_Learning()},
}
type JamPageData struct {
templates.BaseData
UserAvatarUrl string
DaysUntilStart, DaysUntilEnd int
TwitchEmbedUrl string
ProjectSubmissionUrl string
SubmittedProjectUrl string
JamFeedUrl string
}
twitchEmbedUrl := ""
twitchStatus, err := db.QueryOne[models.TwitchLatestStatus](c, c.Conn,
`
SELECT $columns
FROM twitch_latest_status
WHERE twitch_login = $1
`,
"handmadenetwork",
)
if err == nil {
if twitchStatus.Live {
hmnUrl, err := url.Parse(config.Config.BaseUrl)
if err == nil {
twitchEmbedUrl = fmt.Sprintf("https://player.twitch.tv/?channel=%s&parent=%s", twitchStatus.TwitchLogin, hmnUrl.Hostname())
}
}
}
tmpl := JamPageData{
BaseData: baseData,
UserAvatarUrl: templates.UserAvatarDefaultUrl("dark"),
DaysUntilStart: daysUntilStart,
DaysUntilEnd: daysUntilEnd,
ProjectSubmissionUrl: hmnurl.BuildProjectNewJam(),
SubmittedProjectUrl: "",
JamFeedUrl: hmnurl.BuildJamFeed2024_Learning(),
TwitchEmbedUrl: twitchEmbedUrl,
}
if c.CurrentUser != nil {
tmpl.UserAvatarUrl = templates.UserAvatarUrl(c.CurrentUser, "dark")
projects, err := hmndata.FetchProjects(c, c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
OwnerIDs: []int{c.CurrentUser.ID},
JamSlugs: []string{hmndata.WRJ2023.Slug},
Limit: 1,
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch jam projects for current user"))
}
if len(projects) > 0 {
urlContext := hmndata.UrlContextForProject(&projects[0].Project)
tmpl.SubmittedProjectUrl = urlContext.BuildHomepage()
}
}
res.MustWriteTemplate("jam_2024_lj_index.html", tmpl, c.Perf)
return res
}
func JamFeed2024_Learning(c *RequestContext) ResponseData {
jamProjects, err := hmndata.FetchProjects(c, c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
JamSlugs: []string{hmndata.LJ2024.Slug},
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch jam projects for current user"))
}
projectIds := make([]int, 0, len(jamProjects))
for _, jp := range jamProjects {
projectIds = append(projectIds, jp.Project.ID)
}
var timelineItems []templates.TimelineItem
if len(projectIds) > 0 {
snippets, err := hmndata.FetchSnippets(c, c.Conn, c.CurrentUser, hmndata.SnippetQuery{
ProjectIDs: projectIds,
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets for jam showcase"))
}
timelineItems = make([]templates.TimelineItem, 0, len(snippets))
for _, s := range snippets {
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Projects, s.Owner, c.Theme, false)
timelineItem.SmallInfo = true
timelineItems = append(timelineItems, timelineItem)
}
}
type JamFeedData struct {
templates.BaseData
DaysUntilStart, DaysUntilEnd int
TimelineItems []templates.TimelineItem
}
daysUntilStart := daysUntil(hmndata.LJ2024.StartTime)
daysUntilEnd := daysUntil(hmndata.LJ2024.EndTime)
baseData := getBaseDataAutocrumb(c, hmndata.LJ2024.Name)
baseData.OpenGraphItems = []templates.OpenGraphItem{
{Property: "og:site_name", Value: "Handmade Network"},
{Property: "og:type", Value: "website"},
{Property: "og:image", Value: hmnurl.BuildPublic("learningjam2024/opengraph.png", true)},
{Property: "og:description", Value: "Need desc"},
{Property: "og:url", Value: hmnurl.BuildJamFeed2024_Learning()},
}
var res ResponseData
res.MustWriteTemplate("jam_2024_lj_feed.html", JamFeedData{
BaseData: baseData,
DaysUntilStart: daysUntilStart,
DaysUntilEnd: daysUntilEnd,
TimelineItems: timelineItems,
// ProjectSubmissionUrl: hmnurl.BuildProjectNewJam(),
// SubmittedProjectUrl: submittedProjectUrl,
// JamProjects: pageProjects,
}, c.Perf)
return res
}

View File

@ -156,9 +156,9 @@ func Index(c *RequestContext) ResponseData {
AtomFeedUrl: hmnurl.BuildAtomFeed(),
MarkAllReadUrl: hmnurl.HMNProjectContext.BuildForumMarkRead(0),
JamUrl: hmnurl.BuildJamIndex2023(),
JamDaysUntilStart: daysUntil(hmndata.WRJ2023.StartTime),
JamDaysUntilEnd: daysUntil(hmndata.WRJ2023.EndTime),
JamUrl: hmnurl.BuildJamIndex2024_Learning(),
JamDaysUntilStart: daysUntil(hmndata.LJ2024.StartTime),
JamDaysUntilEnd: daysUntil(hmndata.LJ2024.EndTime),
HMSDaysUntilStart: daysUntil(hmndata.HMS2023.StartTime),
HMSDaysUntilEnd: daysUntil(hmndata.HMS2023.EndTime),

View File

@ -61,7 +61,7 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler {
hmnOnly.GET(hmnurl.RegexJamsIndex, JamsIndex)
hmnOnly.GET(hmnurl.RegexJamIndex, func(c *RequestContext) ResponseData {
return c.Redirect(hmnurl.BuildJamIndex2023(), http.StatusFound)
return c.Redirect(hmnurl.BuildJamIndex2024_Learning(), http.StatusFound)
})
hmnOnly.GET(hmnurl.RegexJamIndex2021, JamIndex2021)
hmnOnly.GET(hmnurl.RegexJamIndex2022, JamIndex2022)
@ -71,6 +71,8 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler {
hmnOnly.GET(hmnurl.RegexJamRecap2023_Visibility, JamRecap2023_Visibility)
hmnOnly.GET(hmnurl.RegexJamIndex2023, JamIndex2023)
hmnOnly.GET(hmnurl.RegexJamFeed2023, JamFeed2023)
hmnOnly.GET(hmnurl.RegexJamIndex2024_Learning, JamIndex2024_Learning)
hmnOnly.GET(hmnurl.RegexJamFeed2024_Learning, JamFeed2024_Learning)
hmnOnly.GET(hmnurl.RegexTimeMachine, TimeMachine)
hmnOnly.GET(hmnurl.RegexTimeMachineSubmissions, TimeMachineSubmissions)
@ -79,6 +81,9 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler {
hmnOnly.GET(hmnurl.RegexTimeMachineFormDone, needsAuth(TimeMachineFormDone))
hmnOnly.POST(hmnurl.RegexTimeMachineForm, needsAuth(csrfMiddleware(TimeMachineFormSubmit)))
hmnOnly.GET(hmnurl.RegexCalendarIndex, CalendarIndex)
hmnOnly.GET(hmnurl.RegexCalendarICal, CalendarICal)
hmnOnly.GET(hmnurl.RegexStaffRolesIndex, StaffRolesIndex)
hmnOnly.GET(hmnurl.RegexStaffRole, StaffRole)

View File

@ -12,6 +12,7 @@ import (
"git.handmade.network/hmn/hmn/src/assets"
"git.handmade.network/hmn/hmn/src/auth"
"git.handmade.network/hmn/hmn/src/calendar"
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/discord"
@ -52,6 +53,7 @@ var WebsiteCommand = &cobra.Command{
twitch.MonitorTwitchSubscriptions(backgroundJobContext, conn),
hmns3.StartServer(backgroundJobContext),
assets.BackgroundPreviewGeneration(backgroundJobContext, conn),
calendar.MonitorCalendars(backgroundJobContext),
)
signals := make(chan os.Signal, 1)