Start porting landing page; rework db layer a bit

This commit is contained in:
Ben Visness 2021-03-30 22:55:19 -05:00
parent f7ac023c44
commit 8929a5d749
14 changed files with 581 additions and 76 deletions

1
go.mod
View File

@ -10,6 +10,7 @@ require (
github.com/google/uuid v1.2.0 // indirect
github.com/huandu/xstrings v1.3.2 // 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/julienschmidt/httprouter v1.3.0
github.com/mitchellh/copystructure v1.1.1 // indirect

View File

@ -34,8 +34,7 @@ func makeSessionId() string {
var ErrNoSession = errors.New("no session found")
func GetSession(ctx context.Context, conn *pgxpool.Pool, id string) (*models.Session, error) {
var sess models.Session
err := db.QueryOneToStruct(ctx, conn, &sess, "SELECT $columns FROM sessions WHERE id = $1", id)
row, err := db.QueryOne(ctx, conn, models.Session{}, "SELECT $columns FROM sessions WHERE id = $1", id)
if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) {
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")
}
}
sess := row.(*models.Session)
return &sess, nil
return sess, nil
}
func CreateSession(ctx context.Context, conn *pgxpool.Pool, username string) (*models.Session, error) {

View File

@ -5,15 +5,19 @@ import (
"errors"
"reflect"
"strings"
"time"
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgtype"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/log/zerologadapter"
"github.com/jackc/pgx/v4/pgxpool"
"github.com/rs/zerolog/log"
)
var connInfo = pgtype.NewConnInfo()
func NewConn() *pgx.Conn {
conn, err := pgx.Connect(context.Background(), config.Config.Postgres.DSN())
if err != nil {
@ -40,20 +44,18 @@ func NewConnPool(minConns, maxConns int32) *pgxpool.Pool {
}
type StructQueryIterator struct {
fieldIndices []int
rows pgx.Rows
fieldPaths [][]int
rows pgx.Rows
destType reflect.Type
}
func (it *StructQueryIterator) Next(dest interface{}) bool {
func (it *StructQueryIterator) Next() (interface{}, bool) {
hasNext := it.rows.Next()
if !hasNext {
return false
return nil, false
}
v := reflect.ValueOf(dest)
if v.Kind() != reflect.Ptr {
panic(oops.New(nil, "Next requires a pointer type; got %v", v.Kind()))
}
result := reflect.New(it.destType)
vals, err := it.rows.Values()
if err != nil {
@ -61,47 +63,70 @@ func (it *StructQueryIterator) Next(dest interface{}) bool {
}
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() {
case reflect.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:
field.Set(reflect.ValueOf(val))
}
}
return true
return result.Interface(), true
}
func (it *StructQueryIterator) Close() {
it.rows.Close()
}
func QueryToStructs(ctx context.Context, conn *pgxpool.Pool, destType interface{}, query string, args ...interface{}) (StructQueryIterator, error) {
var fieldIndices []int
var columnNames []string
t := reflect.TypeOf(destType)
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return StructQueryIterator{}, oops.New(nil, "QueryToStructs requires a struct type or a pointer to a struct type")
}
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if columnName := f.Tag.Get("db"); columnName != "" {
fieldIndices = append(fieldIndices, i)
columnNames = append(columnNames, columnName)
func (it *StructQueryIterator) ToSlice() []interface{} {
defer it.Close()
var result []interface{}
for {
row, ok := it.Next()
if !ok {
break
}
result = append(result, row)
}
return result
}
func followPathThroughStructs(structVal reflect.Value, path []int) reflect.Value {
if len(path) < 1 {
panic("can't follow an empty path")
}
val := structVal
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
}
func Query(ctx context.Context, conn *pgxpool.Pool, destExample interface{}, query string, args ...interface{}) (StructQueryIterator, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
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, ", ")
@ -109,28 +134,74 @@ func QueryToStructs(ctx context.Context, conn *pgxpool.Pool, destType interface{
rows, err := conn.Query(ctx, query, args...)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
panic("query exceeded its deadline")
}
return StructQueryIterator{}, err
}
return StructQueryIterator{
fieldIndices: fieldIndices,
rows: rows,
fieldPaths: fieldPaths,
rows: rows,
destType: destType,
}, 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")
func QueryOneToStruct(ctx context.Context, conn *pgxpool.Pool, dest interface{}, query string, args ...interface{}) error {
rows, err := QueryToStructs(ctx, conn, dest, query, args...)
func QueryOne(ctx context.Context, conn *pgxpool.Pool, destExample interface{}, query string, args ...interface{}) (interface{}, error) {
rows, err := Query(ctx, conn, destExample, query, args...)
if err != nil {
return err
return nil, err
}
defer rows.Close()
hasRow := rows.Next(dest)
result, hasRow := rows.Next()
if !hasRow {
return ErrNoMatchingRows
return nil, ErrNoMatchingRows
}
return nil
return result, nil
}

27
src/models/category.go Normal file
View File

@ -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"`
}

31
src/models/post.go Normal file
View File

@ -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"`
}

View File

@ -1,14 +1,18 @@
package models
import "reflect"
const HMNProjectID = 1
var ProjectType = reflect.TypeOf(Project{})
type Project struct {
ID int `db:"id"`
Slug string `db:"slug"`
Name string `db:"name"`
Blurb string `db:"blurb"`
Description string `db:"description"`
Slug *string `db:"slug"` // TODO: Migrate these to NOT NULL
Name *string `db:"name"`
Blurb *string `db:"blurb"`
Description *string `db:"description"`
Color1 string `db:"color_1"`
Color2 string `db:"color_2"`

17
src/models/thread.go Normal file
View File

@ -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"`
}

View File

@ -1,6 +1,11 @@
package models
import "time"
import (
"reflect"
"time"
)
var UserType = reflect.TypeOf(User{})
type User struct {
ID int `db:"id"`

View File

@ -3,3 +3,200 @@
{{ define "content" }}
This is the index page.
{{ 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 &rarr;</a>
</div>
{% endif %}
{% endwith %}
</div>
{% endwith %}
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endspaceless %}
{% endblock %}
*/}}

View File

@ -63,7 +63,7 @@
<body class="{{ join " " .BodyClasses }}">
<div class="content mw-site ph3-m ph4-l">
{{ template "header.html" . }}
based
{{ block "content" . }}{{ end }}
{{ template "footer.html" . }}
</div>
</body>

View File

@ -42,3 +42,6 @@ type BackgroundImage struct {
Url string
Size string // A valid CSS background-size value
}
type Post struct {
}

148
src/website/landing.go Normal file
View File

@ -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)
}
}

View File

@ -33,7 +33,13 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler {
}
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("/assets/project.css", routes.ProjectCSS)
@ -58,8 +64,8 @@ func (s *websiteRoutes) getBaseData(c *RequestContext) templates.BaseData {
return templates.BaseData{
Project: templates.Project{
Name: c.currentProject.Name,
Subdomain: c.currentProject.Slug,
Name: *c.currentProject.Name,
Subdomain: *c.currentProject.Slug,
Color: c.currentProject.Color1,
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) {
var subdomainProject models.Project
err := db.QueryOneToStruct(ctx, conn, &subdomainProject, "SELECT $columns FROM handmade_project WHERE slug = $1", slug)
subdomainProjectRow, err := db.QueryOne(ctx, conn, models.Project{}, "SELECT $columns FROM handmade_project WHERE slug = $1", slug)
if err == nil {
subdomainProject := subdomainProjectRow.(models.Project)
return &subdomainProject, nil
} else if !errors.Is(err, db.ErrNoMatchingRows) {
return nil, oops.New(err, "failed to get projects by slug")
}
var defaultProject models.Project
err = db.QueryOneToStruct(ctx, conn, &defaultProject, "SELECT $columns FROM handmade_project WHERE id = $1", models.HMNProjectID)
defaultProjectRow, err := db.QueryOne(ctx, conn, models.Project{}, "SELECT $columns FROM handmade_project WHERE id = $1", models.HMNProjectID)
if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) {
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")
}
}
defaultProject := defaultProjectRow.(*models.Project)
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)
}
return defaultProject, nil
}
func (s *websiteRoutes) Project(c *RequestContext, p httprouter.Params) {
@ -159,8 +158,7 @@ func (s *websiteRoutes) Login(c *RequestContext, p httprouter.Params) {
redirect = "/"
}
var user models.User
err = db.QueryOneToStruct(c.Context(), s.conn, &user, "SELECT $columns FROM auth_user WHERE username = $1", username)
userRow, err := db.QueryOne(c.Context(), s.conn, models.User{}, "SELECT $columns FROM auth_user WHERE username = $1", username)
if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) {
c.StatusCode = http.StatusUnauthorized
@ -169,6 +167,7 @@ func (s *websiteRoutes) Login(c *RequestContext, p httprouter.Params) {
}
return
}
user := userRow.(models.User)
hashed, err := auth.ParsePasswordString(user.Password)
if err != nil {
@ -285,8 +284,7 @@ func (s *websiteRoutes) getCurrentUserAndMember(ctx context.Context, sessionId s
}
}
var user models.User
err = db.QueryOneToStruct(ctx, s.conn, &user, "SELECT $columns FROM auth_user WHERE username = $1", session.Username)
userRow, err := db.QueryOne(ctx, s.conn, models.User{}, "SELECT $columns FROM auth_user WHERE username = $1", session.Username)
if err != nil {
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")
@ -295,8 +293,9 @@ func (s *websiteRoutes) getCurrentUserAndMember(ctx context.Context, sessionId s
return nil, oops.New(err, "failed to get user for session")
}
}
user := userRow.(*models.User)
// TODO: Also get the member model
return &user, nil
return user, nil
}

View File

@ -25,7 +25,7 @@ var WebsiteCommand = &cobra.Command{
logging.Info().Msg("Hello, HMN!")
conn := db.NewConnPool(4, 8)
conn := db.NewConnPool(4, 128)
server := http.Server{
Addr: config.Config.Addr,
@ -42,10 +42,12 @@ var WebsiteCommand = &cobra.Command{
go func() {
<-signals
logging.Info().Msg("Shutting down the website")
timeout, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
server.Shutdown(timeout)
cancelBackgroundJobs()
go func() {
timeout, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
server.Shutdown(timeout)
cancelBackgroundJobs()
}()
<-signals
logging.Warn().Msg("Forcibly killed the website")