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..d8b85f4c --- /dev/null +++ b/public/js/betaweek_2026_submit_project.js @@ -0,0 +1,70 @@ +// 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 = ""; + projectIDField.value = ""; + } + 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/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/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..9c74643b 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,18 @@ func (ev Event) Within(t time.Time) bool { return ev.WithinGrace(t, 0, 0) } +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) +} + 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 2d6aff58..560353a0 100644 --- a/src/hmndata/jams.go +++ b/src/hmndata/jams.go @@ -7,12 +7,15 @@ 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" ) const JamProjectCreateGracePeriod = 14 * 24 * time.Hour +const JamBannerGraceBefore = 30 * 24 * time.Hour +const JamBannerGraceAfter = 14 * 24 * time.Hour type Jam struct { Event @@ -33,6 +36,7 @@ var WRJ2021 = Jam{ Name: "Wheel Reinvention Jam 2021", Slug: "WRJ2021", UrlSlug: "2021", + Url: hmnurl.BuildJamIndex2021(), }, } @@ -43,6 +47,7 @@ var WRJ2022 = Jam{ Name: "Wheel Reinvention Jam 2022", Slug: "WRJ2022", UrlSlug: "2022", + Url: hmnurl.BuildJamIndex2022(), }, } @@ -53,6 +58,7 @@ var VJ2023 = Jam{ Name: "Visibility Jam 2023", Slug: "VJ2023", UrlSlug: "visibility-2023", + Url: hmnurl.BuildJamIndex2023_Visibility(), }, } @@ -63,6 +69,7 @@ var WRJ2023 = Jam{ Name: "Wheel Reinvention Jam 2023", Slug: "WRJ2023", UrlSlug: "2023", + Url: hmnurl.BuildJamIndex2023(), }, } @@ -74,6 +81,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, @@ -90,6 +98,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", } @@ -102,6 +111,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", } @@ -114,6 +124,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, @@ -127,6 +138,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", } @@ -139,6 +151,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", @@ -152,26 +165,54 @@ var Essentials2026 = Jam{ NagTime: time.Date(2026, 4, 11, 14, 0, 0, 0, time.UTC), } -var AllJams = []Jam{ - WRJ2021, - WRJ2022, - VJ2023, - WRJ2023, - LJ2024, - VJ2024, - WRJ2024, - XRay2025, - WRJ2025, - Essentials2026, +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(), +} + +var AllJams = []*Jam{ + &WRJ2021, + &WRJ2022, + &VJ2023, + &WRJ2023, + &LJ2024, + &VJ2024, + &WRJ2024, + &XRay2025, + &WRJ2025, + &Essentials2026, +} + +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 = Essentials2026 // NOTE(asaf): The /jam route will redirect here +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 @@ -181,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 @@ -191,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.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/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/hmnurl/urls.go b/src/hmnurl/urls.go index 06e2c0c7..2194c8f6 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,24 @@ 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 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 { @@ -948,6 +960,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/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/rawdata/js/betaweek_2026_submit_project.ts b/src/rawdata/js/betaweek_2026_submit_project.ts new file mode 100644 index 00000000..92f76f2a --- /dev/null +++ b/src/rawdata/js/betaweek_2026_submit_project.ts @@ -0,0 +1,62 @@ +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 = ""; + projectIDField.value = ""; + } + 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/mapping.go b/src/templates/mapping.go index 2b82524f..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: hmnurl.BuildJamGenericIndex(jam.UrlSlug), + 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/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..9fe71a86 --- /dev/null +++ b/src/templates/src/betaweek_2026_submit_project.html @@ -0,0 +1,84 @@ +{{ template "base-2024.html" . }} + +{{ define "extrahead" }} + +{{ end }} + +{{ define "content" }} +
+
+ {{ csrftoken .Session }} + +

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/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/templates/src/include/betaweek_2026_sections.html b/src/templates/src/include/betaweek_2026_sections.html new file mode 100644 index 00000000..dc1ef059 --- /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/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])) } } @@ -202,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 { @@ -281,6 +279,144 @@ 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, "Beta Week", nil), + IndexUrl: hmnurl.BuildBetaWeekIndex(), + 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 = true + tmpl.Header.SuppressBanners = true + + res.MustWriteTemplate("betaweek_2026_index.html", tmpl, c.Perf) + return res +} + +func BetaWeekCallForProjects(c *RequestContext) ResponseData { + var res 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(), + } + tmpl.Header.SuppressBanners = true + + 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(), + } + tmpl.Header.SuppressBanners = true + 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/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 { 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 d459d52c..74c674d0 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,13 @@ 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, needsAuth(BetaWeekCallForProjects)) + hmnOnly.POST(hmnurl.RegexBetaWeekCallForProjects, needsAuth(csrfMiddleware(BetaWeekCallForProjectsSubmit))) + hmnOnly.GET(hmnurl.RegexBetaWeekCallForProjectsThanks, needsAuth(BetaWeekCallForProjectsThanks)) hmnOnly.GET(hmnurl.RegexExpo, ExpoIndex) hmnOnly.GET(hmnurl.RegexExpoTicketPurchaseSuccess, needsAuth(ExpoTicketPurchaseSuccess)) @@ -255,6 +255,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 {