Project index
This commit is contained in:
parent
bf96c0bebb
commit
b6c611004c
|
@ -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(""), "")
|
||||||
|
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
||||||
|
|
|
@ -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 }}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 – 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 }}
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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(),
|
||||||
|
|
Loading…
Reference in New Issue