Add upcoming jam page

This commit is contained in:
Ben Visness 2024-05-07 12:12:52 -05:00 committed by Ben Visness
parent 7fd57f692b
commit 22265c9081
12 changed files with 556 additions and 34 deletions

1
.gitignore vendored
View File

@ -16,6 +16,7 @@ local/backups
/tmp
*.exe
.DS_Store
__debug_bin*
# vim session saves
Session.vim

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

View File

@ -7346,12 +7346,6 @@ article code {
margin-left: auto;
margin-right: auto; }
.flex-shrink-0, .edit-form .edit-form-row > :first-child {
flex-shrink: 0; }
.flex-grow-1, .edit-form .edit-form-row > :nth-child(2) {
flex-grow: 1; }
.flex-fair {
flex-basis: 1px;
flex-grow: 1;
@ -7597,7 +7591,19 @@ article code {
grid-template-columns: 1fr 1fr; }
.bg--dim-ns {
background-color: #f0f0f0;
background-color: var(--dim-background); } }
background-color: var(--dim-background); }
.g0-ns {
gap: 0; }
.g1-ns {
gap: 0.25rem; }
.g2-ns {
gap: 0.5rem; }
.g3-ns {
gap: 1rem; }
.g4-ns {
gap: 2rem; }
.g5-ns {
gap: 4rem; } }
@media screen and (min-width: 35em) and (max-width: 60em) {
.bi-avoid-m {
@ -7626,7 +7632,19 @@ article code {
grid-template-columns: 1fr 1fr; }
.bg--dim-m {
background-color: #f0f0f0;
background-color: var(--dim-background); } }
background-color: var(--dim-background); }
.g0-m {
gap: 0; }
.g1-m {
gap: 0.25rem; }
.g2-m {
gap: 0.5rem; }
.g3-m {
gap: 1rem; }
.g4-m {
gap: 2rem; }
.g5-m {
gap: 4rem; } }
@media screen and (min-width: 60em) {
.bi-avoid-l {
@ -7655,7 +7673,19 @@ article code {
grid-template-columns: 1fr 1fr; }
.bg--dim-l {
background-color: #f0f0f0;
background-color: var(--dim-background); } }
background-color: var(--dim-background); }
.g0-l {
gap: 0; }
.g1-l {
gap: 0.25rem; }
.g2-l {
gap: 0.5rem; }
.g3-l {
gap: 1rem; }
.g4-l {
gap: 2rem; }
.g5-l {
gap: 4rem; } }
.not-first:first-child {
display: none; }

View File

@ -463,8 +463,8 @@ func TestTimeMachineFormDone(t *testing.T) {
}
func TestNewsletterSignup(t *testing.T) {
AssertRegexMatch(t, BuildNewsletterSignup(), RegexNewsletterSignup, nil)
AssertSubdomain(t, BuildNewsletterSignup(), "")
AssertRegexMatch(t, BuildAPINewsletterSignup(), RegexNewsletterSignup, nil)
AssertSubdomain(t, BuildAPINewsletterSignup(), "")
}
func TestProjectNewJam(t *testing.T) {

View File

@ -49,13 +49,6 @@ func BuildWhenIsIt() string {
return Url("/whenisit", nil)
}
var RegexNewsletterSignup = regexp.MustCompile("^/newsletter$")
func BuildNewsletterSignup() string {
defer CatchPanic()
return Url("/newsletter", nil)
}
var RegexJamsIndex = regexp.MustCompile("^/jams$")
func BuildJamsIndex() string {
@ -147,6 +140,13 @@ func BuildJamGuidelines2024_Learning() string {
return Url("/jam/learning-2024/guidelines", nil)
}
var RegexJamSaveTheDate = regexp.MustCompile("^/jam/upcoming$")
func BuildJamSaveTheDate() string {
defer CatchPanic()
return Url("/jam/upcoming", nil)
}
func BuildJamIndexAny(slug string) string {
defer CatchPanic()
return Url(fmt.Sprintf("/jam/%s", slug), nil)
@ -945,6 +945,12 @@ func BuildAPICheckUsername() string {
return Url("/api/check_username", nil)
}
var RegexAPINewsletterSignup = regexp.MustCompile("^/api/newsletter_signup$")
func BuildAPINewsletterSignup() string {
return Url("/api/newsletter_signup", nil)
}
/*
* Twitch stuff
*/

View File

@ -0,0 +1,47 @@
package migrations
import (
"context"
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"github.com/jackc/pgx/v5"
)
func init() {
registerMigration(newsletter{})
}
type newsletter struct{}
func (m newsletter) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2024, 5, 7, 1, 34, 32, 0, time.UTC))
}
func (m newsletter) Name() string {
return "newsletter"
}
func (m newsletter) Description() string {
return "Adds the newsletter signup"
}
func (m newsletter) Up(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx,
`
CREATE TABLE newsletter_emails (
email VARCHAR(255) NOT NULL PRIMARY KEY
);
`,
)
return err
}
func (m newsletter) Down(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx,
`
DROP TABLE newsletter_emails;
`,
)
return err
}

