From 1477d1a257548d31b93a186e38508de424616dcb Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Sat, 26 Sep 2026 19:40:39 -0500 Subject: [PATCH 1/7] Beta week home page --- src/hmndata/jams.go | 31 ++++++- src/hmnurl/urls.go | 18 ++-- src/templates/src/betaweek_2026_index.html | 42 +++++++++ .../src/betaweek_2026_submit_project.html | 5 ++ .../src/include/betaweek_2026_sections.html | 86 +++++++++++++++++++ src/website/jam.go | 33 ++++++- src/website/routes.go | 6 +- 7 files changed, 207 insertions(+), 14 deletions(-) create mode 100644 src/templates/src/betaweek_2026_index.html create mode 100644 src/templates/src/betaweek_2026_submit_project.html create mode 100644 src/templates/src/include/betaweek_2026_sections.html diff --git a/src/hmndata/jams.go b/src/hmndata/jams.go index 2d6aff58..9905236f 100644 --- a/src/hmndata/jams.go +++ b/src/hmndata/jams.go @@ -24,6 +24,9 @@ type Jam struct { DiscordRoleIDs map[config.Environment]string NagTime time.Time + + IsBetaWeek bool // HACK(ben): Different routing for beta week + AdminApprovalOnly bool // NOTE(ben): Only admins will be able to tick the box to associate projects with this event } var WRJ2021 = Jam{ @@ -152,6 +155,31 @@ var Essentials2026 = Jam{ NagTime: time.Date(2026, 4, 11, 14, 0, 0, 0, time.UTC), } +// NOTE(ben): For now we are considering Beta Week a Jam because mostly we +// expect it to slot into many existing website systems related to jams. We +// will probably rephrase things eventually but that will probably not be a +// huge structural change. +var BetaWeek2026 = Jam{ + Event: Event{ + StartTime: time.Date(2026, 11, 16, 13, 0, 0, 0, time.UTC), + EndTime: time.Date(2026, 11, 23, 5, 0, 0, 0, time.UTC), + Name: "Beta Week", + Description: "A focused week of testing Handmade software. November 16-22, 2026.", + Slug: "BetaWeek2026", + // NOTE(ben): No URL slug for now, since it's not clear what the patterns will be. + // The URL is /betaweek/2026, for now. + }, + + TemplateName: "2026_betaweek", + ForceDark: true, + + // TODO(betaweek): Discord roles? + // TODO(betaweek): Nag time? + + IsBetaWeek: true, + AdminApprovalOnly: true, +} + var AllJams = []Jam{ WRJ2021, WRJ2022, @@ -163,9 +191,10 @@ var AllJams = []Jam{ XRay2025, WRJ2025, Essentials2026, + BetaWeek2026, } -var LatestJam = Essentials2026 // NOTE(asaf): The /jam route will redirect here +var LatestJam = BetaWeek2026 // NOTE(ben): This is used for Discord integration, e.g. slash command and #jam channel activity func CurrentJam() *Jam { now := time.Now() diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index 06e2c0c7..54c1f83b 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -32,12 +32,6 @@ func BuildJamsIndex() string { return Url("/jams", nil) } -var RegexJamIndex = regexp.MustCompile("^/jam$") - -func BuildJamIndex() string { - return Url("/jam", nil) -} - var RegexJamIndex2021 = regexp.MustCompile("^/jam/2021$") func BuildJamIndex2021() string { @@ -104,6 +98,18 @@ func BuildJamGenericGuidelines(urlSlug string) string { return Url(fmt.Sprintf("/jam/%s/guidelines", urlSlug), nil) } +var RegexBetaWeekIndex = regexp.MustCompile("^/betaweek/2026$") + +func BuildBetaWeekIndex() string { + return Url("/betaweek/2026", nil) +} + +var RegexBetaWeekCallForProjects = regexp.MustCompile("^/betaweek/2026/submit-project$") + +func BuildBetaWeekCallForProjects() string { + return Url("/betaweek/2026/submit-project", nil) +} + var RegexExpo = regexp.MustCompile("^/expo/(?P[^/]+)$") func BuildExpo(urlSlug string, status string) string { diff --git a/src/templates/src/betaweek_2026_index.html b/src/templates/src/betaweek_2026_index.html new file mode 100644 index 00000000..48721580 --- /dev/null +++ b/src/templates/src/betaweek_2026_index.html @@ -0,0 +1,42 @@ +{{ template "base-2024.html" . }} + +{{ define "extrahead" }} + {{ template "betaweek2026-styles" . }} +{{ end }} + +{{ define "content-top" }} + {{ template "betaweek2026-bannerbig" . }} +{{ end }} + +{{ define "content" }} +
+ +
+
+ +
+{{ end }} diff --git a/src/templates/src/betaweek_2026_submit_project.html b/src/templates/src/betaweek_2026_submit_project.html new file mode 100644 index 00000000..69a02178 --- /dev/null +++ b/src/templates/src/betaweek_2026_submit_project.html @@ -0,0 +1,5 @@ +{{ template "base-2024.html" . }} + +{{ define "content" }} +SUBMIT +{{ end }} diff --git a/src/templates/src/include/betaweek_2026_sections.html b/src/templates/src/include/betaweek_2026_sections.html new file mode 100644 index 00000000..58f6ecb7 --- /dev/null +++ b/src/templates/src/include/betaweek_2026_sections.html @@ -0,0 +1,86 @@ +{{ define "betaweek2026-styles" }} + + +{{ end }} + +{{ define "betaweek2026-bannerbig" }} +
+ +
+{{ end }} \ No newline at end of file diff --git a/src/website/jam.go b/src/website/jam.go index 16608b08..35ba5d14 100644 --- a/src/website/jam.go +++ b/src/website/jam.go @@ -218,7 +218,7 @@ func JamGenericIndex(c *RequestContext) ResponseData { urlSlug := strings.ToLower(c.PathParams["urlslug"]) jam, found := findJamByUrlSlug(urlSlug) - if !found { + if !found || jam.IsBetaWeek { // HACK(ben): Just...don't let the beta week slip in here return FourOhFour(c) } @@ -239,7 +239,7 @@ func JamGenericFeed(c *RequestContext) ResponseData { urlSlug := strings.ToLower(c.PathParams["urlslug"]) jam, found := findJamByUrlSlug(urlSlug) - if !found { + if !found || jam.IsBetaWeek { // HACK(ben) return FourOhFour(c) } @@ -263,7 +263,7 @@ func JamGenericGuidelines(c *RequestContext) ResponseData { urlSlug := strings.ToLower(c.PathParams["urlslug"]) jam, found := findJamByUrlSlug(urlSlug) - if !found { + if !found || jam.IsBetaWeek { // HACK(ben) return FourOhFour(c) } @@ -281,6 +281,33 @@ func JamGenericGuidelines(c *RequestContext) ResponseData { return res } +func BetaWeekIndex(c *RequestContext) ResponseData { + var res ResponseData + + type Tmpl struct { + templates.BaseData + IndexUrl string + CallForProjectsUrl string + } + tmpl := Tmpl{ + BaseData: getBaseTemplateData(c, "Submit a Project", nil), + IndexUrl: hmnurl.BuildBetaWeekIndex(), + CallForProjectsUrl: hmnurl.BuildBetaWeekCallForProjects(), + } + + res.MustWriteTemplate("betaweek_2026_index.html", tmpl, c.Perf) + return res +} + +func BetaWeekCallForProjects(c *RequestContext) ResponseData { + var res ResponseData + + tmpl := getBaseTemplateData(c, "Submit a Project", nil) + + res.MustWriteTemplate("betaweek_2026_submit_project.html", tmpl, c.Perf) + return res +} + func getTwitchEmbedUrl(c *RequestContext) string { twitchEmbedUrl := "" twitchStatus, err := db.QueryOne[models.TwitchLatestStatus](c, c.Conn, diff --git a/src/website/routes.go b/src/website/routes.go index d459d52c..d869c177 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -120,9 +120,6 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt }) hmnOnly.GET(hmnurl.RegexJamsIndex, JamsIndex) - hmnOnly.GET(hmnurl.RegexJamIndex, func(c *RequestContext) ResponseData { - return c.Redirect(hmnurl.BuildJamGenericIndex(hmndata.LatestJam.UrlSlug), http.StatusFound) - }) hmnOnly.GET(hmnurl.RegexJamIndex2021, JamIndex2021) hmnOnly.GET(hmnurl.RegexJamIndex2022, JamIndex2022) hmnOnly.GET(hmnurl.RegexJamFeed2022, JamFeed2022) @@ -131,10 +128,11 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt hmnOnly.GET(hmnurl.RegexJamRecap2023_Visibility, JamRecap2023_Visibility) hmnOnly.GET(hmnurl.RegexJamIndex2023, JamIndex2023) hmnOnly.GET(hmnurl.RegexJamFeed2023, JamFeed2023) - hmnOnly.GET(hmnurl.RegexJamGenericIndex, JamGenericIndex) hmnOnly.GET(hmnurl.RegexJamGenericFeed, JamGenericFeed) hmnOnly.GET(hmnurl.RegexJamGenericGuidelines, JamGenericGuidelines) + hmnOnly.GET(hmnurl.RegexBetaWeekIndex, BetaWeekIndex) + hmnOnly.GET(hmnurl.RegexBetaWeekCallForProjects, BetaWeekCallForProjects) hmnOnly.GET(hmnurl.RegexExpo, ExpoIndex) hmnOnly.GET(hmnurl.RegexExpoTicketPurchaseSuccess, needsAuth(ExpoTicketPurchaseSuccess)) From d2cfd228b1189e8bc12c43f128cd7298f4b2057c Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Sun, 27 Sep 2026 12:28:54 -0500 Subject: [PATCH 2/7] CFP form frontend --- README.md | 2 +- go.mod | 2 +- public/js/betaweek_2026_submit_project.js | 69 ++++++++++++++++ src/hmnurl/hmnurl.go | 24 +++++- src/hmnurl/urls.go | 6 ++ .../js/betaweek_2026_submit_project.ts | 61 ++++++++++++++ src/rawdata/js/lib/apitypes.ts | 7 ++ src/rawdata/js/lib/models.ts | 24 ++++++ .../src/betaweek_2026_submit_project.html | 79 ++++++++++++++++++- .../src/standalone_project_card.html | 1 + src/templates/templates.go | 17 ++++ src/templates/types.go | 52 ++++++------ src/website/api.go | 64 +++++++++++++-- src/website/base_data.go | 2 +- src/website/jam.go | 9 ++- src/website/requesthandling.go | 12 +++ src/website/routes.go | 3 +- 17 files changed, 395 insertions(+), 39 deletions(-) create mode 100644 public/js/betaweek_2026_submit_project.js create mode 100644 src/rawdata/js/betaweek_2026_submit_project.ts create mode 100644 src/templates/src/standalone_project_card.html diff --git a/README.md b/README.md index 4d4f26da..f7653ca5 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ We want the website to be a great example of Handmade software on the web. We en You will need the following software installed: -- Go 1.26 or newer: https://go.dev/ +- Go 1.27 or newer: https://go.dev/ You can download Go directly from the website, or install it through major package managers. If you already have Go installed, but are unsure of the version, you can check by running `go version`. diff --git a/go.mod b/go.mod index 7fb0a151..195fae62 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module git.handmade.network/hmn/hmn -go 1.26 +go 1.27 require ( github.com/HandmadeNetwork/golorem v0.0.0-20220507185207-414965a3a817 diff --git a/public/js/betaweek_2026_submit_project.js b/public/js/betaweek_2026_submit_project.js new file mode 100644 index 00000000..fe460954 --- /dev/null +++ b/public/js/betaweek_2026_submit_project.js @@ -0,0 +1,69 @@ +// src/rawdata/js/lib/utils.ts +function assert(cond, msg, soft = false) { + if (!cond) { + if (soft) { + console.error(msg ?? "Assertion failed"); + } else { + throw new Error(msg ?? "Assertion failed"); + } + } +} +function must(val, msg) { + assert(val, msg); + return val; +} + +// src/rawdata/js/betaweek_2026_submit_project.ts +var projectUrlField = must(document.querySelector("#project-url")); +var projectIDField = must(document.querySelector("#project-id")); +var projectPreviewContainer = must(document.querySelector("#project-preview")); +function init({ + currentUserID, + fetchProjectUrl +}) { + function clearProject() { + projectPreviewContainer.hidden = true; + projectPreviewContainer.innerHTML = ""; + } + async function loadProject() { + const projectUrl = projectUrlField.value.trim(); + if (!projectUrl) { + return; + } + const res = await fetch(fetchProjectUrl, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + url: projectUrl + }) + }); + if (res.status == 404) { + projectPreviewContainer.hidden = false; + projectPreviewContainer.innerText = "No project found."; + return; + } else if (res.status >= 400) { + console.error(res); + throw new Error("bad request"); + } + const project = await res.json(); + if (!project.project.owners?.find((u) => u.id === currentUserID)) { + projectPreviewContainer.hidden = false; + projectPreviewContainer.innerText = "You are not one of the owners of that project."; + return; + } + projectIDField.value = `${project.project.id}`; + projectPreviewContainer.hidden = false; + projectPreviewContainer.innerHTML = project.card; + projectPreviewContainer.querySelector("a").target = "_blank"; + } + ; + projectUrlField.addEventListener("input", clearProject); + projectUrlField.addEventListener("change", loadProject); + loadProject(); +} +export { + init +}; +//# sourceMappingURL=betaweek_2026_submit_project.js.map diff --git a/src/hmnurl/hmnurl.go b/src/hmnurl/hmnurl.go index 023a9468..e31eb2d8 100644 --- a/src/hmnurl/hmnurl.go +++ b/src/hmnurl/hmnurl.go @@ -7,6 +7,7 @@ import ( "strings" "git.handmade.network/hmn/hmn/src/models" + "git.handmade.network/hmn/hmn/src/utils" // NOTE(ben): Avoid importing oops to keep wasm builds smaller (thanks zerolog) ) @@ -101,11 +102,32 @@ func NormalizePath(path string) string { return res } -func URLMatchesRoute(url *url.URL, route regexp.Regexp) bool { +// NOTE(ben): Does NOT check the host, only the path. +func URLPathMatchesRoute(url *url.URL, route *regexp.Regexp) bool { path := NormalizePath(url.Path) return route.MatchString(path) } +// NOTE(ben): Mirrors what routing does and matches a route regex, extracting +// and returning the URL params. Panics if the route does not match. Like +// [URLPathMatchesRoute], does not check the host. +func MatchURLPathAgainstRoute(url *url.URL, route *regexp.Regexp) map[string]string { + path := NormalizePath(url.Path) + match := route.FindStringSubmatch(path) + utils.Assert(len(match) > 0) + + res := make(map[string]string) + subexpNames := route.SubexpNames() + for i, param := range match { + paramName := subexpNames[i] + if paramName == "" { + continue + } + res[paramName] = param + } + return res +} + // Takes a project URL and rewrites it using the current URL context. This can be used // to convert a personal project URL to official and vice versa. func (c *UrlContext) RewriteProjectUrl(u *url.URL) string { diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index 54c1f83b..d4f0fcb4 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -954,6 +954,12 @@ func BuildAPINewsletterSignup() string { return Url("/api/newsletter_signup", nil) } +var RegexAPIProject = regexp.MustCompile("^/api/project$") + +func BuildAPIProject() string { + return Url("/api/project", nil) +} + /* * Twitch stuff */ diff --git a/src/rawdata/js/betaweek_2026_submit_project.ts b/src/rawdata/js/betaweek_2026_submit_project.ts new file mode 100644 index 00000000..704bee46 --- /dev/null +++ b/src/rawdata/js/betaweek_2026_submit_project.ts @@ -0,0 +1,61 @@ +import { ProjectResult } from "./lib/apitypes"; +import { must } from "./lib/utils"; + +const projectUrlField = must(document.querySelector("#project-url")); +const projectIDField = must(document.querySelector("#project-id")); +const projectPreviewContainer = must(document.querySelector("#project-preview")); + +export type Config = { + currentUserID: number, + fetchProjectUrl: string, +}; + +export function init({ + currentUserID, + fetchProjectUrl, +}: Config) { + function clearProject() { + projectPreviewContainer.hidden = true; + projectPreviewContainer.innerHTML = ""; + } + async function loadProject() { + const projectUrl = projectUrlField.value.trim(); + if (!projectUrl) { + return; + } + + const res = await fetch(fetchProjectUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + url: projectUrl, + }), + }); + if (res.status == 404) { + projectPreviewContainer.hidden = false; + projectPreviewContainer.innerText = "No project found." + return; + } else if (res.status >= 400) { + console.error(res); + throw new Error("bad request"); + } + + const project: ProjectResult = await res.json(); + if (!project.project.owners?.find(u => u.id === currentUserID)) { + projectPreviewContainer.hidden = false; + projectPreviewContainer.innerText = "You are not one of the owners of that project."; + return; + } + + projectIDField.value = `${project.project.id}`; + projectPreviewContainer.hidden = false; + projectPreviewContainer.innerHTML = project.card; + projectPreviewContainer.querySelector("a")!.target = "_blank"; + }; + + projectUrlField.addEventListener("input", clearProject); + projectUrlField.addEventListener("change", loadProject); + loadProject(); +} diff --git a/src/rawdata/js/lib/apitypes.ts b/src/rawdata/js/lib/apitypes.ts index 86996d11..ef6cd91d 100644 --- a/src/rawdata/js/lib/apitypes.ts +++ b/src/rawdata/js/lib/apitypes.ts @@ -1,9 +1,16 @@ // NOTE(ben): Types for data returned by "API" endpoints, i.e. stuff called // from the frontend. +import { Project } from "./models"; + export type CheckUsernameResult = { found: false } | { found: true, username: string, name: string, avatarUrl: string, }; + +export type ProjectResult = { + project: Project, + card: string, +}; diff --git a/src/rawdata/js/lib/models.ts b/src/rawdata/js/lib/models.ts index 1ab5a3f2..5ad7eeb9 100644 --- a/src/rawdata/js/lib/models.ts +++ b/src/rawdata/js/lib/models.ts @@ -16,6 +16,30 @@ export type Icon = { svg: string, }; +export type Flowsnake = { + angle: number, + hue: number, + size: number, +} + +export type Project = { + id: number, + name: string, + subdomain: string, + color1: string, + color2: string, + url: string, + blurb: string, + description: string, + ai_policy: string, + owners: User[] | null, + logo: string, + flowsnake: Flowsnake, + lifecycle: string, + has_blog: boolean, + has_forum: boolean, +}; + export type SnippetEditorConfig = { assetMaxSize: number, availableProjects: SnippetEditAvailableProject[], diff --git a/src/templates/src/betaweek_2026_submit_project.html b/src/templates/src/betaweek_2026_submit_project.html index 69a02178..cdde3468 100644 --- a/src/templates/src/betaweek_2026_submit_project.html +++ b/src/templates/src/betaweek_2026_submit_project.html @@ -1,5 +1,82 @@ {{ template "base-2024.html" . }} +{{ define "extrahead" }} + +{{ end }} + {{ define "content" }} -SUBMIT +
+
+

Handmade Network Beta Week 2026: Call For Projects

+
+

+ If you have a project that you'd like the community to test during the 2026 Beta Week, please fill out this form. Submissions close on Monday, October 26, 2026. Your application must be approved in order for your project to be listed for community members to test. +

+

+ Beta Week will run from November 16 to November 22, 2026. +

+
+
+ Project details + +
+
+ + +
Paste the link to your HMN project here. Project info will be fetched automatically.
+
+ + + + +
+ + +
Please describe the current status of the project (how long you've been working on it, how complete it is, etc.)
+
+ +
+ + +
Which platforms does your project support? What will testers need to do to set it up?
+
+ +
+ + +
What should testers know before testing? Are there features that need specific attention or that should be avoided?
+
+
+
+
+ Personal details + +
+
+ + +
Please describe which days you will be available during Beta Week.
+
+
+ + +
What are you hoping to achieve with your project during the Beta Week? (It will help us structure and organize the event around the needs of project authors.)
+
+
+
+ + +
+
+
+
+ +
+
{{ end }} diff --git a/src/templates/src/standalone_project_card.html b/src/templates/src/standalone_project_card.html new file mode 100644 index 00000000..03ce37b2 --- /dev/null +++ b/src/templates/src/standalone_project_card.html @@ -0,0 +1 @@ +{{ template "project_card.html" . }} diff --git a/src/templates/templates.go b/src/templates/templates.go index 22bb4725..db630498 100644 --- a/src/templates/templates.go +++ b/src/templates/templates.go @@ -1,6 +1,7 @@ package templates import ( + "bytes" "embed" "encoding/base64" "encoding/json" @@ -116,6 +117,22 @@ func GetTemplate(name string) (*template.Template, bool) { return template, hasTemplate } +// NOTE(ben): Panics if the template is not found. Returns an error only for +// errors that occur during template execution. +func ExecuteTemplateByName(name string, data any) ([]byte, error) { + template, hasTemplate := GetTemplate(name) + if !hasTemplate { + panic(fmt.Sprintf("no template with name %s", name)) + } + + var w bytes.Buffer + err := template.Execute(&w, data) + if err != nil { + return nil, err + } + return w.Bytes(), nil +} + func hasSuffix(s string, suffixes ...string) bool { for _, suffix := range suffixes { if strings.HasSuffix(s, suffix) { diff --git a/src/templates/types.go b/src/templates/types.go index d6081c2e..180d3a83 100644 --- a/src/templates/types.go +++ b/src/templates/types.go @@ -160,39 +160,39 @@ type BlogIndexEntry struct { } type Project struct { - ID int - Name string - Subdomain string - Color1 string - Color2 string - Url string - Blurb string - ParsedDescription template.HTML - ParsedAIPolicy template.HTML - Owners []User + ID int `json:"id"` + Name string `json:"name"` + Subdomain string `json:"subdomain"` + Color1 string `json:"color1"` + Color2 string `json:"color2"` + Url string `json:"url"` + Blurb string `json:"blurb"` + ParsedDescription template.HTML `json:"description"` + ParsedAIPolicy template.HTML `json:"ai_policy"` + Owners []User `json:"owners"` - Logo string - Flowsnake Flowsnake + Logo string `json:"logo"` + Flowsnake Flowsnake `json:"flowsnake"` // TODO(ben): Delete or rename this, please - LifecycleBadgeClass string - LifecycleString string + LifecycleBadgeClass string `json:"-"` + LifecycleString string `json:"lifecycle"` - IsHMN bool + IsHMN bool `json:"-"` - HasBlog bool - HasForum bool + HasBlog bool `json:"has_blog"` + HasForum bool `json:"has_forum"` - UUID string - DateApproved time.Time + UUID string `json:"-"` + DateApproved time.Time `json:"-"` - Gallery bool - GallerySort int - GalleryDesc string - GalleryImage *Asset + Gallery bool `json:"-"` + GallerySort int `json:"-"` + GalleryDesc string `json:"-"` + GalleryImage *Asset `json:"-"` // NOTE(ben): Legacy; now we just have screenshots. We are keeping this // around so project authors can retrieve their old header images. - HeaderImage string + HeaderImage string `json:"-"` } type ProjectSettings struct { @@ -224,7 +224,9 @@ type ProjectSettings struct { } type Flowsnake struct { - Angle, Hue, Size int + Angle int `json:"angle"` + Hue int `json:"hue"` + Size int `json:"size"` } type Hilbert struct { diff --git a/src/website/api.go b/src/website/api.go index 09bd3af9..407a6148 100644 --- a/src/website/api.go +++ b/src/website/api.go @@ -1,14 +1,15 @@ package website import ( - "encoding/json" "errors" - "io" "net/http" + "net/url" + "strconv" "strings" "git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/hmndata" + "git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/templates" @@ -46,12 +47,9 @@ func APICheckUsername(c *RequestContext) ResponseData { } func APINewsletterSignup(c *RequestContext) ResponseData { - bodyBytes := utils.Must1(io.ReadAll(c.Req.Body)) - type Input struct { + input, err := c.GetJSON[struct { Email string `json:"email"` - } - var input Input - err := json.Unmarshal(bodyBytes, &input) + }]() if err != nil { return c.ErrorResponse(http.StatusBadRequest, err) } @@ -87,3 +85,55 @@ func APINewsletterSignup(c *RequestContext) ResponseData { res.WriteHeader(http.StatusNoContent) return res } + +func APIProject(c *RequestContext) ResponseData { + input, err := c.GetJSON[struct { + Url string `json:"url"` + }]() + if err != nil { + return c.ErrorResponse(http.StatusBadRequest, err) + } + + if !hmnurl.UrlIsLocal(input.Url) { + return FourOhFour(c) + } + parsedUrl, err := url.Parse(input.Url) + if err != nil { + return c.ErrorResponse(http.StatusBadRequest, err) + } + + var project hmndata.ProjectAndStuff + if slug := hmnurl.GetOfficialProjectSlugFromHost(parsedUrl.Host); slug != "" { + // NOTE(ben): Official project + project, err = hmndata.FetchProjectBySlug(c, c.Conn, c.CurrentUser, slug, hmndata.ProjectsQuery{ + Lifecycles: models.AllProjectLifecycles, + IncludeHidden: true, // NOTE(ben): Still filtered by user visibility rules + }) + } else if hmnurl.URLPathMatchesRoute(parsedUrl, hmnurl.RegexPersonalProject) { + projectIDStr := hmnurl.MatchURLPathAgainstRoute(parsedUrl, hmnurl.RegexPersonalProject)["projectid"] + projectID := utils.Must1(strconv.Atoi(projectIDStr)) + project, err = hmndata.FetchProject(c, c.Conn, c.CurrentUser, projectID, hmndata.ProjectsQuery{ + Lifecycles: models.AllProjectLifecycles, + IncludeHidden: true, + }) + } else { + return FourOhFour(c) + } + if errors.Is(err, db.NotFound) { + return FourOhFour(c) + } else if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project through API")) + } + + type Res struct { + Project templates.Project `json:"project"` + Card string `json:"card"` + } + p := templates.ProjectAndStuffToTemplate(&project) + card := utils.Must1(templates.ExecuteTemplateByName("standalone_project_card.html", p)) + res := Res{ + Project: p, + Card: string(card), + } + return c.JSONResponse(http.StatusOK, res) +} diff --git a/src/website/base_data.go b/src/website/base_data.go index 04701c60..5a848c1c 100644 --- a/src/website/base_data.go +++ b/src/website/base_data.go @@ -30,7 +30,7 @@ func getBaseTemplateData(c *RequestContext, title string, breadcrumbs []template notices := getNoticesFromCookie(c) var loginUrl string - if hmnurl.URLMatchesRoute(c.URL(), *hmnurl.RegexLoginPage) || hmnurl.URLMatchesRoute(c.URL(), *hmnurl.RegexRegister) { + if hmnurl.URLPathMatchesRoute(c.URL(), hmnurl.RegexLoginPage) || hmnurl.URLPathMatchesRoute(c.URL(), hmnurl.RegexRegister) { // NOTE(ben): If clicking the login button from the login or register page, don't try to redirect to the // login page after login :) loginUrl = hmnurl.BuildLoginPage(c.URL().Query().Get("destination"), c.URL().Query().Get("notice")) diff --git a/src/website/jam.go b/src/website/jam.go index 35ba5d14..72fe09f4 100644 --- a/src/website/jam.go +++ b/src/website/jam.go @@ -302,7 +302,14 @@ func BetaWeekIndex(c *RequestContext) ResponseData { func BetaWeekCallForProjects(c *RequestContext) ResponseData { var res ResponseData - tmpl := getBaseTemplateData(c, "Submit a Project", nil) + type Tmpl struct { + templates.BaseData + FetchProjectUrl string + } + tmpl := Tmpl{ + BaseData: getBaseTemplateData(c, "Submit a Project", nil), + FetchProjectUrl: hmnurl.BuildAPIProject(), + } res.MustWriteTemplate("betaweek_2026_submit_project.html", tmpl, c.Perf) return res diff --git a/src/website/requesthandling.go b/src/website/requesthandling.go index 46578b92..d8977694 100644 --- a/src/website/requesthandling.go +++ b/src/website/requesthandling.go @@ -26,6 +26,7 @@ import ( "git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/perf" "git.handmade.network/hmn/hmn/src/templates" + "git.handmade.network/hmn/hmn/src/utils" "github.com/jackc/pgx/v5/pgxpool" "github.com/rs/zerolog" ) @@ -393,6 +394,17 @@ func (c *RequestContext) ErrorResponse(status int, errs ...error) ResponseData { return res } +func (c *RequestContext) GetJSON[T any]() (T, error) { + bodyBytes := utils.Must1(io.ReadAll(c.Req.Body)) + var input T + err := json.Unmarshal(bodyBytes, &input) + if err != nil { + var zero T + return zero, err + } + return input, nil +} + func (c *RequestContext) JSONResponse(status int, data any) ResponseData { res := ResponseData{StatusCode: status} addCORSHeaders(c, &res) diff --git a/src/website/routes.go b/src/website/routes.go index d869c177..f7f77cd7 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -132,7 +132,7 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt hmnOnly.GET(hmnurl.RegexJamGenericFeed, JamGenericFeed) hmnOnly.GET(hmnurl.RegexJamGenericGuidelines, JamGenericGuidelines) hmnOnly.GET(hmnurl.RegexBetaWeekIndex, BetaWeekIndex) - hmnOnly.GET(hmnurl.RegexBetaWeekCallForProjects, BetaWeekCallForProjects) + hmnOnly.GET(hmnurl.RegexBetaWeekCallForProjects, needsAuth(BetaWeekCallForProjects)) hmnOnly.GET(hmnurl.RegexExpo, ExpoIndex) hmnOnly.GET(hmnurl.RegexExpoTicketPurchaseSuccess, needsAuth(ExpoTicketPurchaseSuccess)) @@ -253,6 +253,7 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt // NOTE(asaf): CheckUsername requires a logged-in user, so it can't use the apiRoutes middleware. hmnOnly.POST(hmnurl.RegexAPICheckUsername, needsAuth(csrfMiddleware(APICheckUsername))) apiRoutes.POST(hmnurl.RegexAPINewsletterSignup, APINewsletterSignup) + apiRoutes.POST(hmnurl.RegexAPIProject, APIProject) apiRoutes.POST(hmnurl.RegexStripeWebhook, StripeWebhook) hmnOnly.GET(hmnurl.RegexLibraryAny, func(c *RequestContext) ResponseData { From 852775e1e6ac1b011a6c45b6439d4a00b8503f3f Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Sun, 27 Sep 2026 13:05:04 -0500 Subject: [PATCH 3/7] CFP form backend --- src/hmnurl/urls.go | 6 ++ ...6-09-27T174819Z_BetaWeekCallForProjects.go | 55 ++++++++++++ .../src/betaweek_2026_submit_project.html | 2 +- .../betaweek_2026_submit_project_thanks.html | 11 +++ src/website/jam.go | 86 +++++++++++++++++++ src/website/routes.go | 2 + 6 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 src/migration/migrations/2026-09-27T174819Z_BetaWeekCallForProjects.go create mode 100644 src/templates/src/betaweek_2026_submit_project_thanks.html diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index d4f0fcb4..2194c8f6 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -110,6 +110,12 @@ func BuildBetaWeekCallForProjects() string { return Url("/betaweek/2026/submit-project", nil) } +var RegexBetaWeekCallForProjectsThanks = regexp.MustCompile("^/betaweek/2026/submit-project/thanks$") + +func BuildBetaWeekCallForProjectsThanks() string { + return Url("/betaweek/2026/submit-project/thanks", nil) +} + var RegexExpo = regexp.MustCompile("^/expo/(?P[^/]+)$") func BuildExpo(urlSlug string, status string) string { diff --git a/src/migration/migrations/2026-09-27T174819Z_BetaWeekCallForProjects.go b/src/migration/migrations/2026-09-27T174819Z_BetaWeekCallForProjects.go new file mode 100644 index 00000000..c6ccb4fd --- /dev/null +++ b/src/migration/migrations/2026-09-27T174819Z_BetaWeekCallForProjects.go @@ -0,0 +1,55 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "github.com/jackc/pgx/v5" +) + +func init() { + registerMigration(BetaWeekCallForProjects{}) +} + +type BetaWeekCallForProjects struct{} + +func (m BetaWeekCallForProjects) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2026, 9, 27, 17, 48, 19, 0, time.UTC)) +} + +func (m BetaWeekCallForProjects) Name() string { + return "BetaWeekCallForProjects" +} + +func (m BetaWeekCallForProjects) Description() string { + return "Add a table for beta week submissions" +} + +func (m BetaWeekCallForProjects) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, + ` + CREATE TABLE betaweek_project_submission ( + id SERIAL NOT NULL PRIMARY KEY, + event_slug VARCHAR(64) NOT NULL, + + project_id INT NOT NULL REFERENCES project, + project_status TEXT NOT NULL, + project_platforms TEXT NOT NULL, + project_instructions TEXT NOT NULL, + author_availability TEXT NOT NULL, + author_goals TEXT NOT NULL + ) + `, + ) + return err +} + +func (m BetaWeekCallForProjects) Down(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, + ` + DROP TABLE betaweek_project_submission; + `, + ) + return err +} diff --git a/src/templates/src/betaweek_2026_submit_project.html b/src/templates/src/betaweek_2026_submit_project.html index cdde3468..4713198f 100644 --- a/src/templates/src/betaweek_2026_submit_project.html +++ b/src/templates/src/betaweek_2026_submit_project.html @@ -12,7 +12,7 @@ {{ define "content" }}
-
+

Handmade Network Beta Week 2026: Call For Projects

diff --git a/src/templates/src/betaweek_2026_submit_project_thanks.html b/src/templates/src/betaweek_2026_submit_project_thanks.html new file mode 100644 index 00000000..ec18e41d --- /dev/null +++ b/src/templates/src/betaweek_2026_submit_project_thanks.html @@ -0,0 +1,11 @@ +{{ template "base-2024.html" . }} + +{{ define "content" }} +

+
+

Thanks!

+

We have received your submission and will be in touch.

+

Back to Beta Week

+
+
+{{ end }} diff --git a/src/website/jam.go b/src/website/jam.go index 72fe09f4..ae00e020 100644 --- a/src/website/jam.go +++ b/src/website/jam.go @@ -305,16 +305,102 @@ func BetaWeekCallForProjects(c *RequestContext) ResponseData { type Tmpl struct { templates.BaseData FetchProjectUrl string + SubmitUrl string } tmpl := Tmpl{ BaseData: getBaseTemplateData(c, "Submit a Project", nil), FetchProjectUrl: hmnurl.BuildAPIProject(), + SubmitUrl: hmnurl.BuildBetaWeekCallForProjects(), } res.MustWriteTemplate("betaweek_2026_submit_project.html", tmpl, c.Perf) return res } +func BetaWeekCallForProjectsSubmit(c *RequestContext) ResponseData { + err := c.Req.ParseForm() + if err != nil { + return c.ErrorResponse(http.StatusBadRequest, err) + } + + projectIDStr := c.Req.Form.Get("project-id") + projectID, err := strconv.Atoi(projectIDStr) + if err != nil { + return c.RejectRequest("bad project ID") + } + projectStatus := strings.TrimSpace(c.Req.Form.Get("project-status")) + projectPlatforms := strings.TrimSpace(c.Req.Form.Get("project-platforms")) + projectInstructions := strings.TrimSpace(c.Req.Form.Get("project-instructions")) + authorAvailability := strings.TrimSpace(c.Req.Form.Get("author-availability")) + authorGoals := strings.TrimSpace(c.Req.Form.Get("author-goals")) + authorAcknowledgment := c.Req.Form.Get("author-acknowledgment") + + projectOk := true + p, err := hmndata.FetchProject(c, c.Conn, c.CurrentUser, projectID, hmndata.ProjectsQuery{ + Lifecycles: models.AllProjectLifecycles, + IncludeHidden: true, + }) + if err == db.NotFound { + projectOk = false + } else if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project")) + } else { + validOwner := false + for _, owner := range p.Owners { + if owner.ID == c.CurrentUser.ID { + validOwner = true + } + } + if !validOwner { + projectOk = false + } + } + if !projectOk { + return c.RejectRequest("Either that project does not exist, or you do not have access to it.") + } + + if projectStatus == "" || projectPlatforms == "" || projectInstructions == "" || authorAvailability == "" || authorGoals == "" || authorAcknowledgment == "" { + return c.RejectRequest("Some required fields are missing.") + } + + _, err = c.Conn.Exec(c, + ` + INSERT INTO betaweek_project_submission ( + event_slug, + project_id, project_status, project_platforms, project_instructions, + author_availability, author_goals + ) VALUES ( + $1, + $2, $3, $4, $5, + $6, $7 + ) + `, + hmndata.BetaWeek2026.Slug, + projectID, projectStatus, projectPlatforms, projectInstructions, + authorAvailability, authorGoals, + ) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to save submission")) + } + + return c.Redirect(hmnurl.BuildBetaWeekCallForProjectsThanks(), http.StatusSeeOther) +} + +func BetaWeekCallForProjectsThanks(c *RequestContext) ResponseData { + var res ResponseData + + type Tmpl struct { + templates.BaseData + BetaWeekUrl string + } + tmpl := Tmpl{ + BaseData: getBaseTemplateData(c, "Submit a Project | Thanks", nil), + BetaWeekUrl: hmnurl.BuildBetaWeekIndex(), + } + res.MustWriteTemplate("betaweek_2026_submit_project_thanks.html", tmpl, c.Perf) + return res +} + func getTwitchEmbedUrl(c *RequestContext) string { twitchEmbedUrl := "" twitchStatus, err := db.QueryOne[models.TwitchLatestStatus](c, c.Conn, diff --git a/src/website/routes.go b/src/website/routes.go index f7f77cd7..a1089991 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -133,6 +133,8 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt hmnOnly.GET(hmnurl.RegexJamGenericGuidelines, JamGenericGuidelines) hmnOnly.GET(hmnurl.RegexBetaWeekIndex, BetaWeekIndex) hmnOnly.GET(hmnurl.RegexBetaWeekCallForProjects, needsAuth(BetaWeekCallForProjects)) + hmnOnly.POST(hmnurl.RegexBetaWeekCallForProjects, needsAuth(BetaWeekCallForProjectsSubmit)) + hmnOnly.GET(hmnurl.RegexBetaWeekCallForProjectsThanks, needsAuth(BetaWeekCallForProjectsThanks)) hmnOnly.GET(hmnurl.RegexExpo, ExpoIndex) hmnOnly.GET(hmnurl.RegexExpoTicketPurchaseSuccess, needsAuth(ExpoTicketPurchaseSuccess)) From 1facc7535c972625ef77961259692c262c5a83a5 Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Sun, 27 Sep 2026 18:22:37 -0500 Subject: [PATCH 4/7] Banner / initial opengraph --- src/discord/commands.go | 4 ++-- src/hmndata/events.go | 8 +++++++- src/hmndata/jams.go | 19 +++++++++++++++++-- src/templates/mapping.go | 2 +- src/templates/src/include/header-2024.html | 9 +++++++++ src/utils/utils.go | 7 ------- src/website/base_data.go | 11 ++++++++--- src/website/jam.go | 22 +++++++++++++++++++--- 8 files changed, 63 insertions(+), 19 deletions(-) diff --git a/src/discord/commands.go b/src/discord/commands.go index a9ae6a98..6bf41e29 100644 --- a/src/discord/commands.go +++ b/src/discord/commands.go @@ -67,7 +67,7 @@ func (bot *botInstance) createApplicationCommands(ctx context.Context) { Type: ApplicationCommandTypeChatInput, Name: SlashCommandHMHReplay, Description: "Join the Handmade Hero Replay", - DMPermission: utils.P(false), + DMPermission: new(false), Options: []ApplicationCommandOption{ { Type: ApplicationCommandOptionTypeBoolean, @@ -82,7 +82,7 @@ func (bot *botInstance) createApplicationCommands(ctx context.Context) { Type: ApplicationCommandTypeChatInput, Name: SlashCommandJoinJam, Description: "Join an upcoming jam", - DMPermission: utils.P(false), + DMPermission: new(false), })) } diff --git a/src/hmndata/events.go b/src/hmndata/events.go index 0e2c10ef..3ed5efc7 100644 --- a/src/hmndata/events.go +++ b/src/hmndata/events.go @@ -7,13 +7,15 @@ import ( ) type Event struct { - StartTime, EndTime time.Time + StartTime, EndTime time.Time + BannerStartTime, BannerEndTime time.Time Name string Description string // NOTE(asaf): Used by opengraph Image string // NOTE(ben): Used by opengraph Slug string UrlSlug string + Url string IndexUrl string TicketSuccessUrl string @@ -27,6 +29,10 @@ func (ev Event) Within(t time.Time) bool { return ev.WithinGrace(t, 0, 0) } +func (ev Event) WithinBanner(t time.Time) bool { + return ev.BannerStartTime.Before(t) && t.Before(ev.BannerEndTime) +} + func (ev Event) WithinGrace(t time.Time, before, after time.Duration) bool { return ev.StartTime.Add(-before).Before(t) && t.Before(ev.EndTime.Add(after)) } diff --git a/src/hmndata/jams.go b/src/hmndata/jams.go index 9905236f..56156d80 100644 --- a/src/hmndata/jams.go +++ b/src/hmndata/jams.go @@ -7,6 +7,7 @@ import ( "git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/utils" @@ -36,6 +37,7 @@ var WRJ2021 = Jam{ Name: "Wheel Reinvention Jam 2021", Slug: "WRJ2021", UrlSlug: "2021", + Url: hmnurl.BuildJamIndex2021(), }, } @@ -46,6 +48,7 @@ var WRJ2022 = Jam{ Name: "Wheel Reinvention Jam 2022", Slug: "WRJ2022", UrlSlug: "2022", + Url: hmnurl.BuildJamIndex2022(), }, } @@ -56,6 +59,7 @@ var VJ2023 = Jam{ Name: "Visibility Jam 2023", Slug: "VJ2023", UrlSlug: "visibility-2023", + Url: hmnurl.BuildJamIndex2023_Visibility(), }, } @@ -66,6 +70,7 @@ var WRJ2023 = Jam{ Name: "Wheel Reinvention Jam 2023", Slug: "WRJ2023", UrlSlug: "2023", + Url: hmnurl.BuildJamIndex2023(), }, } @@ -77,6 +82,7 @@ var LJ2024 = Jam{ Description: "A two-weekend jam where you dive deep into a topic, then share it with the rest of the community.", Slug: "LJ2024", UrlSlug: "learning-2024", + Url: hmnurl.BuildJamGenericIndex("learning-2024"), }, TemplateName: "2024_lj", ForceDark: true, @@ -93,6 +99,7 @@ var VJ2024 = Jam{ Description: "See things in a new way. July 19 - 21.", Slug: "VJ2024", UrlSlug: "visibility-2024", + Url: hmnurl.BuildJamGenericIndex("visibility-2024"), }, TemplateName: "2024_vj", } @@ -105,6 +112,7 @@ var WRJ2024 = Jam{ Description: "A one-week jam where we build software from scratch. September 23 - 29 on the Handmade Network.", Slug: "WRJ2024", UrlSlug: "wheel-reinvention-2024", + Url: hmnurl.BuildJamGenericIndex("wheel-reinvention-2024"), }, TemplateName: "2024_wrj", } @@ -117,6 +125,7 @@ var XRay2025 = Jam{ Description: "A jam to find out how software works on the inside. June 9 - 15 on the Handmade Network.", Slug: "XRay2025", UrlSlug: "x-ray-2025", + Url: hmnurl.BuildJamGenericIndex("x-ray-2025"), }, TemplateName: "2025_xray", ForceDark: true, @@ -130,6 +139,7 @@ var WRJ2025 = Jam{ Description: "A one-week jam to build software from scratch. September 22 - 28 on the Handmade Network.", Slug: "WRJ2025", UrlSlug: "wheel-reinvention-2025", + Url: hmnurl.BuildJamGenericIndex("wheel-reinvention-2025"), }, TemplateName: "2025_wrj", } @@ -142,6 +152,7 @@ var Essentials2026 = Jam{ Description: "Build simple programs you would use every day, from scratch, by hand. April 13 - 19, 2026.", Slug: "Essentials2026", UrlSlug: "essentials", + Url: hmnurl.BuildJamGenericIndex("essentials"), }, TemplateName: "2026_essentials", @@ -161,13 +172,17 @@ var Essentials2026 = Jam{ // huge structural change. var BetaWeek2026 = Jam{ Event: Event{ - StartTime: time.Date(2026, 11, 16, 13, 0, 0, 0, time.UTC), - EndTime: time.Date(2026, 11, 23, 5, 0, 0, 0, time.UTC), + StartTime: time.Date(2026, 11, 16, 13, 0, 0, 0, time.UTC), + EndTime: time.Date(2026, 11, 23, 5, 0, 0, 0, time.UTC), + BannerStartTime: time.Date(2026, 9, 27, 0, 0, 0, 0, time.UTC), + BannerEndTime: time.Date(2026, 11, 23, 5, 0, 0, 0, time.UTC), + Name: "Beta Week", Description: "A focused week of testing Handmade software. November 16-22, 2026.", Slug: "BetaWeek2026", // NOTE(ben): No URL slug for now, since it's not clear what the patterns will be. // The URL is /betaweek/2026, for now. + Url: hmnurl.BuildBetaWeekIndex(), }, TemplateName: "2026_betaweek", diff --git a/src/templates/mapping.go b/src/templates/mapping.go index 2b82524f..334b68de 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -538,7 +538,7 @@ func JamToBannerEvent(jam hmndata.Jam) BannerEvent { DaysUntilEnd: utils.DaysUntil(jam.EndTime), StartTimeUnix: jam.StartTime.Unix(), EndTimeUnix: jam.EndTime.Unix(), - Url: hmnurl.BuildJamGenericIndex(jam.UrlSlug), + Url: jam.Url, } } diff --git a/src/templates/src/include/header-2024.html b/src/templates/src/include/header-2024.html index 65c30729..298170a2 100644 --- a/src/templates/src/include/header-2024.html +++ b/src/templates/src/include/header-2024.html @@ -197,6 +197,15 @@ {{ define "event banner" }} + {{ if eq .Slug "BetaWeek2026" }} + + Join us for the Handmade Network Beta Week, November 16-22, 2026. More info
{{ svg "arrow-right" }}
+
+ {{ end }} {{ if eq .Slug "Essentials2026" }} 0 { - submittedProject = utils.P(templates.ProjectAndStuffToTemplate(&projects[0])) + submittedProject = new(templates.ProjectAndStuffToTemplate(&projects[0])) } } @@ -295,6 +293,22 @@ func BetaWeekIndex(c *RequestContext) ResponseData { CallForProjectsUrl: hmnurl.BuildBetaWeekCallForProjects(), } + opengraph := []templates.OpenGraphItem{ + {Property: "og:title", Value: hmndata.BetaWeek2026.Name}, + {Property: "og:site_name", Value: "Handmade Network"}, + {Property: "og:type", Value: "website"}, + // {Property: "og:image", Value: hmnurl.BuildPublic(assets.OpenGraphImage, true)}, + {Property: "og:description", Value: hmndata.BetaWeek2026.Description}, + {Property: "og:url", Value: hmndata.BetaWeek2026.Url}, + {Name: "twitter:card", Value: "summary_large_image"}, + // {Name: "twitter:image", Value: hmnurl.BuildPublic(assets.TwitterCard, true)}, + } + + tmpl.OpenGraphItems = opengraph + tmpl.BodyClasses = append(tmpl.BodyClasses, "header-transparent") + tmpl.ForceDark = hmndata.BetaWeek2026.ForceDark + tmpl.Header.SuppressBanners = true + res.MustWriteTemplate("betaweek_2026_index.html", tmpl, c.Perf) return res } @@ -312,6 +326,7 @@ func BetaWeekCallForProjects(c *RequestContext) ResponseData { FetchProjectUrl: hmnurl.BuildAPIProject(), SubmitUrl: hmnurl.BuildBetaWeekCallForProjects(), } + tmpl.Header.SuppressBanners = true res.MustWriteTemplate("betaweek_2026_submit_project.html", tmpl, c.Perf) return res @@ -397,6 +412,7 @@ func BetaWeekCallForProjectsThanks(c *RequestContext) ResponseData { BaseData: getBaseTemplateData(c, "Submit a Project | Thanks", nil), BetaWeekUrl: hmnurl.BuildBetaWeekIndex(), } + tmpl.Header.SuppressBanners = true res.MustWriteTemplate("betaweek_2026_submit_project_thanks.html", tmpl, c.Perf) return res } From 0759ccc1ba0b41da545175fe3082abb37937dea9 Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Mon, 28 Sep 2026 01:21:38 -0500 Subject: [PATCH 5/7] Patch 1 updates --- src/admintools/adminevent.go | 2 +- src/hmndata/events.go | 8 ++ src/hmndata/jams.go | 91 +++++++++---------- src/hmnurl/hmnurl_test.go | 5 - src/templates/mapping.go | 14 +-- .../src/include/betaweek_2026_sections.html | 2 +- src/website/base_data.go | 12 +-- src/website/jam.go | 18 ++-- src/website/jam_nag.go | 2 +- 9 files changed, 74 insertions(+), 80 deletions(-) diff --git a/src/admintools/adminevent.go b/src/admintools/adminevent.go index 24b6904c..c286aede 100644 --- a/src/admintools/adminevent.go +++ b/src/admintools/adminevent.go @@ -47,7 +47,7 @@ func addJamNagCommand(postCommand *cobra.Command) { os.Exit(1) } - nags, err := website.NagUsersToCreateJamProjects(ctx, conn, &jam) + nags, err := website.NagUsersToCreateJamProjects(ctx, conn, jam) if err != nil { fmt.Fprintf(os.Stderr, "ERROR: %s\n", err) } else { diff --git a/src/hmndata/events.go b/src/hmndata/events.go index 3ed5efc7..9c74643b 100644 --- a/src/hmndata/events.go +++ b/src/hmndata/events.go @@ -30,6 +30,14 @@ func (ev Event) Within(t time.Time) bool { } func (ev Event) WithinBanner(t time.Time) bool { + // NOTE(ben): This is kind of jank, but basically we assume that any event + // without an explicit banner start and end time is a jam and we use these + // common jam grace period variables for it, which is dumb but idk. We should + // probably just fill in all the banner things on existing jams and delete + // these constants. + if ev.BannerStartTime.IsZero() { + return ev.WithinGrace(t, JamBannerGraceBefore, JamBannerGraceAfter) + } return ev.BannerStartTime.Before(t) && t.Before(ev.BannerEndTime) } diff --git a/src/hmndata/jams.go b/src/hmndata/jams.go index 56156d80..560353a0 100644 --- a/src/hmndata/jams.go +++ b/src/hmndata/jams.go @@ -14,6 +14,8 @@ import ( ) const JamProjectCreateGracePeriod = 14 * 24 * time.Hour +const JamBannerGraceBefore = 30 * 24 * time.Hour +const JamBannerGraceAfter = 14 * 24 * time.Hour type Jam struct { Event @@ -25,9 +27,6 @@ type Jam struct { DiscordRoleIDs map[config.Environment]string NagTime time.Time - - IsBetaWeek bool // HACK(ben): Different routing for beta week - AdminApprovalOnly bool // NOTE(ben): Only admins will be able to tick the box to associate projects with this event } var WRJ2021 = Jam{ @@ -166,56 +165,54 @@ var Essentials2026 = Jam{ NagTime: time.Date(2026, 4, 11, 14, 0, 0, 0, time.UTC), } -// NOTE(ben): For now we are considering Beta Week a Jam because mostly we -// expect it to slot into many existing website systems related to jams. We -// will probably rephrase things eventually but that will probably not be a -// huge structural change. -var BetaWeek2026 = Jam{ - Event: Event{ - StartTime: time.Date(2026, 11, 16, 13, 0, 0, 0, time.UTC), - EndTime: time.Date(2026, 11, 23, 5, 0, 0, 0, time.UTC), - BannerStartTime: time.Date(2026, 9, 27, 0, 0, 0, 0, time.UTC), - BannerEndTime: time.Date(2026, 11, 23, 5, 0, 0, 0, time.UTC), - - Name: "Beta Week", - Description: "A focused week of testing Handmade software. November 16-22, 2026.", - Slug: "BetaWeek2026", - // NOTE(ben): No URL slug for now, since it's not clear what the patterns will be. - // The URL is /betaweek/2026, for now. - Url: hmnurl.BuildBetaWeekIndex(), - }, - - TemplateName: "2026_betaweek", - ForceDark: true, - - // TODO(betaweek): Discord roles? - // TODO(betaweek): Nag time? +var BetaWeek2026 = Event{ + StartTime: time.Date(2026, 11, 16, 13, 0, 0, 0, time.UTC), + EndTime: time.Date(2026, 11, 23, 5, 0, 0, 0, time.UTC), + BannerStartTime: time.Date(2026, 9, 27, 0, 0, 0, 0, time.UTC), + BannerEndTime: time.Date(2026, 11, 23, 5, 0, 0, 0, time.UTC), + + Name: "Beta Week", + Description: "A focused week of testing Handmade software. November 16-22, 2026.", + Slug: "BetaWeek2026", + // NOTE(ben): No URL slug for now, since it's not clear what the patterns will be. + // The URL is /betaweek/2026, for now. + Url: hmnurl.BuildBetaWeekIndex(), +} - IsBetaWeek: true, - AdminApprovalOnly: true, +var AllJams = []*Jam{ + &WRJ2021, + &WRJ2022, + &VJ2023, + &WRJ2023, + &LJ2024, + &VJ2024, + &WRJ2024, + &XRay2025, + &WRJ2025, + &Essentials2026, } -var AllJams = []Jam{ - WRJ2021, - WRJ2022, - VJ2023, - WRJ2023, - LJ2024, - VJ2024, - WRJ2024, - XRay2025, - WRJ2025, - Essentials2026, - BetaWeek2026, +var AllEvents = []*Event{ + &WRJ2021.Event, + &WRJ2022.Event, + &VJ2023.Event, + &WRJ2023.Event, + &LJ2024.Event, + &VJ2024.Event, + &WRJ2024.Event, + &XRay2025.Event, + &WRJ2025.Event, + &Essentials2026.Event, + &BetaWeek2026, } -var LatestJam = BetaWeek2026 // NOTE(ben): This is used for Discord integration, e.g. slash command and #jam channel activity +var LatestJam *Jam = &Essentials2026 // NOTE(ben): This is used for Discord integration, e.g. slash command and #jam channel activity func CurrentJam() *Jam { now := time.Now() for i, jam := range AllJams { if jam.Event.Within(now) { - return &AllJams[i] + return AllJams[i] } } return nil @@ -225,7 +222,7 @@ func UpcomingJam(window time.Duration) *Jam { now := time.Now() for i, jam := range AllJams { if jam.Event.WithinGrace(now, window, 0) { - return &AllJams[i] + return AllJams[i] } } return nil @@ -235,19 +232,19 @@ func RecentJam(window time.Duration) *Jam { now := time.Now() for i, jam := range AllJams { if jam.Event.WithinGrace(now, 0, window) { - return &AllJams[i] + return AllJams[i] } } return nil } -func JamBySlug(slug string) (Jam, bool) { +func JamBySlug(slug string) (*Jam, bool) { for _, jam := range AllJams { if jam.Slug == slug { return jam, true } } - return Jam{Event: Event{Slug: slug}}, false + return &Jam{Slug: slug}, false } func FetchJamsForProject(ctx context.Context, dbConn db.ConnOrTx, user *models.User, projectId int) ([]*models.JamProject, error) { diff --git a/src/hmnurl/hmnurl_test.go b/src/hmnurl/hmnurl_test.go index 1cdab20e..5a692b41 100644 --- a/src/hmnurl/hmnurl_test.go +++ b/src/hmnurl/hmnurl_test.go @@ -366,11 +366,6 @@ func TestJamsIndex(t *testing.T) { AssertRegexMatch(t, BuildJamsIndex(), RegexJamsIndex, nil) } -func TestJamIndex(t *testing.T) { - AssertRegexMatch(t, BuildJamIndex(), RegexJamIndex, nil) - AssertSubdomain(t, BuildJamIndex(), "") -} - func TestJamIndex2021(t *testing.T) { AssertRegexMatch(t, BuildJamIndex2021(), RegexJamIndex2021, nil) AssertSubdomain(t, BuildJamIndex2021(), "") diff --git a/src/templates/mapping.go b/src/templates/mapping.go index 334b68de..d8f569c8 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -531,14 +531,14 @@ func CalendarEventToTemplate(ev *calendar.CalendarEvent) CalendarEvent { } } -func JamToBannerEvent(jam hmndata.Jam) BannerEvent { +func EventToBannerEvent(event *hmndata.Event) BannerEvent { return BannerEvent{ - Slug: jam.Slug, - DaysUntilStart: utils.DaysUntil(jam.StartTime), - DaysUntilEnd: utils.DaysUntil(jam.EndTime), - StartTimeUnix: jam.StartTime.Unix(), - EndTimeUnix: jam.EndTime.Unix(), - Url: jam.Url, + Slug: event.Slug, + DaysUntilStart: utils.DaysUntil(event.StartTime), + DaysUntilEnd: utils.DaysUntil(event.EndTime), + StartTimeUnix: event.StartTime.Unix(), + EndTimeUnix: event.EndTime.Unix(), + Url: event.Url, } } diff --git a/src/templates/src/include/betaweek_2026_sections.html b/src/templates/src/include/betaweek_2026_sections.html index 58f6ecb7..dc1ef059 100644 --- a/src/templates/src/include/betaweek_2026_sections.html +++ b/src/templates/src/include/betaweek_2026_sections.html @@ -77,7 +77,7 @@

November 16 - 22, 2026

One week of testing and improving Handmade software.
diff --git a/src/website/base_data.go b/src/website/base_data.go index 7aef599f..5829e935 100644 --- a/src/website/base_data.go +++ b/src/website/base_data.go @@ -53,15 +53,9 @@ func getBaseTemplateData(c *RequestContext, title string, breadcrumbs []template } var bannerEvent *templates.BannerEvent - for _, jam := range hmndata.AllJams { - // NOTE(ben): This used to do some logic with variables named - // JamBannerGraceBefore and JamBannerGraceAfter. In retrospect, this is a - // lot more rigid and annoying than just throwing some BannerStart and - // BannerEnd variables on each event. So that's what we do now, but all the - // existing jams are missing that (since I can't be bothered to backfill - // them), and you will have to supply those variables for any future jams. - if jam.Event.WithinBanner(time.Now()) { - bannerEvent = new(templates.JamToBannerEvent(jam)) + for _, event := range hmndata.AllEvents { + if event.WithinBanner(time.Now()) { + bannerEvent = new(templates.EventToBannerEvent(event)) } } diff --git a/src/website/jam.go b/src/website/jam.go index 9dfc8d7a..df0eab07 100644 --- a/src/website/jam.go +++ b/src/website/jam.go @@ -99,7 +99,7 @@ type JamGenericTemplateData struct { ShortFeed bool } -func getJamAssets(jam hmndata.Jam) JamAssets { +func getJamAssets(jam *hmndata.Jam) JamAssets { return JamAssets{ Logo: fmt.Sprintf("jams/%s/logo.svg", jam.UrlSlug), TwitterCard: fmt.Sprintf("jams/%s/TwitterCard.png", jam.UrlSlug), @@ -107,7 +107,7 @@ func getJamAssets(jam hmndata.Jam) JamAssets { } } -func getJamGenericTemplateData(c *RequestContext, jam hmndata.Jam, baseData templates.BaseData, numTimelineItems int) (JamGenericTemplateData, error) { +func getJamGenericTemplateData(c *RequestContext, jam *hmndata.Jam, baseData templates.BaseData, numTimelineItems int) (JamGenericTemplateData, error) { now := JamCurrentTime(c, jam.Event) assets := getJamAssets(jam) @@ -200,14 +200,14 @@ func getJamGenericTemplateData(c *RequestContext, jam hmndata.Jam, baseData temp return templateData, nil } -func findJamByUrlSlug(urlSlug string) (hmndata.Jam, bool) { +func findJamByUrlSlug(urlSlug string) (*hmndata.Jam, bool) { for _, j := range hmndata.AllJams { if strings.ToLower(j.UrlSlug) == urlSlug { return j, true } } - return hmndata.Jam{}, false + return nil, false } func JamGenericIndex(c *RequestContext) ResponseData { @@ -216,7 +216,7 @@ func JamGenericIndex(c *RequestContext) ResponseData { urlSlug := strings.ToLower(c.PathParams["urlslug"]) jam, found := findJamByUrlSlug(urlSlug) - if !found || jam.IsBetaWeek { // HACK(ben): Just...don't let the beta week slip in here + if !found { return FourOhFour(c) } @@ -237,7 +237,7 @@ func JamGenericFeed(c *RequestContext) ResponseData { urlSlug := strings.ToLower(c.PathParams["urlslug"]) jam, found := findJamByUrlSlug(urlSlug) - if !found || jam.IsBetaWeek { // HACK(ben) + if !found { return FourOhFour(c) } @@ -261,7 +261,7 @@ func JamGenericGuidelines(c *RequestContext) ResponseData { urlSlug := strings.ToLower(c.PathParams["urlslug"]) jam, found := findJamByUrlSlug(urlSlug) - if !found || jam.IsBetaWeek { // HACK(ben) + if !found { return FourOhFour(c) } @@ -288,7 +288,7 @@ func BetaWeekIndex(c *RequestContext) ResponseData { CallForProjectsUrl string } tmpl := Tmpl{ - BaseData: getBaseTemplateData(c, "Submit a Project", nil), + BaseData: getBaseTemplateData(c, "Beta Week", nil), IndexUrl: hmnurl.BuildBetaWeekIndex(), CallForProjectsUrl: hmnurl.BuildBetaWeekCallForProjects(), } @@ -306,7 +306,7 @@ func BetaWeekIndex(c *RequestContext) ResponseData { tmpl.OpenGraphItems = opengraph tmpl.BodyClasses = append(tmpl.BodyClasses, "header-transparent") - tmpl.ForceDark = hmndata.BetaWeek2026.ForceDark + tmpl.ForceDark = true tmpl.Header.SuppressBanners = true res.MustWriteTemplate("betaweek_2026_index.html", tmpl, c.Perf) diff --git a/src/website/jam_nag.go b/src/website/jam_nag.go index 4454b626..590016d7 100644 --- a/src/website/jam_nag.go +++ b/src/website/jam_nag.go @@ -37,7 +37,7 @@ func NagUsersToCreateJamProjectsJob(dbConn *pgxpool.Pool) *jobs.Job { case now := <-t.C: for _, jam := range hmndata.AllJams { if utils.TimeIsBetween(lastTime, jam.NagTime, now) { - nags, err := NagUsersToCreateJamProjects(job.Ctx, dbConn, &jam) + nags, err := NagUsersToCreateJamProjects(job.Ctx, dbConn, jam) if err != nil { log.Error().Err(err).Msg("Failed to nag people about the jam") } else { From 2c35b34e6d367496dc82229e0d53122793082d22 Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Mon, 28 Sep 2026 01:48:16 -0500 Subject: [PATCH 6/7] Patch 2 updates --- public/js/betaweek_2026_submit_project.js | 1 + src/rawdata/js/betaweek_2026_submit_project.ts | 1 + src/templates/src/betaweek_2026_submit_project.html | 2 +- src/website/api.go | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/public/js/betaweek_2026_submit_project.js b/public/js/betaweek_2026_submit_project.js index fe460954..d8b85f4c 100644 --- a/public/js/betaweek_2026_submit_project.js +++ b/public/js/betaweek_2026_submit_project.js @@ -24,6 +24,7 @@ function init({ function clearProject() { projectPreviewContainer.hidden = true; projectPreviewContainer.innerHTML = ""; + projectIDField.value = ""; } async function loadProject() { const projectUrl = projectUrlField.value.trim(); diff --git a/src/rawdata/js/betaweek_2026_submit_project.ts b/src/rawdata/js/betaweek_2026_submit_project.ts index 704bee46..92f76f2a 100644 --- a/src/rawdata/js/betaweek_2026_submit_project.ts +++ b/src/rawdata/js/betaweek_2026_submit_project.ts @@ -17,6 +17,7 @@ export function init({ function clearProject() { projectPreviewContainer.hidden = true; projectPreviewContainer.innerHTML = ""; + projectIDField.value = ""; } async function loadProject() { const projectUrl = projectUrlField.value.trim(); diff --git a/src/templates/src/betaweek_2026_submit_project.html b/src/templates/src/betaweek_2026_submit_project.html index 4713198f..af2edbd4 100644 --- a/src/templates/src/betaweek_2026_submit_project.html +++ b/src/templates/src/betaweek_2026_submit_project.html @@ -31,7 +31,7 @@

Handmade Network Beta Week 2026: Call For Projects

Paste the link to your HMN project here. Project info will be fetched automatically.
- + diff --git a/src/website/api.go b/src/website/api.go index 407a6148..a749ce5c 100644 --- a/src/website/api.go +++ b/src/website/api.go @@ -107,7 +107,7 @@ func APIProject(c *RequestContext) ResponseData { // NOTE(ben): Official project project, err = hmndata.FetchProjectBySlug(c, c.Conn, c.CurrentUser, slug, hmndata.ProjectsQuery{ Lifecycles: models.AllProjectLifecycles, - IncludeHidden: true, // NOTE(ben): Still filtered by user visibility rules + IncludeHidden: true, }) } else if hmnurl.URLPathMatchesRoute(parsedUrl, hmnurl.RegexPersonalProject) { projectIDStr := hmnurl.MatchURLPathAgainstRoute(parsedUrl, hmnurl.RegexPersonalProject)["projectid"] From c0e6cf17f700b10ba7adf3aa28732c30a0c5e7b2 Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Mon, 28 Sep 2026 01:50:53 -0500 Subject: [PATCH 7/7] Patch 3 updates --- src/templates/src/betaweek_2026_submit_project.html | 2 ++ src/website/routes.go | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/templates/src/betaweek_2026_submit_project.html b/src/templates/src/betaweek_2026_submit_project.html index af2edbd4..9fe71a86 100644 --- a/src/templates/src/betaweek_2026_submit_project.html +++ b/src/templates/src/betaweek_2026_submit_project.html @@ -13,6 +13,8 @@ {{ define "content" }}
+ {{ csrftoken .Session }} +

Handmade Network Beta Week 2026: Call For Projects

diff --git a/src/website/routes.go b/src/website/routes.go index a1089991..74c674d0 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -133,7 +133,7 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt hmnOnly.GET(hmnurl.RegexJamGenericGuidelines, JamGenericGuidelines) hmnOnly.GET(hmnurl.RegexBetaWeekIndex, BetaWeekIndex) hmnOnly.GET(hmnurl.RegexBetaWeekCallForProjects, needsAuth(BetaWeekCallForProjects)) - hmnOnly.POST(hmnurl.RegexBetaWeekCallForProjects, needsAuth(BetaWeekCallForProjectsSubmit)) + hmnOnly.POST(hmnurl.RegexBetaWeekCallForProjects, needsAuth(csrfMiddleware(BetaWeekCallForProjectsSubmit))) hmnOnly.GET(hmnurl.RegexBetaWeekCallForProjectsThanks, needsAuth(BetaWeekCallForProjectsThanks)) hmnOnly.GET(hmnurl.RegexExpo, ExpoIndex)