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/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
|
||||
|
|
|
@ -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) {
|
||||
|
|
155
src/db/db.go
155
src/db/db.go
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
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"`
|
||||
|
|
|
@ -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
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"reflect"
|
||||
"time"
|
||||
)
|
||||
|
||||
var UserType = reflect.TypeOf(User{})
|
||||
|
||||
type User struct {
|
||||
ID int `db:"id"`
|
||||
|
|
|
@ -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 →</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endspaceless %}
|
||||
{% endblock %}
|
||||
*/}}
|
|
@ -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>
|
||||
|
|
|
@ -42,3 +42,6 @@ type BackgroundImage struct {
|
|||
Url string
|
||||
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.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
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
Loading…
Reference in New Issue