View File

@ -167,14 +167,6 @@ article code {
margin-right: auto;
}
.flex-shrink-0 {
flex-shrink: 0;
}
.flex-grow-1 {
flex-grow: 1;
}
.flex-fair {
flex-basis: 1px;
flex-grow: 1;
@ -410,6 +402,13 @@ article code {
.bg--dim-ns {
@include usevar(background-color, dim-background);
}
.g0-ns { gap: $spacing-none; }
.g1-ns { gap: $spacing-extra-small; }
.g2-ns { gap: $spacing-small; }
.g3-ns { gap: $spacing-medium; }
.g4-ns { gap: $spacing-large; }
.g5-ns { gap: $spacing-extra-large; }
}
@media #{$breakpoint-medium} {
@ -429,6 +428,13 @@ article code {
.bg--dim-m {
@include usevar(background-color, dim-background);
}
.g0-m { gap: $spacing-none; }
.g1-m { gap: $spacing-extra-small; }
.g2-m { gap: $spacing-small; }
.g3-m { gap: $spacing-medium; }
.g4-m { gap: $spacing-large; }
.g5-m { gap: $spacing-extra-large; }
}
@media #{$breakpoint-large} {
@ -448,6 +454,13 @@ article code {
.bg--dim-l {
@include usevar(background-color, dim-background);
}
.g0-l { gap: $spacing-none; }
.g1-l { gap: $spacing-extra-small; }
.g2-l { gap: $spacing-small; }
.g3-l { gap: $spacing-medium; }
.g4-l { gap: $spacing-large; }
.g5-l { gap: $spacing-extra-large; }
}
.not-first:first-child {

View File

@ -0,0 +1,333 @@
{{/*
This is a copy-paste from base.html because we want to preserve the unique
style of this page no matter what future changes we make to the base.
*/}}
<!DOCTYPE html>
<html lang="en-US" {{ if .OpenGraphItems }} prefix="og: http://ogp.me/ns#"{{ end }}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" sizes="16x16" href="{{ static "favicon-16x16.png" }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ static "favicon-32x32.png" }}">
{{ if .CanonicalLink }}<link rel="canonical" href="{{ .CanonicalLink }}">{{ end }}
{{ range .OpenGraphItems }}
{{ if .Property }}
<meta property="{{ .Property }}" content="{{ .Value }}" />
{{ else }}
<meta name="{{ .Name }}" content="{{ .Value }}" />
{{ end }}
{{ end }}
{{ if .Title }}
<title>{{ .Title }} | Handmade Network</title>
{{ else }}
<title>Handmade Network</title>
{{ end }}
<meta name="theme-color" content="#232426">
<!-- <script src="{{ static "js/templates.js" }}"></script> -->
<link rel="stylesheet" href="{{ static "fonts/mohave/stylesheet.css" }}">
<link href='https://fonts.googleapis.com/css?family=Fira+Sans:300,400,500,600' rel='stylesheet' type='text/css'>
<link href='https://fonts.googleapis.com/css?family=Fira+Mono:300,400,500,700' rel='stylesheet' type='text/css'>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
<link rel="stylesheet" type="text/css" href="{{ static "style.css" }}">
<style>
:root {
--theme-gradient-dark: linear-gradient(to bottom right, #232426, #BFB6BE);
--theme-gradient-light: linear-gradient(to bottom right, #8BD5FF, #5899FF);
--white: #fff;
--bg-button: rgba(255, 255, 255, 0);
--bg-button-hover: rgba(255, 255, 255, 0.1);
--charcoal: #2F2F2F;
--gray: #CBCBCB;
--rich-gray: #494949;
--spacing-0: 0;
--spacing-1: .25rem;
--spacing-2: .5rem;
--spacing-3: 1rem;
--spacing-4: 2rem;
--spacing-5: 4rem;
--spacing-6: 8rem;
--spacing-7: 16rem;
--border-radius-2: 0.25rem;
--link-color: white;
}
body {
font-family: "Inter", "Fira Sans", sans-serif;
font-size: 1rem; /* remove this override when base stylesheet is less dumb */
}
h1, h2, h3, h4, h5, h6 {
font-weight: 700;
}
.w2-5 {
width: 3rem;
}
.link--white {
--link-color: var(--white);
}
.post-content p {
/* stupid override, should be done by .post-content instead of .content */
margin: 0.6rem 0;
}
.bg--theme-gradient-dark {
background: var(--theme-gradient-dark);
/* background: linear-gradient(to bottom right, #333, #BBB); */
}
.bg--theme-gradient-light {
background: var(--theme-gradient-light);
}
.bg--charcoal {
background: var(--charcoal);
}
.bg--rich-gray {
background: var(--rich-gray);
}
.bg--gray {
background: var(--gray);
}
.b--charcoal {
border-color: var(--charcoal);
}
.b--rich-gray {
border-color: var(--rich-gray);
}
.c--theme-gradient-dark {
background: var(--theme-gradient-dark);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
.c--theme-gradient-light {
background: var(--theme-gradient-light);
background-clip: text;
-webkit-background-clip: text;
}
.c--theme-gradient-light {
color: transparent;
}
.c--gray {
color: var(--gray);
}
.input--jam {
border: 1px solid var(--white);
border-radius: var(--border-radius-2) !important;
padding: var(--spacing-2) !important;
}
.btn--jam {
border: 1px solid var(--white);
border-radius: var(--border-radius-2);
padding: var(--spacing-2) var(--spacing-3);
background-color: var(--bg-button);
}
.btn--jam.small {
padding: var(--spacing-1) var(--spacing-2);
}
.btn--jam:hover {
background-color: var(--bg-button-hover);
}
.button-simple {
background: var(--bg-button);
border: 1px solid var(--white);
}
.button-simple:hover {
background: var(--bg-button-hover);
}
.c-white {
color: var(--white);
}
.invisible {
visibility: hidden;
}
.svg-mask {
mask: var(--mask-url);
-webkit-mask: var(--mask-url);
}
.fill-current {
fill: currentColor;
}
.square {
aspect-ratio: 1 / 1;
}
.wide-screen {
aspect-ratio: 16 / 9;
}
.iframe-fill iframe {
flex-grow: 1;
}
.hmn-logo {
font-family: 'MohaveHMN', sans-serif;
text-transform: uppercase;
font-size: 1.6rem;
padding: 0.2rem 0.8rem;
}
.header-nav {
padding-right: 0.6rem;
}
.user-avatar {
border-radius: 999px;
background-color: rgba(0, 0, 0, 0.4);
}
.user-avatar.header {
width: 1.8rem;
}
.jam-logo {
max-width: 18rem;
}
.jam-logo-small {
max-width: 8rem;
}
.jam-title {
font-size: 2.5rem;
font-weight: 700;
/* align the bounding box with the visible text */
margin-left: -0.06em;
margin-right: -0.03em;
}
.w-headline {
width: 20.3rem;
}
/* not small */
@media screen and (min-width: 35em) {
.w-headline {
width: 44.67rem;
}
.jam-title {
font-size: 5.53rem;
}
}
</style>
</head>
<body class="bg--theme-gradient-dark c-white flex flex-column">
<div class="bg-black-20 flex flex-row items-center link--white">
<a href="{{ .Header.HMNHomepageUrl }}" class="hmn-logo flex-shrink-0">
Handmade
</a>
<div class="flex-grow-1 flex-shrink-1"></div>
<div class="header-nav flex flex-row items-center g3 lh-solid f6">
<a href="{{ .Header.ProjectIndexUrl }}">Projects</a>
<a href="{{ .Header.JamsUrl }}">Jams</a>
<a class="db" href="{{ or .Header.UserProfileUrl .LoginPageUrl }}">
<img class="user-avatar header" src="{{ .UserAvatarUrl }}">
</a>
</div>
</div>
<div class="tc flex-grow-1 flex justify-center items-center-ns g5 pv5">
<div class="w-headline flex flex-column">
<div class="jam-title mb4 lh-solid">Handmade Jams</div>
<div class="flex flex-column flex-row-ns justify-between-ns g4 g0-ns lh-solid">
<div>
<div class="fw7 f3 f2-ns">Visibility Jam</div>
<div class="fw3 f4 mt1">July 19-21, 2024</div>
<div class="fw6 f5 pt3">
<a href="{{ .Visibility2023Url }}" class="db">
Last year's jam<div class="dib svgicon f8 ml1">{{ svg "chevron-right" }}</div>
</a>
</div>
</div>
<div>
<div class="fw7 f3 f2-ns">Wheel Reinvention Jam</div>
<div class="fw3 f4 mt1">September 23-29, 2024</div>
<div class="fw6 f5 pt3">
<a href="{{ .WRJ2023Url }}" class="db">
Last year's jam<div class="dib svgicon f8 ml1">{{ svg "chevron-right" }}</div>
</a>
</div>
</div>
</div>
<div class="tl tj-ns mt4 post-content">
<p>The Handmade community runs multiple programming jams every year. If you've participated in a game jam before, the idea is similar—an event where participants build something from scratch in a short period of time. Unlike a game jam, though, we don't declare winners (although we do celebrate our favorite results). <a class="fw6" href="{{ .JamsUrl }}">See all our prior jams here.</a></p>
<p>To participate in the next one, <a class="fw6" href="https://discord.gg/hmn">join our Discord</a>, or sign up for our mailing list:</p>
</div>
<form id="newsletter-form" class="mt4 flex flex-column g1">
<div class="flex g2 justify-center">
<input id="newsletter-email" type="email" class="input--jam flex-grow-1 flex-grow-0-ns" placeholder="me@example.com">
<button id="newsletter-button" class="btn--jam">Sign up</button>
</div>
<div id="newsletter-message" class="f7">
&nbsp;
</div>
</form>
</div>
</div>
<script>
const form = document.querySelector("#newsletter-form");
const emailField = document.querySelector("#newsletter-email");
const button = document.querySelector("#newsletter-button");
const message = document.querySelector("#newsletter-message");
form.addEventListener("submit", async e => {
e.preventDefault();
button.disabled = true;
try {
const res = await fetch("{{ .NewsletterSignupUrl }}", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
"email": emailField.value,
}),
});
if (res.status >= 400) {
throw new Error("bad request");
}
message.innerText = "Signed up successfully!";
} catch (err) {
message.innerText = "There was an error signing up.";
}
button.disabled = false;
});
</script>
</body>
</html>

View File

@ -1,13 +1,16 @@
package website
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/utils"
)
func APICheckUsername(c *RequestContext) ResponseData {
@ -44,12 +47,59 @@ func APICheckUsername(c *RequestContext) ResponseData {
}
var res ResponseData
res.Header().Set("Content-Type", "application/json")
addCORSHeaders(c, &res)
if found {
res.Write([]byte(fmt.Sprintf(`{ "found": true, "canonical": "%s" }`, canonicalUsername)))
res.WriteJson(map[string]any{
"found": true,
"canonical": canonicalUsername,
}, nil)
} else {
res.Write([]byte(`{ "found": false }`))
res.WriteJson(map[string]any{
"found": false,
}, nil)
}
return res
}
func APINewsletterSignup(c *RequestContext) ResponseData {
bodyBytes := utils.Must1(io.ReadAll(c.Req.Body))
type Input struct {
Email string `json:"email"`
}
var input Input
err := json.Unmarshal(bodyBytes, &input)
if err != nil {
return c.ErrorResponse(http.StatusBadRequest, err)
}
var res ResponseData
sanitized := input.Email
sanitized = strings.TrimSpace(sanitized)
sanitized = strings.ToLower(sanitized)
if len(sanitized) > 200 {
res.StatusCode = http.StatusBadRequest
return res
}
if !strings.Contains(sanitized, "@") {
res.StatusCode = http.StatusBadRequest
res.WriteJson(map[string]any{
"error": "bad email",
}, nil)
return res
}
_, err = c.Conn.Exec(c,
`
INSERT INTO newsletter_emails (email) VALUES ($1)
ON CONFLICT DO NOTHING
`,
sanitized,
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to save email into database"))
}
res.WriteHeader(http.StatusNoContent)
return res
}

