Start porting landing page; rework db layer a bit
This commit is contained in:
parent
f7ac023c44
commit
8929a5d749
1
go.mod
1
go.mod
|
@ -10,6 +10,7 @@ require (
|
||||||
github.com/google/uuid v1.2.0 // indirect
|
github.com/google/uuid v1.2.0 // indirect
|
||||||
github.com/huandu/xstrings v1.3.2 // indirect
|
github.com/huandu/xstrings v1.3.2 // indirect
|
||||||
github.com/imdario/mergo v0.3.12 // indirect
|
github.com/imdario/mergo v0.3.12 // indirect
|
||||||
|
github.com/jackc/pgtype v1.6.2
|
||||||
github.com/jackc/pgx/v4 v4.10.1
|
github.com/jackc/pgx/v4 v4.10.1
|
||||||
github.com/julienschmidt/httprouter v1.3.0
|
github.com/julienschmidt/httprouter v1.3.0
|
||||||
github.com/mitchellh/copystructure v1.1.1 // indirect
|
github.com/mitchellh/copystructure v1.1.1 // indirect
|
||||||
|
|
|
@ -34,8 +34,7 @@ func makeSessionId() string {
|
||||||
var ErrNoSession = errors.New("no session found")
|
var ErrNoSession = errors.New("no session found")
|
||||||
|
|
||||||
func GetSession(ctx context.Context, conn *pgxpool.Pool, id string) (*models.Session, error) {
|
func GetSession(ctx context.Context, conn *pgxpool.Pool, id string) (*models.Session, error) {
|
||||||
var sess models.Session
|
row, err := db.QueryOne(ctx, conn, models.Session{}, "SELECT $columns FROM sessions WHERE id = $1", id)
|
||||||
err := db.QueryOneToStruct(ctx, conn, &sess, "SELECT $columns FROM sessions WHERE id = $1", id)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.ErrNoMatchingRows) {
|
if errors.Is(err, db.ErrNoMatchingRows) {
|
||||||
return nil, ErrNoSession
|
return nil, ErrNoSession
|
||||||
|
@ -43,8 +42,9 @@ func GetSession(ctx context.Context, conn *pgxpool.Pool, id string) (*models.Ses
|
||||||
return nil, oops.New(err, "failed to get session")
|
return nil, oops.New(err, "failed to get session")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
sess := row.(*models.Session)
|
||||||
|
|
||||||
return &sess, nil
|
return sess, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateSession(ctx context.Context, conn *pgxpool.Pool, username string) (*models.Session, error) {
|
func CreateSession(ctx context.Context, conn *pgxpool.Pool, username string) (*models.Session, error) {
|
||||||
|
|
143
src/db/db.go
143
src/db/db.go
|
@ -5,15 +5,19 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/config"
|
"git.handmade.network/hmn/hmn/src/config"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
|
"github.com/jackc/pgtype"
|
||||||
"github.com/jackc/pgx/v4"
|
"github.com/jackc/pgx/v4"
|
||||||
"github.com/jackc/pgx/v4/log/zerologadapter"
|
"github.com/jackc/pgx/v4/log/zerologadapter"
|
||||||
"github.com/jackc/pgx/v4/pgxpool"
|
"github.com/jackc/pgx/v4/pgxpool"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var connInfo = pgtype.NewConnInfo()
|
||||||
|
|
||||||
func NewConn() *pgx.Conn {
|
func NewConn() *pgx.Conn {
|
||||||
conn, err := pgx.Connect(context.Background(), config.Config.Postgres.DSN())
|
conn, err := pgx.Connect(context.Background(), config.Config.Postgres.DSN())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -40,20 +44,18 @@ func NewConnPool(minConns, maxConns int32) *pgxpool.Pool {
|
||||||
}
|
}
|
||||||
|
|
||||||
type StructQueryIterator struct {
|
type StructQueryIterator struct {
|
||||||
fieldIndices []int
|
fieldPaths [][]int
|
||||||
rows pgx.Rows
|
rows pgx.Rows
|
||||||
|
destType reflect.Type
|
||||||
}
|
}
|
||||||
|
|
||||||
func (it *StructQueryIterator) Next(dest interface{}) bool {
|
func (it *StructQueryIterator) Next() (interface{}, bool) {
|
||||||
hasNext := it.rows.Next()
|
hasNext := it.rows.Next()
|
||||||
if !hasNext {
|
if !hasNext {
|
||||||
return false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
v := reflect.ValueOf(dest)
|
result := reflect.New(it.destType)
|
||||||
if v.Kind() != reflect.Ptr {
|
|
||||||
panic(oops.New(nil, "Next requires a pointer type; got %v", v.Kind()))
|
|
||||||
}
|
|
||||||
|
|
||||||
vals, err := it.rows.Values()
|
vals, err := it.rows.Values()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -61,47 +63,70 @@ func (it *StructQueryIterator) Next(dest interface{}) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, val := range vals {
|
for i, val := range vals {
|
||||||
field := v.Elem().Field(it.fieldIndices[i])
|
if val == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
field := followPathThroughStructs(result, it.fieldPaths[i])
|
||||||
|
if field.Kind() == reflect.Ptr {
|
||||||
|
field.Set(reflect.New(field.Type().Elem()))
|
||||||
|
field = field.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
switch field.Kind() {
|
switch field.Kind() {
|
||||||
case reflect.Int:
|
case reflect.Int:
|
||||||
field.SetInt(reflect.ValueOf(val).Int())
|
field.SetInt(reflect.ValueOf(val).Int())
|
||||||
case reflect.Ptr:
|
|
||||||
// TODO: I'm pretty sure we don't handle nullable ints correctly lol. Maybe this needs to be a function somehow, and recurse onto itself?? Reflection + recursion sounds like a great idea
|
|
||||||
if val != nil {
|
|
||||||
field.Set(reflect.New(field.Type().Elem()))
|
|
||||||
field.Elem().Set(reflect.ValueOf(val))
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
field.Set(reflect.ValueOf(val))
|
field.Set(reflect.ValueOf(val))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return result.Interface(), true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (it *StructQueryIterator) Close() {
|
func (it *StructQueryIterator) Close() {
|
||||||
it.rows.Close()
|
it.rows.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func QueryToStructs(ctx context.Context, conn *pgxpool.Pool, destType interface{}, query string, args ...interface{}) (StructQueryIterator, error) {
|
func (it *StructQueryIterator) ToSlice() []interface{} {
|
||||||
var fieldIndices []int
|
defer it.Close()
|
||||||
var columnNames []string
|
var result []interface{}
|
||||||
|
for {
|
||||||
|
row, ok := it.Next()
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
result = append(result, row)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
t := reflect.TypeOf(destType)
|
func followPathThroughStructs(structVal reflect.Value, path []int) reflect.Value {
|
||||||
if t.Kind() == reflect.Ptr {
|
if len(path) < 1 {
|
||||||
t = t.Elem()
|
panic("can't follow an empty path")
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.Kind() != reflect.Struct {
|
val := structVal
|
||||||
return StructQueryIterator{}, oops.New(nil, "QueryToStructs requires a struct type or a pointer to a struct type")
|
for _, i := range path {
|
||||||
|
if val.Kind() == reflect.Ptr && val.Type().Elem().Kind() == reflect.Struct {
|
||||||
|
if val.IsNil() {
|
||||||
|
val.Set(reflect.New(val.Type()))
|
||||||
}
|
}
|
||||||
|
val = val.Elem()
|
||||||
|
}
|
||||||
|
val = val.Field(i)
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
for i := 0; i < t.NumField(); i++ {
|
func Query(ctx context.Context, conn *pgxpool.Pool, destExample interface{}, query string, args ...interface{}) (StructQueryIterator, error) {
|
||||||
f := t.Field(i)
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
if columnName := f.Tag.Get("db"); columnName != "" {
|
defer cancel()
|
||||||
fieldIndices = append(fieldIndices, i)
|
|
||||||
columnNames = append(columnNames, columnName)
|
destType := reflect.TypeOf(destExample)
|
||||||
}
|
columnNames, fieldPaths, err := getColumnNamesAndPaths(destType, nil, "")
|
||||||
|
if err != nil {
|
||||||
|
return StructQueryIterator{}, oops.New(err, "failed to generate column names")
|
||||||
}
|
}
|
||||||
|
|
||||||
columnNamesString := strings.Join(columnNames, ", ")
|
columnNamesString := strings.Join(columnNames, ", ")
|
||||||
|
@ -109,28 +134,74 @@ func QueryToStructs(ctx context.Context, conn *pgxpool.Pool, destType interface{
|
||||||
|
|
||||||
rows, err := conn.Query(ctx, query, args...)
|
rows, err := conn.Query(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
panic("query exceeded its deadline")
|
||||||
|
}
|
||||||
return StructQueryIterator{}, err
|
return StructQueryIterator{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return StructQueryIterator{
|
return StructQueryIterator{
|
||||||
fieldIndices: fieldIndices,
|
fieldPaths: fieldPaths,
|
||||||
rows: rows,
|
rows: rows,
|
||||||
|
destType: destType,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix string) ([]string, [][]int, error) {
|
||||||
|
var columnNames []string
|
||||||
|
var fieldPaths [][]int
|
||||||
|
|
||||||
|
if destType.Kind() == reflect.Ptr {
|
||||||
|
destType = destType.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
if destType.Kind() != reflect.Struct {
|
||||||
|
return nil, nil, oops.New(nil, "can only get column names and paths from a struct, got type '%v' (at prefix '%v')", destType.Name(), prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < destType.NumField(); i++ {
|
||||||
|
field := destType.Field(i)
|
||||||
|
path := append(pathSoFar, i)
|
||||||
|
|
||||||
|
if columnName := field.Tag.Get("db"); columnName != "" {
|
||||||
|
fieldType := field.Type
|
||||||
|
if destType.Kind() == reflect.Ptr {
|
||||||
|
fieldType = destType.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
_, isRecognizedByPgtype := connInfo.DataTypeForValue(reflect.New(fieldType)) // if pgtype recognizes it, we don't need to dig in further for more `db` tags
|
||||||
|
// NOTE: boy it would be nice if we didn't have to do reflect.New here, considering that pgtype is just doing reflection on the value anyway
|
||||||
|
|
||||||
|
if fieldType.Kind() == reflect.Struct && !isRecognizedByPgtype {
|
||||||
|
subCols, subPaths, err := getColumnNamesAndPaths(fieldType, path, columnName+".")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
columnNames = append(columnNames, subCols...)
|
||||||
|
fieldPaths = append(fieldPaths, subPaths...)
|
||||||
|
} else {
|
||||||
|
columnNames = append(columnNames, prefix+columnName)
|
||||||
|
fieldPaths = append(fieldPaths, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return columnNames, fieldPaths, nil
|
||||||
|
}
|
||||||
|
|
||||||
var ErrNoMatchingRows = errors.New("no matching rows")
|
var ErrNoMatchingRows = errors.New("no matching rows")
|
||||||
|
|
||||||
func QueryOneToStruct(ctx context.Context, conn *pgxpool.Pool, dest interface{}, query string, args ...interface{}) error {
|
func QueryOne(ctx context.Context, conn *pgxpool.Pool, destExample interface{}, query string, args ...interface{}) (interface{}, error) {
|
||||||
rows, err := QueryToStructs(ctx, conn, dest, query, args...)
|
rows, err := Query(ctx, conn, destExample, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
hasRow := rows.Next(dest)
|
result, hasRow := rows.Next()
|
||||||
if !hasRow {
|
if !hasRow {
|
||||||
return ErrNoMatchingRows
|
return nil, ErrNoMatchingRows
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
type CategoryType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
CatTypeBlog CategoryType = iota + 1
|
||||||
|
CatTypeForum
|
||||||
|
CatTypeStatic
|
||||||
|
CatTypeAnnotation
|
||||||
|
CatTypeWiki
|
||||||
|
CatTypeLibraryResource
|
||||||
|
)
|
||||||
|
|
||||||
|
type Category struct {
|
||||||
|
ID int `db:"id"`
|
||||||
|
|
||||||
|
ParentID *int `db:"parent_id"`
|
||||||
|
ProjectID *int `db:"project_id"`
|
||||||
|
|
||||||
|
Slug *string `db:"slug"`
|
||||||
|
Name *string `db:"name"`
|
||||||
|
Blurb *string `db:"blurb"`
|
||||||
|
Kind CategoryType `db:"kind"`
|
||||||
|
Color1 string `db:"color_1"`
|
||||||
|
Color2 string `db:"color_2"`
|
||||||
|
Depth int `db:"depth"`
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Post struct {
|
||||||
|
ID int `db:"id"`
|
||||||
|
|
||||||
|
// TODO: Document each of these
|
||||||
|
AuthorID *int `db:"author_id"`
|
||||||
|
CategoryID int `db:"category_id"`
|
||||||
|
ParentID *int `db:"parent_id"`
|
||||||
|
ThreadID *int `db:"thread_id"`
|
||||||
|
CurrentID int `db:"current_id"`
|
||||||
|
|
||||||
|
Depth int `db:"depth"`
|
||||||
|
Slug string `db:"slug"`
|
||||||
|
AuthorName string `db:"author_name"`
|
||||||
|
PostDate time.Time `db:"postdate"`
|
||||||
|
IP net.IPNet `db:"ip"`
|
||||||
|
Sticky bool `db:"sticky"`
|
||||||
|
Moderated bool `db:"moderated"` // TODO: I'm not sure this is ever meaningfully used. It always seems to be 0 / false?
|
||||||
|
Hits int `db:"hits"`
|
||||||
|
Featured bool `db:"featured"`
|
||||||
|
FeatureVotes int `db:"featurevotes"` // TODO: Remove this column from the db, it's never used
|
||||||
|
|
||||||
|
Preview string `db:"preview"`
|
||||||
|
ReadOnly bool `db:"readonly"`
|
||||||
|
}
|
|
@ -1,14 +1,18 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
|
import "reflect"
|
||||||
|
|
||||||
const HMNProjectID = 1
|
const HMNProjectID = 1
|
||||||
|
|
||||||
|
var ProjectType = reflect.TypeOf(Project{})
|
||||||
|
|
||||||
type Project struct {
|
type Project struct {
|
||||||
ID int `db:"id"`
|
ID int `db:"id"`
|
||||||
|
|
||||||
Slug string `db:"slug"`
|
Slug *string `db:"slug"` // TODO: Migrate these to NOT NULL
|
||||||
Name string `db:"name"`
|
Name *string `db:"name"`
|
||||||
Blurb string `db:"blurb"`
|
Blurb *string `db:"blurb"`
|
||||||
Description string `db:"description"`
|
Description *string `db:"description"`
|
||||||
|
|
||||||
Color1 string `db:"color_1"`
|
Color1 string `db:"color_1"`
|
||||||
Color2 string `db:"color_2"`
|
Color2 string `db:"color_2"`
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
type Thread struct {
|
||||||
|
ID int `db:"id"`
|
||||||
|
|
||||||
|
CategoryID int `db:"category_id"`
|
||||||
|
|
||||||
|
Title string `db:"title"`
|
||||||
|
Hits int `db:"hits"`
|
||||||
|
ReplyCount int `db:"reply_count"`
|
||||||
|
Sticky bool `db:"sticky"`
|
||||||
|
Locked bool `db:"locked"`
|
||||||
|
Moderated int `db:"moderated"`
|
||||||
|
|
||||||
|
FirstID *int `db:"first_id"`
|
||||||
|
LastID *int `db:"last_id"`
|
||||||
|
}
|
|
@ -1,6 +1,11 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"reflect"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var UserType = reflect.TypeOf(User{})
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int `db:"id"`
|
ID int `db:"id"`
|
||||||
|
|
|
@ -3,3 +3,200 @@
|
||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
This is the index page.
|
This is the index page.
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
{{ define "extrahead" }}
|
||||||
|
<link rel="stylesheet" type="text/css" href="{{ static "landing.css" }}"/>
|
||||||
|
<script type="text/javascript" src="{{ static "util.js" }}"></script>
|
||||||
|
<style type="text/css">
|
||||||
|
{{ range _, $col := .RecentPostColumns }}
|
||||||
|
{{ range _, $entry := $col }}
|
||||||
|
{{ $themeDim := eq .Theme "dark" | ternary (darken .Color 0.5) (brighten .Color 0.2) }}
|
||||||
|
{{ $themeDimmer := eq .Theme "dark" | ternary (darken .Color 0.65) (brighten .Color 0.4) }}
|
||||||
|
{{ $themeDimmest := eq .Theme "dark" | ternary (darken .Color 0.8) (brighten .Color 0.6) }}
|
||||||
|
|
||||||
|
{{ $linkColor := eq .Theme "dark" | ternary (brighten .Color 0.1) (darken .Color 0.2) }}
|
||||||
|
{{ $linkHoverColor := eq .Theme "dark" | ternary (brighten .Color 0.2) (darken .Color 0.1) }}
|
||||||
|
|
||||||
|
{{ eq .Theme "dark" }}
|
||||||
|
#p{{ .Project.Subdomain }} .unread a { color: #{% rgb_accent entry.project.color_1 0.55 %}; }
|
||||||
|
#p{{ .Project.Subdomain }} .unread a:hover { color: #{% rgb_accent entry.project.color_1 0.65 %}; }
|
||||||
|
#p{{ .Project.Subdomain }} .unread .avatar-icon { border: 2px solid #{% rgb_accent entry.project.color_1 0.55 %}; }
|
||||||
|
#p{{ .Project.Subdomain }} .thread:nth-of-type(even) { background-color:#{% rgb_accent entry.project.color_1 0.14 False 0.03%}; }
|
||||||
|
#p{{ .Project.Subdomain }} .forum .post:nth-of-type(even) { background-color:#{% rgb_accent entry.project.color_1 0.14 False 0.03 %}; }
|
||||||
|
#p{{ .Project.Subdomain }} .blog .post:nth-of-type(even) { background-color:#{% rgb_accent entry.project.color_1 0.14 False 0.03 %}; }
|
||||||
|
{{ else }}
|
||||||
|
#p{{ .Project.Subdomain }} .unread a { color: #{% rgb_accent entry.project.color_1 0.35 %}; }
|
||||||
|
#p{{ .Project.Subdomain }} .unread a:hover { color: #{% rgb_accent entry.project.color_1 0.45 %}; }
|
||||||
|
#p{{ .Project.Subdomain }} .unread .avatar-icon { border: 2px solid #{% rgb_accent entry.project.color_1 0.35 %}; }
|
||||||
|
#p{{ .Project.Subdomain }} .thread:nth-of-type(even) { background-color:#{% rgb_accent entry.project.color_1 0.94 False 0.2 %}; }
|
||||||
|
#p{{ .Project.Subdomain }} .forum .post:nth-of-type(even) { background-color:#{% rgb_accent entry.project.color_1 0.94 False 0.2 %}; }
|
||||||
|
#p{{ .Project.Subdomain }} .blog .post:nth-of-type(even) { background-color:#{% rgb_accent entry.project.color_1 0.94 False 0.2 %}; }
|
||||||
|
{{ end }}
|
||||||
|
#p{{ .Project.Subdomain }} .thread.more { background-color:transparent; }
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
</style>
|
||||||
|
<script type="text/javascript" src="{% static 'templates.js' %}?v={% cachebust %}"></script>
|
||||||
|
<script type="text/javascript" src="{% static 'timeline.js' %}?v={% cachebust %}"></script>
|
||||||
|
<script type="text/javascript" src="{% static 'showcase.js' %}?v={% cachebust %}"></script>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{% block columns %}
|
||||||
|
{% include "showcase/js_templates.html" %}
|
||||||
|
{% include "timeline/js_templates.html" %}
|
||||||
|
<div class="content-block pb3">
|
||||||
|
<div class="tc tl-l w-100 pb2">
|
||||||
|
<h2 class="di-l mr2-l">Community Showcase</h2>
|
||||||
|
<ul class="list dib-l">
|
||||||
|
<li class="dib-ns ma0 ph2">
|
||||||
|
<a href="{% url 'showcase' %}">View all</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="showcase relative overflow-hidden">
|
||||||
|
<div id="showcase-items" class="flex relative pl3 pl0-ns"></div>
|
||||||
|
<div class="arrow-container left">
|
||||||
|
<a href="javascript:void(0)" class="arrow svgicon svgicon-nofix" onclick="scrollShowcase('left')">{% svg 'chevron-left' %}</a>
|
||||||
|
</div>
|
||||||
|
<div class="arrow-container right">
|
||||||
|
<a href="javascript:void(0)" class="arrow svgicon svgicon-nofix" onclick="scrollShowcase('right')">{% svg 'chevron-right' %}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="c--dimmer i pv2 ph3 ph0-ns">
|
||||||
|
This is a selection of recent work done by community members. Want to participate? <a href="https://discord.gg/hxWxDee" target="_blank">Join us on Discord.</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const timelineData = JSON.parse("{{ showcase_timeline_json|escapejs }}");
|
||||||
|
|
||||||
|
const showcaseEl = document.querySelector('#showcase-items');
|
||||||
|
for (const item of timelineData.items) {
|
||||||
|
const [itemEl, addThumbnail] = makeShowcaseItem(item);
|
||||||
|
addThumbnail();
|
||||||
|
itemEl.container.classList.add('mr3');
|
||||||
|
showcaseEl.appendChild(itemEl.root);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rem2px(rem) {
|
||||||
|
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollShowcase(direction = null) {
|
||||||
|
const ITEM_WIDTH = showcaseEl.querySelector('.showcase-item').getBoundingClientRect().width;
|
||||||
|
const ITEM_SPACING = rem2px(1);
|
||||||
|
|
||||||
|
const showcaseWidth = showcaseEl.getBoundingClientRect().width;
|
||||||
|
const numVisible = showcaseWidth / (ITEM_WIDTH + ITEM_SPACING);
|
||||||
|
const scrollMagnitude = Math.floor(numVisible) - 1;
|
||||||
|
const scrollDirection = (direction === 'right' ? 1 : (direction === 'left' ? -1 : 0));
|
||||||
|
const scrollAmount = scrollMagnitude * scrollDirection;
|
||||||
|
|
||||||
|
const minIndex = 0;
|
||||||
|
const maxIndex = timelineData.items.length - Math.floor(numVisible);
|
||||||
|
|
||||||
|
const currentScrollIndex = parseInt(showcaseEl.getAttribute('data-scroll-index'), 10) || 0;
|
||||||
|
const newScrollIndex = Math.max(minIndex, Math.min(maxIndex, currentScrollIndex + scrollAmount));
|
||||||
|
|
||||||
|
showcaseEl.style.transform = `translateX(${-newScrollIndex * (ITEM_WIDTH + ITEM_SPACING)}px)`;
|
||||||
|
showcaseEl.setAttribute('data-scroll-index', newScrollIndex);
|
||||||
|
|
||||||
|
const leftArrowEl = document.querySelector('.arrow-container.left');
|
||||||
|
const rightArrowEl = document.querySelector('.arrow-container.right');
|
||||||
|
|
||||||
|
leftArrowEl.classList.toggle('hide', newScrollIndex === minIndex);
|
||||||
|
rightArrowEl.classList.toggle('hide', newScrollIndex === maxIndex);
|
||||||
|
}
|
||||||
|
scrollShowcase(); // force a scroll as an easy way to initialize styles
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => scrollShowcase());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="content-block">
|
||||||
|
<div class="optionbar pb2">
|
||||||
|
<div class="tc tl-l w-100">
|
||||||
|
<h2 class="di-l mr2-l">Around the Network</h2>
|
||||||
|
<ul class="list dib-l">
|
||||||
|
<li class="dib-ns ma0 ph2">
|
||||||
|
<a href="{% url 'feed' %}">View all posts on HMN</a>
|
||||||
|
</li>
|
||||||
|
<li class="dib-ns ma0 ph2">
|
||||||
|
<a href="{% url 'podcast' %}">Podcast</a>
|
||||||
|
</li>
|
||||||
|
<!-- <li class="dib-ns ma0 ph2">
|
||||||
|
<a href="{% url 'streams' %}">See who's live</a>
|
||||||
|
</li> -->
|
||||||
|
<li class="dib-ns ma0 ph2">
|
||||||
|
<a href="/blogs/p/1138-%5Btutorial%5D_handmade_network_irc" target="_blank">Chat in IRC</a>
|
||||||
|
</li>
|
||||||
|
<li class="dib-ns ma0 ph2">
|
||||||
|
<a href="https://discord.gg/hxWxDee" target="_blank">Chat on Discord</a>
|
||||||
|
</li>
|
||||||
|
<li class="dib-ns ma0 ph2">
|
||||||
|
<a href="https://handmadedev.show/" target="_blank">See the Show</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% spaceless %}
|
||||||
|
<div class="content-block news cf">
|
||||||
|
{% for col in recent_post_columns %}
|
||||||
|
<div class="fl w-100 w-50-l">
|
||||||
|
<div class="mw7 mw-none-l center-layout">
|
||||||
|
{% if forloop.counter == 1 %}
|
||||||
|
<div class="pt3">
|
||||||
|
{% include "blog_index_thread_list_entry.html" with post=featured_post align_top=True %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for entry in col %}
|
||||||
|
{% with proj=entry.project posts=entry.posts %}
|
||||||
|
<div class="pt3" id="p{{proj.slug}}">
|
||||||
|
<a
|
||||||
|
href="{% url 'cover_page' subdomain=proj.slug %}"
|
||||||
|
{% if user|get_theme == 'dark' %}
|
||||||
|
style="color:#{% rgb_accent proj.color_1 0.55 %};"
|
||||||
|
{% else %}
|
||||||
|
style="color:#{% rgb_accent proj.color_1 0.25 %};"
|
||||||
|
{% endif %}
|
||||||
|
>
|
||||||
|
<h2 class="ph3">{{ proj.name }}</h2>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if entry.featured and proj.slug != "hmn" %}
|
||||||
|
{% with post=entry.featured.0 has_read=entry.featured.1 %}
|
||||||
|
{% if post.category.kind == 5 and post.parent == None %}
|
||||||
|
{% include "thread_list_entry.html" with thread=post.thread %}
|
||||||
|
{% else %}
|
||||||
|
{% include "blog_index_thread_list_entry.html" with align_top=True %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for post, has_read in posts %}
|
||||||
|
{% if forloop.counter0 < max_posts %}
|
||||||
|
{% include "thread_list_entry.html" with thread=post.thread %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% with more=posts|length|add:-5|clamp_lower:0 %}
|
||||||
|
{% if more > 0 %}
|
||||||
|
<div class="ph3 thread unread more">
|
||||||
|
<a class="title"
|
||||||
|
href="{% url 'project_forum' subdomain=proj.slug %}"
|
||||||
|
>{{ more }} more recently →</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endspaceless %}
|
||||||
|
{% endblock %}
|
||||||
|
*/}}
|
|
@ -63,7 +63,7 @@
|
||||||
<body class="{{ join " " .BodyClasses }}">
|
<body class="{{ join " " .BodyClasses }}">
|
||||||
<div class="content mw-site ph3-m ph4-l">
|
<div class="content mw-site ph3-m ph4-l">
|
||||||
{{ template "header.html" . }}
|
{{ template "header.html" . }}
|
||||||
based
|
{{ block "content" . }}{{ end }}
|
||||||
{{ template "footer.html" . }}
|
{{ template "footer.html" . }}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
@ -42,3 +42,6 @@ type BackgroundImage struct {
|
||||||
Url string
|
Url string
|
||||||
Size string // A valid CSS background-size value
|
Size string // A valid CSS background-size value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Post struct {
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,148 @@
|
||||||
|
package website
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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/templates"
|
||||||
|
"github.com/julienschmidt/httprouter"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LandingTemplateData struct {
|
||||||
|
templates.BaseData
|
||||||
|
|
||||||
|
PostColumns [][]LandingPageProject
|
||||||
|
ShowcaseTimelineJson string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LandingPageProject struct {
|
||||||
|
Project templates.Project
|
||||||
|
FeaturedPost *LandingPagePost
|
||||||
|
Posts []LandingPagePost
|
||||||
|
}
|
||||||
|
|
||||||
|
type LandingPagePost struct {
|
||||||
|
Post templates.Post
|
||||||
|
HasRead bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *websiteRoutes) Index(c *RequestContext, p httprouter.Params) {
|
||||||
|
const maxPosts = 5
|
||||||
|
const numProjectsToGet = 7
|
||||||
|
|
||||||
|
iterProjects, err := db.Query(c.Context(), s.conn, models.Project{},
|
||||||
|
"SELECT $columns FROM handmade_project WHERE flags = 0 OR id = $1",
|
||||||
|
models.HMNProjectID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.Errored(http.StatusInternalServerError, oops.New(err, "failed to get projects for home page"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer iterProjects.Close()
|
||||||
|
|
||||||
|
var pageProjects []LandingPageProject
|
||||||
|
_ = pageProjects // TODO: NO
|
||||||
|
|
||||||
|
for _, projRow := range iterProjects.ToSlice() {
|
||||||
|
proj := projRow.(*models.Project)
|
||||||
|
|
||||||
|
type ProjectPost struct {
|
||||||
|
Post models.Post `db:"post"`
|
||||||
|
ThreadLastReadTime *time.Time `db:"tlri.lastread"`
|
||||||
|
CatLastReadTime *time.Time `db:"clri.lastread"`
|
||||||
|
}
|
||||||
|
|
||||||
|
memberId := 3 // TODO: NO
|
||||||
|
projectPostIter, err := db.Query(c.Context(), s.conn, ProjectPost{},
|
||||||
|
`
|
||||||
|
SELECT $columns
|
||||||
|
FROM
|
||||||
|
handmade_post AS post
|
||||||
|
JOIN handmade_thread AS thread ON thread.id = post.thread_id
|
||||||
|
JOIN handmade_category AS cat ON cat.id = thread.category_id
|
||||||
|
LEFT OUTER JOIN handmade_threadlastreadinfo AS tlri ON (
|
||||||
|
tlri.thread_id = thread.id
|
||||||
|
AND tlri.member_id = $1
|
||||||
|
)
|
||||||
|
LEFT OUTER JOIN handmade_categorylastreadinfo AS clri ON (
|
||||||
|
clri.category_id = cat.id
|
||||||
|
AND clri.member_id = $1
|
||||||
|
)
|
||||||
|
WHERE
|
||||||
|
cat.project_id = $2
|
||||||
|
AND cat.kind IN ($3, $4, $5, $6)
|
||||||
|
AND post.moderated = FALSE
|
||||||
|
AND post.thread_id IS NOT NULL
|
||||||
|
ORDER BY postdate DESC
|
||||||
|
LIMIT $7
|
||||||
|
`,
|
||||||
|
memberId,
|
||||||
|
proj.ID,
|
||||||
|
models.CatTypeBlog, models.CatTypeForum, models.CatTypeWiki, models.CatTypeLibraryResource,
|
||||||
|
maxPosts,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.Logger.Error().Err(err).Msg("failed to fetch project posts")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
projectPosts := projectPostIter.ToSlice()
|
||||||
|
|
||||||
|
landingPageProject := LandingPageProject{
|
||||||
|
Project: templates.Project{ // TODO: Use a common function to map from model to template data
|
||||||
|
Name: *proj.Name,
|
||||||
|
Subdomain: *proj.Slug,
|
||||||
|
// ...
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, projectPostRow := range projectPosts {
|
||||||
|
projectPost := projectPostRow.(*ProjectPost)
|
||||||
|
|
||||||
|
hasRead := false
|
||||||
|
if projectPost.ThreadLastReadTime != nil && projectPost.ThreadLastReadTime.After(projectPost.Post.PostDate) {
|
||||||
|
hasRead = true
|
||||||
|
} else if projectPost.CatLastReadTime != nil && projectPost.CatLastReadTime.After(projectPost.Post.PostDate) {
|
||||||
|
hasRead = true
|
||||||
|
}
|
||||||
|
|
||||||
|
landingPageProject.Posts = append(landingPageProject.Posts, LandingPagePost{
|
||||||
|
Post: templates.Post{}, // TODO: Use a common function to map from model to template again
|
||||||
|
HasRead: hasRead,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type newsThreadQuery struct {
|
||||||
|
Thread models.Thread `db:"thread"`
|
||||||
|
}
|
||||||
|
newsThreadRow, err := db.QueryOne(c.Context(), s.conn, newsThreadQuery{},
|
||||||
|
`
|
||||||
|
SELECT $columns
|
||||||
|
FROM
|
||||||
|
handmade_thread as thread
|
||||||
|
JOIN handmade_category AS cat ON thread.category_id = cat.id
|
||||||
|
WHERE
|
||||||
|
cat.project_id = $1
|
||||||
|
AND cat.kind = $2
|
||||||
|
`,
|
||||||
|
models.HMNProjectID,
|
||||||
|
models.CatTypeBlog,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.Errored(http.StatusInternalServerError, oops.New(err, "failed to fetch latest news post"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newsThread := newsThreadRow.(*newsThreadQuery)
|
||||||
|
_ = newsThread // TODO: NO
|
||||||
|
|
||||||
|
baseData := s.getBaseData(c)
|
||||||
|
baseData.BodyClasses = append(baseData.BodyClasses, "hmdev", "landing") // TODO: Is "hmdev" necessary any more?
|
||||||
|
|
||||||
|
err = c.WriteTemplate("index.html", s.getBaseData(c))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
|
@ -33,7 +33,13 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler {
|
||||||
}
|
}
|
||||||
|
|
||||||
mainRoutes := routes.WithWrappers(routes.CommonWebsiteDataWrapper)
|
mainRoutes := routes.WithWrappers(routes.CommonWebsiteDataWrapper)
|
||||||
mainRoutes.GET("/", routes.Index)
|
mainRoutes.GET("/", func(c *RequestContext, p httprouter.Params) {
|
||||||
|
if c.currentProject.ID == models.HMNProjectID {
|
||||||
|
routes.Index(c, p)
|
||||||
|
} else {
|
||||||
|
// TODO: Return the project landing page
|
||||||
|
}
|
||||||
|
})
|
||||||
mainRoutes.GET("/project/:id", routes.Project)
|
mainRoutes.GET("/project/:id", routes.Project)
|
||||||
mainRoutes.GET("/assets/project.css", routes.ProjectCSS)
|
mainRoutes.GET("/assets/project.css", routes.ProjectCSS)
|
||||||
|
|
||||||
|
@ -58,8 +64,8 @@ func (s *websiteRoutes) getBaseData(c *RequestContext) templates.BaseData {
|
||||||
|
|
||||||
return templates.BaseData{
|
return templates.BaseData{
|
||||||
Project: templates.Project{
|
Project: templates.Project{
|
||||||
Name: c.currentProject.Name,
|
Name: *c.currentProject.Name,
|
||||||
Subdomain: c.currentProject.Slug,
|
Subdomain: *c.currentProject.Slug,
|
||||||
Color: c.currentProject.Color1,
|
Color: c.currentProject.Color1,
|
||||||
|
|
||||||
IsHMN: c.currentProject.IsHMN(),
|
IsHMN: c.currentProject.IsHMN(),
|
||||||
|
@ -75,16 +81,15 @@ func (s *websiteRoutes) getBaseData(c *RequestContext) templates.BaseData {
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchProjectBySlug(ctx context.Context, conn *pgxpool.Pool, slug string) (*models.Project, error) {
|
func FetchProjectBySlug(ctx context.Context, conn *pgxpool.Pool, slug string) (*models.Project, error) {
|
||||||
var subdomainProject models.Project
|
subdomainProjectRow, err := db.QueryOne(ctx, conn, models.Project{}, "SELECT $columns FROM handmade_project WHERE slug = $1", slug)
|
||||||
err := db.QueryOneToStruct(ctx, conn, &subdomainProject, "SELECT $columns FROM handmade_project WHERE slug = $1", slug)
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
subdomainProject := subdomainProjectRow.(models.Project)
|
||||||
return &subdomainProject, nil
|
return &subdomainProject, nil
|
||||||
} else if !errors.Is(err, db.ErrNoMatchingRows) {
|
} else if !errors.Is(err, db.ErrNoMatchingRows) {
|
||||||
return nil, oops.New(err, "failed to get projects by slug")
|
return nil, oops.New(err, "failed to get projects by slug")
|
||||||
}
|
}
|
||||||
|
|
||||||
var defaultProject models.Project
|
defaultProjectRow, err := db.QueryOne(ctx, conn, models.Project{}, "SELECT $columns FROM handmade_project WHERE id = $1", models.HMNProjectID)
|
||||||
err = db.QueryOneToStruct(ctx, conn, &defaultProject, "SELECT $columns FROM handmade_project WHERE id = $1", models.HMNProjectID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.ErrNoMatchingRows) {
|
if errors.Is(err, db.ErrNoMatchingRows) {
|
||||||
return nil, oops.New(nil, "default project didn't exist in the database")
|
return nil, oops.New(nil, "default project didn't exist in the database")
|
||||||
|
@ -92,15 +97,9 @@ func FetchProjectBySlug(ctx context.Context, conn *pgxpool.Pool, slug string) (*
|
||||||
return nil, oops.New(err, "failed to get default project")
|
return nil, oops.New(err, "failed to get default project")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
defaultProject := defaultProjectRow.(*models.Project)
|
||||||
|
|
||||||
return &defaultProject, nil
|
return defaultProject, nil
|
||||||
}
|
|
||||||
|
|
||||||
func (s *websiteRoutes) Index(c *RequestContext, p httprouter.Params) {
|
|
||||||
err := c.WriteTemplate("index.html", s.getBaseData(c))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *websiteRoutes) Project(c *RequestContext, p httprouter.Params) {
|
func (s *websiteRoutes) Project(c *RequestContext, p httprouter.Params) {
|
||||||
|
@ -159,8 +158,7 @@ func (s *websiteRoutes) Login(c *RequestContext, p httprouter.Params) {
|
||||||
redirect = "/"
|
redirect = "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
var user models.User
|
userRow, err := db.QueryOne(c.Context(), s.conn, models.User{}, "SELECT $columns FROM auth_user WHERE username = $1", username)
|
||||||
err = db.QueryOneToStruct(c.Context(), s.conn, &user, "SELECT $columns FROM auth_user WHERE username = $1", username)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.ErrNoMatchingRows) {
|
if errors.Is(err, db.ErrNoMatchingRows) {
|
||||||
c.StatusCode = http.StatusUnauthorized
|
c.StatusCode = http.StatusUnauthorized
|
||||||
|
@ -169,6 +167,7 @@ func (s *websiteRoutes) Login(c *RequestContext, p httprouter.Params) {
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
user := userRow.(models.User)
|
||||||
|
|
||||||
hashed, err := auth.ParsePasswordString(user.Password)
|
hashed, err := auth.ParsePasswordString(user.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -285,8 +284,7 @@ func (s *websiteRoutes) getCurrentUserAndMember(ctx context.Context, sessionId s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var user models.User
|
userRow, err := db.QueryOne(ctx, s.conn, models.User{}, "SELECT $columns FROM auth_user WHERE username = $1", session.Username)
|
||||||
err = db.QueryOneToStruct(ctx, s.conn, &user, "SELECT $columns FROM auth_user WHERE username = $1", session.Username)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.ErrNoMatchingRows) {
|
if errors.Is(err, db.ErrNoMatchingRows) {
|
||||||
logging.Debug().Str("username", session.Username).Msg("returning no current user for this request because the user for the session couldn't be found")
|
logging.Debug().Str("username", session.Username).Msg("returning no current user for this request because the user for the session couldn't be found")
|
||||||
|
@ -295,8 +293,9 @@ func (s *websiteRoutes) getCurrentUserAndMember(ctx context.Context, sessionId s
|
||||||
return nil, oops.New(err, "failed to get user for session")
|
return nil, oops.New(err, "failed to get user for session")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
user := userRow.(*models.User)
|
||||||
|
|
||||||
// TODO: Also get the member model
|
// TODO: Also get the member model
|
||||||
|
|
||||||
return &user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,7 +25,7 @@ var WebsiteCommand = &cobra.Command{
|
||||||
|
|
||||||
logging.Info().Msg("Hello, HMN!")
|
logging.Info().Msg("Hello, HMN!")
|
||||||
|
|
||||||
conn := db.NewConnPool(4, 8)
|
conn := db.NewConnPool(4, 128)
|
||||||
|
|
||||||
server := http.Server{
|
server := http.Server{
|
||||||
Addr: config.Config.Addr,
|
Addr: config.Config.Addr,
|
||||||
|
@ -42,10 +42,12 @@ var WebsiteCommand = &cobra.Command{
|
||||||
go func() {
|
go func() {
|
||||||
<-signals
|
<-signals
|
||||||
logging.Info().Msg("Shutting down the website")
|
logging.Info().Msg("Shutting down the website")
|
||||||
|
go func() {
|
||||||
timeout, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
timeout, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
server.Shutdown(timeout)
|
server.Shutdown(timeout)
|
||||||
cancelBackgroundJobs()
|
cancelBackgroundJobs()
|
||||||
|
}()
|
||||||
|
|
||||||
<-signals
|
<-signals
|
||||||
logging.Warn().Msg("Forcibly killed the website")
|
logging.Warn().Msg("Forcibly killed the website")
|
||||||
|
|
Loading…
Reference in New Issue