Project index

This commit is contained in:
Asaf Gartner 2021-06-07 02:48:43 +03:00
parent bf96c0bebb
commit b6c611004c
14 changed files with 544 additions and 54 deletions

View File

@ -32,10 +32,6 @@ func TestHomepage(t *testing.T) {
AssertSubdomain(t, BuildProjectHomepage("hero"), "hero") AssertSubdomain(t, BuildProjectHomepage("hero"), "hero")
} }
func TestProjectIndex(t *testing.T) {
AssertRegexMatch(t, BuildProjectIndex(), RegexProjectIndex, nil)
}
func TestShowcase(t *testing.T) { func TestShowcase(t *testing.T) {
AssertRegexMatch(t, BuildShowcase(), RegexShowcase, nil) AssertRegexMatch(t, BuildShowcase(), RegexShowcase, nil)
} }
@ -70,6 +66,10 @@ func TestLogoutAction(t *testing.T) {
AssertRegexMatch(t, BuildLogoutAction(), RegexLogoutAction, nil) AssertRegexMatch(t, BuildLogoutAction(), RegexLogoutAction, nil)
} }
func TestRegister(t *testing.T) {
AssertRegexMatch(t, BuildRegister(), RegexRegister, nil)
}
func TestStaticPages(t *testing.T) { func TestStaticPages(t *testing.T) {
AssertRegexMatch(t, BuildManifesto(), RegexManifesto, nil) AssertRegexMatch(t, BuildManifesto(), RegexManifesto, nil)
AssertRegexMatch(t, BuildAbout(), RegexAbout, nil) AssertRegexMatch(t, BuildAbout(), RegexAbout, nil)
@ -94,6 +94,20 @@ func TestFeed(t *testing.T) {
assert.Panics(t, func() { BuildFeedWithPage(0) }) assert.Panics(t, func() { BuildFeedWithPage(0) })
} }
func TestProjectIndex(t *testing.T) {
AssertRegexMatch(t, BuildProjectIndex(1), RegexProjectIndex, nil)
AssertRegexMatch(t, BuildProjectIndex(2), RegexProjectIndex, map[string]string{"page": "2"})
assert.Panics(t, func() { BuildProjectIndex(0) })
}
func TestProjectNew(t *testing.T) {
AssertRegexMatch(t, BuildProjectNew(), RegexProjectNew, nil)
}
func TestProjectNotApproved(t *testing.T) {
AssertRegexMatch(t, BuildProjectNotApproved("test"), RegexProjectNotApproved, map[string]string{"slug": "test"})
}
func TestPodcast(t *testing.T) { func TestPodcast(t *testing.T) {
AssertRegexMatch(t, BuildPodcast(""), RegexPodcast, nil) AssertRegexMatch(t, BuildPodcast(""), RegexPodcast, nil)
AssertSubdomain(t, BuildPodcast(""), "") AssertSubdomain(t, BuildPodcast(""), "")

View File

@ -16,8 +16,6 @@ Any function in this package whose name starts with Build is required to be cove
This helps ensure that we don't generate URLs that can't be routed. This helps ensure that we don't generate URLs that can't be routed.
*/ */
// TODO(asaf): Make this whole file only crash in Dev
var RegexHomepage = regexp.MustCompile("^/$") var RegexHomepage = regexp.MustCompile("^/$")
func BuildHomepage() string { func BuildHomepage() string {
@ -29,13 +27,6 @@ func BuildProjectHomepage(projectSlug string) string {
return ProjectUrl("/", nil, projectSlug) return ProjectUrl("/", nil, projectSlug)
} }
var RegexProjectIndex = regexp.MustCompile("^/projects$")
func BuildProjectIndex() string {
defer CatchPanic()
return Url("/projects", nil)
}
var RegexShowcase = regexp.MustCompile("^/showcase$") var RegexShowcase = regexp.MustCompile("^/showcase$")
func BuildShowcase() string { func BuildShowcase() string {
@ -80,6 +71,13 @@ func BuildLogoutAction() string {
return Url("/logout", nil) return Url("/logout", nil)
} }
var RegexRegister = regexp.MustCompile("^/_register$")
func BuildRegister() string {
defer CatchPanic()
return Url("/_register", nil)
}
/* /*
* Static Pages * Static Pages
*/ */
@ -186,6 +184,40 @@ func BuildAtomFeedForShowcase() string {
return Url("/atom/showcase", nil) return Url("/atom/showcase", nil)
} }
/*
* Projects
*/
var RegexProjectIndex = regexp.MustCompile("^/projects(/(?P<page>.+)?)?$")
func BuildProjectIndex(page int) string {
defer CatchPanic()
if page < 1 {
panic(oops.New(nil, "page must be >= 1"))
}
if page == 1 {
return Url("/projects", nil)
} else {
return Url(fmt.Sprintf("/projects/%d", page), nil)
}
}
var RegexProjectNew = regexp.MustCompile("^/projects/new$")
func BuildProjectNew() string {
defer CatchPanic()
return Url("/projects/new", nil)
}
var RegexProjectNotApproved = regexp.MustCompile("^/p/(?P<slug>.+)$")
func BuildProjectNotApproved(slug string) string {
defer CatchPanic()
return Url(fmt.Sprintf("/p/%s", slug), nil)
}
/* /*
* Podcast * Podcast
*/ */

View File

@ -24,6 +24,16 @@ const (
ProjectLifecycleLTS ProjectLifecycleLTS
) )
// NOTE(asaf): Just checking the lifecycle is not sufficient. Visible projects also must have flags = 0.
var VisibleProjectLifecycles = []ProjectLifecycle{
ProjectLifecycleActive,
ProjectLifecycleHiatus,
ProjectLifecycleLTSRequired, // NOTE(asaf): LTS means complete
ProjectLifecycleLTS,
}
const RecentProjectUpdateTimespanSec = 60 * 60 * 24 * 28 // NOTE(asaf): Four weeks
type Project struct { type Project struct {
ID int `db:"id"` ID int `db:"id"`
@ -33,6 +43,7 @@ type Project struct {
Name string `db:"name"` Name string `db:"name"`
Blurb string `db:"blurb"` Blurb string `db:"blurb"`
Description string `db:"description"` Description string `db:"description"`
ParsedDescription string `db:"descparsed"`
Lifecycle ProjectLifecycle `db:"lifecycle"` // TODO(asaf): Ensure we only fetch projects in the correct lifecycle phase everywhere. Lifecycle ProjectLifecycle `db:"lifecycle"` // TODO(asaf): Ensure we only fetch projects in the correct lifecycle phase everywhere.
@ -42,6 +53,8 @@ type Project struct {
LogoLight string `db:"logolight"` LogoLight string `db:"logolight"`
LogoDark string `db:"logodark"` LogoDark string `db:"logodark"`
Flags int `db:"flags"` // NOTE(asaf): Flags is currently only used to mark a project as hidden. Flags == 1 means hidden. Flags == 0 means visible.
Featured bool `db:"featured"`
DateApproved time.Time `db:"date_approved"` DateApproved time.Time `db:"date_approved"`
AllLastUpdated time.Time `db:"all_last_updated"` AllLastUpdated time.Time `db:"all_last_updated"`
} }

View File

@ -51,17 +51,50 @@ func (p *Post) AddUrls(projectSlug string, subforums []string, threadId int, pos
p.QuoteUrl = hmnurl.BuildForumPostQuote(projectSlug, subforums, threadId, postId) p.QuoteUrl = hmnurl.BuildForumPostQuote(projectSlug, subforums, threadId, postId)
} }
func ProjectToTemplate(p *models.Project) Project { var LifecycleBadgeClasses = map[models.ProjectLifecycle]string{
models.ProjectLifecycleUnapproved: "",
models.ProjectLifecycleApprovalRequired: "",
models.ProjectLifecycleActive: "",
models.ProjectLifecycleHiatus: "notice-hiatus",
models.ProjectLifecycleDead: "notice-dead",
models.ProjectLifecycleLTSRequired: "",
models.ProjectLifecycleLTS: "notice-lts",
}
var LifecycleBadgeStrings = map[models.ProjectLifecycle]string{
models.ProjectLifecycleUnapproved: "",
models.ProjectLifecycleApprovalRequired: "",
models.ProjectLifecycleActive: "",
models.ProjectLifecycleHiatus: "On Hiatus",
models.ProjectLifecycleDead: "Dead",
models.ProjectLifecycleLTSRequired: "",
models.ProjectLifecycleLTS: "Complete",
}
func ProjectToTemplate(p *models.Project, theme string) Project {
logo := p.LogoLight
if theme == "dark" {
logo = p.LogoDark
}
var url string
if p.Lifecycle == models.ProjectLifecycleUnapproved || p.Lifecycle == models.ProjectLifecycleApprovalRequired {
url = hmnurl.BuildProjectNotApproved(p.Slug)
} else {
url = hmnurl.BuildProjectHomepage(p.Slug)
}
return Project{ return Project{
Name: p.Name, Name: p.Name,
Subdomain: p.Subdomain(), Subdomain: p.Subdomain(),
Color1: p.Color1, Color1: p.Color1,
Color2: p.Color2, Color2: p.Color2,
Url: hmnurl.BuildProjectHomepage(p.Slug), Url: url,
Blurb: p.Blurb, Blurb: p.Blurb,
ParsedDescription: template.HTML(p.ParsedDescription),
LogoLight: hmnurl.BuildUserFile(p.LogoLight), Logo: hmnurl.BuildUserFile(logo),
LogoDark: hmnurl.BuildUserFile(p.LogoDark),
LifecycleBadgeClass: LifecycleBadgeClasses[p.Lifecycle],
LifecycleString: LifecycleBadgeStrings[p.Lifecycle],
IsHMN: p.IsHMN(), IsHMN: p.IsHMN(),

View File

@ -37,7 +37,7 @@
<uri>{{ .ProfileUrl }}</uri> <uri>{{ .ProfileUrl }}</uri>
</author> </author>
{{ end }} {{ end }}
<logo>{{ .LogoLight }}</logo> <logo>{{ .Logo }}</logo>
<summary type="html">{{ .Blurb }}</summary> <summary type="html">{{ .Blurb }}</summary>
</entry> </entry>
{{ end }} {{ end }}

View File

@ -1,5 +1,4 @@
<header class="mb3"> <header class="mb3">
{{/* TODO: All the URLs in here are wrong. */}}
<div class="user-options flex justify-center justify-end-ns"> <div class="user-options flex justify-center justify-end-ns">
{{ if .User }} {{ if .User }}
{{ if .User.IsSuperuser }} {{ if .User.IsSuperuser }}
@ -31,19 +30,22 @@
</div> </div>
{{ end }} {{ end }}
</div> </div>
<div class="menu-bar flex flex-column flex-row-l justify-between {% if project and project.slug != 'hmn' %}project{% endif %}"> <div class="menu-bar flex flex-column flex-row-l justify-between {{ if .IsProjectPage }}project{{ end }}">
<div class="flex flex-column flex-row-ns"> <div class="flex flex-column flex-row-ns">
<a href="{{ .Header.HMNHomepageUrl }}" class="logo hmdev-logo"> <a href="{{ .Header.HMNHomepageUrl }}" class="logo hmdev-logo">
<div class="underscore"></div> <div class="underscore"></div>
</a> </a>
<div class="items flex items-center justify-center justify-start-ns"> <div class="items flex items-center justify-center justify-start-ns">
{{ if not .Project.IsHMN }} {{ if .IsProjectPage }}
<a class="project-logo" href="{{ .Header.ProjectHomepageUrl }}"> <a class="project-logo" href="{{ .Header.ProjectHomepageUrl }}">
<h1>{{ .Project.Name }}</h1> <h1>{{ .Project.Name }}</h1>
</a> </a>
{{ end }} {{ end }}
{{ if not .IsProjectPage }}
<a href="{{ .Header.ProjectIndexUrl }}" class="projects">Projects</a>
{{ end }}
{{ if .Project.HasBlog }} {{ if .Project.HasBlog }}
<a href="{{ .Header.BlogUrl }}" class="blog">Blog</a> <a href="{{ .Header.BlogUrl }}" class="blog">{{ if .IsProjectPage }}Blog{{ else }}News{{ end }}</a>
{{ end }} {{ end }}
{{ if .Project.HasForum }} {{ if .Project.HasForum }}
<a href="{{ .Header.ForumsUrl }}" class="forums">Forums</a> <a href="{{ .Header.ForumsUrl }}" class="forums">Forums</a>

View File

@ -0,0 +1,16 @@
{{- /*gotype: git.handmade.network/hmn/hmn/src/templates.ProjectCardData*/ -}}
<a class="project-card flex br2 overflow-hidden items-center relative {{ .Classes }}" href="{{ .Project.Url }}" >
<div class="image-container flex-shrink-0">
<div class="image bg-center cover" style="background-image:url({{.Project.Logo}})"></div>
</div>
<div class="details pa3 flex-grow-1">
<h3 class="mb1">{{ .Project.Name }}</h3>
<div class="blurb">{{ .Project.Blurb }}</div>
<div class="badges mt2">
{{ if .Project.LifecycleString }}
<span class="badge {{ .Project.LifecycleBadgeClass }}">{{ .Project.LifecycleString }}</span>
{{ end }}
</div>
</div>
</a>

View File

@ -0,0 +1,152 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="content-block no-bg-image">
{{ with .CarouselProjects }}
<div class="project-carousel-container mw-100 mv2 mv3-ns margin-center">
<div class="project-carousel pa3 h5 overflow-hidden bg--dim br2-ns">
<div class="dn db-l"> <!-- desktop carousel -->
{{ range $index, $project := . }}
<div class="project-carousel-item flex pa3 w-100 h-100 bg--dim items-center {{ if eq $index 0 }}active{{ end }}">
<div class="flex-grow-1 pr3 relative flex flex-column h-100 justify-center">
<a href="{{ $project.Url }}">
<h3>{{ $project.Name }}</h3>
</a>
<div class="project-carousel-description">
{{ $project.ParsedDescription }}
</div>
<div class="project-carousel-fade"></div>
</div>
<div class="flex-shrink-0 order-0 order-1-ns">
<a href="{{ $project.Url }}">
<div class="image bg-center cover w5 h5 br2" style="background-image:url({{ $project.Logo }})" ></div>
</a>
</div>
</div>
{{ end }}
</div>
<div class="db dn-l"> <!-- mobile/tablet carousel -->
{{ range $index, $project := . }}
<div class="project-carousel-item-small {{ if eq $index 0 }}active{{ end }}">
{{ template "project_card.html" projectcarddata $project "h-100" }}
</div>
{{ end }}
</div>
</div>
<div class="flex justify-center pv2">
{{ range $index, $project := . }}
<div
class="project-carousel-button br-pill w1 h1 mh2 {{ if eq $index 0 }}active{{ end }}"
onclick="carouselButtonClick({{ $index }})"
></div>
{{ end }}
</div>
</div>
{{ end }}
<div class="flex flex-column flex-row-l mv3 items-start">
<div class="bg--dim-ns br2">
<div class="clear"></div>
<div class="optionbar pv2 ph3">
<a href="{{ .ProjectAtomFeedUrl }}"><span class="icon big">4</span> RSS Feed &ndash; New Projects</span></a>
{{ template "pagination.html" .Pagination }}
</div>
<div class="projectlist ph3">
{{ range .Projects }}
<div class="mv3">
{{ template "project_card.html" projectcarddata . ""}}
</div>
{{ end }}
</div>
<div class="optionbar bottom pv2 ph3">
<div class="options order-1"></div>
<div class="options order-0 order-last-ns">{{ template "pagination.html" .Pagination }}</div>
</div>
</div>
<div class="w-100 w-40-l ph3 ph0-ns flex-shrink-0">
<div class="ml3-l mt3 mt0-l pa3 bg--dim br2">
{{ if not .UserPendingProject }}
<div class="content-block new-project p-spaced">
<h2>Submit a Project</h2>
<pre>your-project-here.handmade.network</pre>
<p>
Developing something cool? Building a tool, game, website, etc.
that meshes with the <a href="{{ .ManifestoUrl }}">Handmade philosophy</a>?
</p>
{{ if .User }}
<p class="center">
<a class="button" href="{{ .NewProjectUrl }}">Submit a Project</a>
</p>
{{ else }}
<p>
Become a member now to submit a project to the site!
</p>
<p class="center">
<a class="button" href="{{ .RegisterUrl }}">Register</a>
<span class="mh2">or</span>
<a class="button" href="{{ .LoginUrl }}">Log In</a>
</p>
{{ end }}
</div>
{{ else }}
<div class="content-block single">
<h2>Project pending</h2>
<p>Thanks for considering us as a home for<br /><a href="{{ .UserPendingProject.Url }}">{{ .UserPendingProject.Name }}</a>!</p>
<br />
{{ if .UserPendingProjectUnderReview }}
<p>We see it's ready for review by an administrator, great! We'll try and get back to you in a timely manner.</p>
{{ else }}
<p>When you're ready for us to review it, let us know using the checkbox on {{ .UserPendingProject.Name }}'s profile editor.</p>
{{ end }}
</div>
{{ end }}
{{ if .UserApprovedProjects }}
<div class="content-block single projectlist">
{{ if .UserPendingProject }}
<h2>Your other projects</h2>
{{ else }}
<h2>Your projects</h2>
{{ end }}
{{ range .UserApprovedProjects }}
<div class="mv3">
{{ template "project_card.html" projectcarddata . "" }}
</div>
{{ end }}
</div>
{{ end }}
</div>
</div>
</div>
</div>
<script>
const numCarouselItems = {{ len .CarouselProjects }};
function activateCarouselProject(i) {
const items = document.querySelectorAll('.project-carousel-item');
items.forEach(item => item.classList.remove('active'));
items[i].classList.add('active');
const smallItems = document.querySelectorAll('.project-carousel-item-small');
smallItems.forEach(item => item.classList.remove('active'));
smallItems[i].classList.add('active');
const buttons = document.querySelectorAll('.project-carousel-button');
buttons.forEach(button => button.classList.remove('active'));
buttons[i].classList.add('active');
}
let carouselTimerCurrent = 0;
const carouselTimer = setInterval(() => {
const next = (carouselTimerCurrent + 1) % numCarouselItems;
activateCarouselProject(next);
carouselTimerCurrent = next;
}, 10000);
function carouselButtonClick(i) {
activateCarouselProject(i);
clearInterval(carouselTimer);
}
</script>
{{ end }}

View File

@ -158,6 +158,14 @@ var HMNTemplateFuncs = template.FuncMap{
"noescape": func(str string) template.HTML { "noescape": func(str string) template.HTML {
return template.HTML(str) return template.HTML(str)
}, },
// NOTE(asaf): Template specific functions:
"projectcarddata": func(project Project, classes string) ProjectCardData {
return ProjectCardData{
Project: &project,
Classes: classes,
}
},
} }
type ErrInvalidHexColor struct { type ErrInvalidHexColor struct {

View File

@ -20,6 +20,7 @@ type BaseData struct {
Project Project Project Project
User *User User *User
IsProjectPage bool
Header Header Header Header
Footer Footer Footer Footer
} }
@ -32,6 +33,7 @@ type Header struct {
RegisterUrl string RegisterUrl string
HMNHomepageUrl string HMNHomepageUrl string
ProjectHomepageUrl string ProjectHomepageUrl string
ProjectIndexUrl string
BlogUrl string BlogUrl string
ForumsUrl string ForumsUrl string
WikiUrl string WikiUrl string
@ -92,10 +94,13 @@ type Project struct {
Color2 string Color2 string
Url string Url string
Blurb string Blurb string
ParsedDescription template.HTML
Owners []User Owners []User
LogoDark string Logo string
LogoLight string
LifecycleBadgeClass string
LifecycleString string
IsHMN bool IsHMN bool
@ -192,6 +197,11 @@ type ThreadListItem struct {
Content string Content string
} }
type ProjectCardData struct {
Project *Project
Classes string
}
type Breadcrumb struct { type Breadcrumb struct {
Name, Url string Name, Url string
Current bool Current bool

View File

@ -183,7 +183,7 @@ func AtomFeed(c *RequestContext) ResponseData {
feedData.FeedType = FeedTypeProjects feedData.FeedType = FeedTypeProjects
feedData.FeedID = FeedIDProjects feedData.FeedID = FeedIDProjects
feedData.AtomFeedUrl = hmnurl.BuildAtomFeedForProjects() feedData.AtomFeedUrl = hmnurl.BuildAtomFeedForProjects()
feedData.FeedUrl = hmnurl.BuildProjectIndex() feedData.FeedUrl = hmnurl.BuildProjectIndex(1)
c.Perf.StartBlock("SQL", "Fetching projects") c.Perf.StartBlock("SQL", "Fetching projects")
type projectResult struct { type projectResult struct {
@ -192,10 +192,15 @@ func AtomFeed(c *RequestContext) ResponseData {
projects, err := db.Query(c.Context(), c.Conn, projectResult{}, projects, err := db.Query(c.Context(), c.Conn, projectResult{},
` `
SELECT $columns SELECT $columns
FROM handmade_project AS project FROM
handmade_project AS project
WHERE
project.lifecycle = ANY($1)
AND project.flags = 0
ORDER BY date_approved DESC ORDER BY date_approved DESC
LIMIT $1 LIMIT $2
`, `,
models.VisibleProjectLifecycles,
itemsPerFeed, itemsPerFeed,
) )
if err != nil { if err != nil {
@ -205,7 +210,7 @@ func AtomFeed(c *RequestContext) ResponseData {
projectMap := make(map[int]*templates.Project) projectMap := make(map[int]*templates.Project)
for _, p := range projects.ToSlice() { for _, p := range projects.ToSlice() {
project := p.(*projectResult).Project project := p.(*projectResult).Project
templateProject := templates.ProjectToTemplate(&project) templateProject := templates.ProjectToTemplate(&project, c.Theme)
templateProject.UUID = uuid.NewSHA1(uuid.NameSpaceURL, []byte(templateProject.Url)).URN() templateProject.UUID = uuid.NewSHA1(uuid.NameSpaceURL, []byte(templateProject.Url)).URN()
projectIds = append(projectIds, project.ID) projectIds = append(projectIds, project.ID)

View File

@ -55,12 +55,12 @@ func Index(c *RequestContext) ResponseData {
SELECT $columns SELECT $columns
FROM handmade_project FROM handmade_project
WHERE WHERE
(flags = 0 AND NOT lifecycle = ANY($1)) (flags = 0 AND lifecycle = ANY($1))
OR id = $2 OR id = $2
ORDER BY all_last_updated DESC ORDER BY all_last_updated DESC
LIMIT $3 LIMIT $3
`, `,
[]models.ProjectLifecycle{models.ProjectLifecycleUnapproved, models.ProjectLifecycleApprovalRequired}, models.VisibleProjectLifecycles,
models.HMNProjectID, models.HMNProjectID,
numProjectsToGet*2, // hedge your bets against projects that don't have any content numProjectsToGet*2, // hedge your bets against projects that don't have any content
) )
@ -140,7 +140,7 @@ func Index(c *RequestContext) ResponseData {
} }
landingPageProject := LandingPageProject{ landingPageProject := LandingPageProject{
Project: templates.ProjectToTemplate(proj), Project: templates.ProjectToTemplate(proj, c.Theme),
ForumsUrl: forumsUrl, ForumsUrl: forumsUrl,
} }

203
src/website/projects.go Normal file
View File

@ -0,0 +1,203 @@
package website
import (
"math"
"math/rand"
"net/http"
"strconv"
"time"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/templates"
"git.handmade.network/hmn/hmn/src/utils"
)
type ProjectTemplateData struct {
templates.BaseData
Pagination templates.Pagination
CarouselProjects []templates.Project
Projects []templates.Project
UserPendingProjectUnderReview bool
UserPendingProject *templates.Project
UserApprovedProjects []templates.Project
ProjectAtomFeedUrl string
ManifestoUrl string
NewProjectUrl string
RegisterUrl string
LoginUrl string
}
func ProjectIndex(c *RequestContext) ResponseData {
const projectsPerPage = 20
const maxCarouselProjects = 10
page := 1
pageString, hasPage := c.PathParams["page"]
if hasPage && pageString != "" {
if pageParsed, err := strconv.Atoi(pageString); err == nil {
page = pageParsed
} else {
return c.Redirect(hmnurl.BuildProjectIndex(1), http.StatusSeeOther)
}
}
if page < 1 {
return c.Redirect(hmnurl.BuildProjectIndex(1), http.StatusSeeOther)
}
c.Perf.StartBlock("SQL", "Fetching all visible projects")
type projectResult struct {
Project models.Project `db:"project"`
}
allProjects, err := db.Query(c.Context(), c.Conn, projectResult{},
`
SELECT $columns
FROM
handmade_project AS project
WHERE
project.lifecycle = ANY($1)
AND project.flags = 0
ORDER BY project.date_approved ASC
`,
models.VisibleProjectLifecycles,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch projects"))
}
allProjectsSlice := allProjects.ToSlice()
c.Perf.EndBlock()
numPages := int(math.Ceil(float64(len(allProjectsSlice)) / projectsPerPage))
if page > numPages {
return c.Redirect(hmnurl.BuildProjectIndex(numPages), http.StatusSeeOther)
}
pagination := templates.Pagination{
Current: page,
Total: numPages,
FirstUrl: hmnurl.BuildProjectIndex(1),
LastUrl: hmnurl.BuildProjectIndex(numPages),
NextUrl: hmnurl.BuildProjectIndex(utils.IntClamp(1, page+1, numPages)),
PreviousUrl: hmnurl.BuildProjectIndex(utils.IntClamp(1, page-1, numPages)),
}
var userApprovedProjects []templates.Project
var userPendingProject *templates.Project
userPendingProjectUnderReview := false
if c.CurrentUser != nil {
c.Perf.StartBlock("SQL", "fetching user projects")
type UserProjectQuery struct {
Project models.Project `db:"project"`
}
userProjectsResult, err := db.Query(c.Context(), c.Conn, UserProjectQuery{},
`
SELECT $columns
FROM
handmade_project AS project
INNER JOIN handmade_project_groups AS project_groups ON project_groups.project_id = project.id
INNER JOIN auth_user_groups AS user_groups ON user_groups.group_id = project_groups.group_id
WHERE
user_groups.user_id = $1
`,
c.CurrentUser.ID,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user projects"))
}
for _, project := range userProjectsResult.ToSlice() {
p := project.(*UserProjectQuery).Project
if p.Lifecycle == models.ProjectLifecycleUnapproved || p.Lifecycle == models.ProjectLifecycleApprovalRequired {
if userPendingProject == nil {
// NOTE(asaf): Technically a user could have more than one pending project.
// For example, if they created one project themselves and were added as an additional owner to another user's project.
// So we'll just take the first one. I don't think it matters. I guess it especially won't matter after Projects 2.0.
tmplProject := templates.ProjectToTemplate(&p, c.Theme)
userPendingProject = &tmplProject
userPendingProjectUnderReview = (p.Lifecycle == models.ProjectLifecycleApprovalRequired)
}
} else {
userApprovedProjects = append(userApprovedProjects, templates.ProjectToTemplate(&p, c.Theme))
}
}
c.Perf.EndBlock()
}
c.Perf.StartBlock("PROJECTS", "Grouping and sorting")
var handmadeHero *templates.Project
var featuredProjects []templates.Project
var recentProjects []templates.Project
var restProjects []templates.Project
now := time.Now()
for _, p := range allProjectsSlice {
project := &p.(*projectResult).Project
templateProject := templates.ProjectToTemplate(project, c.Theme)
if project.Slug == "hero" {
// NOTE(asaf): Handmade Hero gets special treatment. Must always be first in the list.
handmadeHero = &templateProject
continue
}
if project.Featured {
featuredProjects = append(featuredProjects, templateProject)
} else if now.Sub(project.AllLastUpdated).Seconds() < models.RecentProjectUpdateTimespanSec {
recentProjects = append(recentProjects, templateProject)
} else {
restProjects = append(restProjects, templateProject)
}
}
_, randSeed := now.ISOWeek()
random := rand.New(rand.NewSource(int64(randSeed)))
random.Shuffle(len(featuredProjects), func(i, j int) { featuredProjects[i], featuredProjects[j] = featuredProjects[j], featuredProjects[i] })
random.Shuffle(len(recentProjects), func(i, j int) { recentProjects[i], recentProjects[j] = recentProjects[j], recentProjects[i] })
random.Shuffle(len(restProjects), func(i, j int) { restProjects[i], restProjects[j] = restProjects[j], restProjects[i] })
if handmadeHero != nil {
// NOTE(asaf): As mentioned above, inserting HMH first.
featuredProjects = append([]templates.Project{*handmadeHero}, featuredProjects...)
}
orderedProjects := make([]templates.Project, 0, len(featuredProjects)+len(recentProjects)+len(restProjects))
orderedProjects = append(orderedProjects, featuredProjects...)
orderedProjects = append(orderedProjects, recentProjects...)
orderedProjects = append(orderedProjects, restProjects...)
firstProjectIndex := (page - 1) * projectsPerPage
endIndex := utils.IntMin(firstProjectIndex+projectsPerPage, len(orderedProjects))
pageProjects := orderedProjects[firstProjectIndex:endIndex]
var carouselProjects []templates.Project
if page == 1 {
carouselProjects = featuredProjects[:utils.IntMin(len(featuredProjects), maxCarouselProjects)]
}
c.Perf.EndBlock()
baseData := getBaseData(c)
baseData.Title = "Project List"
var res ResponseData
res.WriteTemplate("project_index.html", ProjectTemplateData{
BaseData: baseData,
Pagination: pagination,
CarouselProjects: carouselProjects,
Projects: pageProjects,
UserPendingProjectUnderReview: userPendingProjectUnderReview,
UserPendingProject: userPendingProject,
UserApprovedProjects: userApprovedProjects,
ProjectAtomFeedUrl: hmnurl.BuildAtomFeedForProjects(),
ManifestoUrl: hmnurl.BuildManifesto(),
NewProjectUrl: hmnurl.BuildProjectNew(),
RegisterUrl: hmnurl.BuildRegister(),
LoginUrl: hmnurl.BuildLoginPage(c.FullUrl()),
}, c.Perf)
return res
}

View File

@ -106,9 +106,9 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
mainRoutes.GET(hmnurl.RegexFeed, Feed) mainRoutes.GET(hmnurl.RegexFeed, Feed)
mainRoutes.GET(hmnurl.RegexAtomFeed, AtomFeed) mainRoutes.GET(hmnurl.RegexAtomFeed, AtomFeed)
// TODO(asaf): Trailing slashes break these mainRoutes.GET(hmnurl.RegexProjectIndex, ProjectIndex)
mainRoutes.GET(hmnurl.RegexForumThread, ForumThread) mainRoutes.GET(hmnurl.RegexForumThread, ForumThread)
// mainRoutes.GET(`^/(?P<cats>forums(/cat)*)/t/(?P<threadid>\d+)/p/(?P<postid>\d+)$`, ForumPost)
mainRoutes.GET(hmnurl.RegexForumCategory, ForumCategory) mainRoutes.GET(hmnurl.RegexForumCategory, ForumCategory)
mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS) mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS)
@ -131,11 +131,12 @@ func getBaseData(c *RequestContext) templates.BaseData {
} }
return templates.BaseData{ return templates.BaseData{
Project: templates.ProjectToTemplate(c.CurrentProject), Project: templates.ProjectToTemplate(c.CurrentProject, c.Theme),
LoginPageUrl: hmnurl.BuildLoginPage(c.FullUrl()), LoginPageUrl: hmnurl.BuildLoginPage(c.FullUrl()),
User: templateUser, User: templateUser,
Theme: c.Theme, Theme: c.Theme,
ProjectCSSUrl: hmnurl.BuildProjectCSS(c.CurrentProject.Color1), ProjectCSSUrl: hmnurl.BuildProjectCSS(c.CurrentProject.Color1),
IsProjectPage: !c.CurrentProject.IsHMN(),
Header: templates.Header{ Header: templates.Header{
AdminUrl: hmnurl.BuildHomepage(), // TODO(asaf) AdminUrl: hmnurl.BuildHomepage(), // TODO(asaf)
MemberSettingsUrl: hmnurl.BuildHomepage(), // TODO(asaf) MemberSettingsUrl: hmnurl.BuildHomepage(), // TODO(asaf)
@ -144,6 +145,7 @@ func getBaseData(c *RequestContext) templates.BaseData {
RegisterUrl: hmnurl.BuildHomepage(), // TODO(asaf) RegisterUrl: hmnurl.BuildHomepage(), // TODO(asaf)
HMNHomepageUrl: hmnurl.BuildHomepage(), // TODO(asaf) HMNHomepageUrl: hmnurl.BuildHomepage(), // TODO(asaf)
ProjectHomepageUrl: hmnurl.BuildProjectHomepage(c.CurrentProject.Slug), ProjectHomepageUrl: hmnurl.BuildProjectHomepage(c.CurrentProject.Slug),
ProjectIndexUrl: hmnurl.BuildProjectIndex(1),
BlogUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, 1), BlogUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, 1),
ForumsUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, nil, 1), ForumsUrl: hmnurl.BuildForumCategory(c.CurrentProject.Slug, nil, 1),
WikiUrl: hmnurl.BuildWiki(c.CurrentProject.Slug), WikiUrl: hmnurl.BuildWiki(c.CurrentProject.Slug),
@ -159,7 +161,7 @@ func getBaseData(c *RequestContext) templates.BaseData {
ManifestoUrl: hmnurl.BuildManifesto(), ManifestoUrl: hmnurl.BuildManifesto(),
CodeOfConductUrl: hmnurl.BuildCodeOfConduct(), CodeOfConductUrl: hmnurl.BuildCodeOfConduct(),
CommunicationGuidelinesUrl: hmnurl.BuildCommunicationGuidelines(), CommunicationGuidelinesUrl: hmnurl.BuildCommunicationGuidelines(),
ProjectIndexUrl: hmnurl.BuildProjectIndex(), ProjectIndexUrl: hmnurl.BuildProjectIndex(1),
ForumsUrl: hmnurl.BuildForumCategory(models.HMNProjectSlug, nil, 1), ForumsUrl: hmnurl.BuildForumCategory(models.HMNProjectSlug, nil, 1),
ContactUrl: hmnurl.BuildContactPage(), ContactUrl: hmnurl.BuildContactPage(),
SitemapUrl: hmnurl.BuildSiteMap(), SitemapUrl: hmnurl.BuildSiteMap(),