View File

@ -43,6 +43,39 @@ func JamsIndex(c *RequestContext) ResponseData {
return res
}
func JamSaveTheDate(c *RequestContext) ResponseData {
var res ResponseData
type TemplateData struct {
templates.BaseData
UserAvatarUrl string
JamsUrl string
Visibility2023Url string
WRJ2023Url string
NewsletterSignupUrl string
}
tmpl := TemplateData{
BaseData: getBaseDataAutocrumb(c, "Upcoming Jams"),
UserAvatarUrl: templates.UserAvatarUrl(c.CurrentUser, "dark"),
JamsUrl: hmnurl.BuildJamsIndex(),
Visibility2023Url: hmnurl.BuildJamIndex2023_Visibility(),
WRJ2023Url: hmnurl.BuildJamIndex2023(),
NewsletterSignupUrl: hmnurl.BuildAPINewsletterSignup(),
}
tmpl.OpenGraphItems = []templates.OpenGraphItem{
{Property: "og:title", Value: "Upcoming Jams"},
{Property: "og:site_name", Value: "Handmade Network"},
{Property: "og:type", Value: "website"},
{Property: "og:image", Value: hmnurl.BuildPublic("HMNLogo_SaveThedate.png", true)},
{Property: "og:description", Value: "Upcoming programming jams from the Handmade community."},
{Property: "og:url", Value: hmnurl.BuildJamSaveTheDate()},
}
res.MustWriteTemplate("jam_save_the_date.html", tmpl, c.Perf)
return res
}
func JamIndex2024_Learning(c *RequestContext) ResponseData {
var res ResponseData

View File

@ -3,6 +3,7 @@ package website
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html"
"html/template"
@ -454,6 +455,15 @@ func (rd *ResponseData) MustWriteTemplate(name string, data interface{}, rp *per
}
}
func (rd *ResponseData) WriteJson(data any, rp *perf.RequestPerf) {
dataJson, err := json.Marshal(data)
if err != nil {
panic(err)
}
rd.Header().Set("Content-Type", "application/json")
rd.Write(dataJson)
}
func doRequest(rw http.ResponseWriter, c *RequestContext, h Handler) {
defer func() {
/*

View File

@ -55,13 +55,10 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler {
hmnOnly.GET(hmnurl.RegexProjectSubmissionGuidelines, ProjectSubmissionGuidelines)
hmnOnly.GET(hmnurl.RegexConferences, Conferences)
hmnOnly.GET(hmnurl.RegexWhenIsIt, WhenIsIt)
hmnOnly.GET(hmnurl.RegexNewsletterSignup, func(c *RequestContext) ResponseData {
return c.Redirect("https://cdn.forms-content.sg-form.com/9c83182a-f04a-11ed-a42d-f6f307313b7c", http.StatusFound)
})
hmnOnly.GET(hmnurl.RegexJamsIndex, JamsIndex)
hmnOnly.GET(hmnurl.RegexJamIndex, func(c *RequestContext) ResponseData {
return c.Redirect(hmnurl.BuildJamIndex2024_Learning(), http.StatusFound)
return c.Redirect(hmnurl.BuildJamSaveTheDate(), http.StatusFound)
})
hmnOnly.GET(hmnurl.RegexJamIndex2021, JamIndex2021)
hmnOnly.GET(hmnurl.RegexJamIndex2022, JamIndex2022)
@ -74,6 +71,7 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler {
hmnOnly.GET(hmnurl.RegexJamIndex2024_Learning, JamIndex2024_Learning)
hmnOnly.GET(hmnurl.RegexJamFeed2024_Learning, JamFeed2024_Learning)
hmnOnly.GET(hmnurl.RegexJamGuidelines2024_Learning, JamGuidelines2024_Learning)
hmnOnly.GET(hmnurl.RegexJamSaveTheDate, JamSaveTheDate)
hmnOnly.GET(hmnurl.RegexTimeMachine, TimeMachine)
hmnOnly.GET(hmnurl.RegexTimeMachineSubmissions, TimeMachineSubmissions)
@ -160,6 +158,7 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler {
hmnOnly.POST(hmnurl.RegexEducationArticleDelete, educationAuthorsOnly(csrfMiddleware(EducationArticleDeleteSubmit)))
hmnOnly.POST(hmnurl.RegexAPICheckUsername, csrfMiddleware(APICheckUsername))
hmnOnly.POST(hmnurl.RegexAPINewsletterSignup, APINewsletterSignup)
hmnOnly.GET(hmnurl.RegexLibraryAny, func(c *RequestContext) ResponseData {
return c.Redirect(hmnurl.BuildEducationIndex(), http.StatusFound)