Add route grouping stuff for projects (needs thorough testing)
This commit is contained in:
parent
61683966a2
commit
cb967b92fd
|
@ -9,8 +9,8 @@ set -euxo pipefail
|
||||||
# TODO(opensource): We should adapt Asaf's seedfile command and then delete this.
|
# TODO(opensource): We should adapt Asaf's seedfile command and then delete this.
|
||||||
|
|
||||||
THIS_PATH=$(pwd)
|
THIS_PATH=$(pwd)
|
||||||
BETA_PATH='/mnt/c/Users/bvisn/Developer/handmade/handmade-beta'
|
#BETA_PATH='/mnt/c/Users/bvisn/Developer/handmade/handmade-beta'
|
||||||
# BETA_PATH='/Users/benvisness/Developer/handmade/handmade-beta'
|
BETA_PATH='/Users/benvisness/Developer/handmade/handmade-beta'
|
||||||
|
|
||||||
pushd $BETA_PATH
|
pushd $BETA_PATH
|
||||||
docker-compose down -v
|
docker-compose down -v
|
||||||
|
@ -19,4 +19,5 @@ pushd $BETA_PATH
|
||||||
|
|
||||||
docker-compose exec postgres bash -c "psql -U postgres -c \"CREATE ROLE hmn CREATEDB LOGIN PASSWORD 'password';\""
|
docker-compose exec postgres bash -c "psql -U postgres -c \"CREATE ROLE hmn CREATEDB LOGIN PASSWORD 'password';\""
|
||||||
popd
|
popd
|
||||||
go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-09-06
|
#go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-09-06
|
||||||
|
go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-10-23
|
||||||
|
|
|
@ -97,8 +97,6 @@ func BuildRegistrationSuccess() string {
|
||||||
return Url("/registered_successfully", nil)
|
return Url("/registered_successfully", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(asaf): Delete the old version a bit after launch
|
|
||||||
var RegexOldEmailConfirmation = regexp.MustCompile(`^/_register/confirm/(?P<username>[\w\ \.\,\-@\+\_]+)/(?P<hash>[\d\w]+)/(?P<nonce>.+)[\/]?$`)
|
|
||||||
var RegexEmailConfirmation = regexp.MustCompile("^/email_confirmation/(?P<username>[^/]+)/(?P<token>[^/]+)$")
|
var RegexEmailConfirmation = regexp.MustCompile("^/email_confirmation/(?P<username>[^/]+)/(?P<token>[^/]+)$")
|
||||||
|
|
||||||
func BuildEmailConfirmation(username, token string) string {
|
func BuildEmailConfirmation(username, token string) string {
|
||||||
|
@ -295,14 +293,14 @@ func BuildProjectNew() string {
|
||||||
return Url("/projects/new", nil)
|
return Url("/projects/new", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexPersonalProjectHomepage = regexp.MustCompile("^/p/(?P<id>[0-9]+)(/(?P<slug>[^/]*))?")
|
var RegexPersonalProject = regexp.MustCompile("^/p/(?P<projectid>[0-9]+)(/(?P<slug>[a-zA-Z0-9-]+))?")
|
||||||
|
|
||||||
func BuildPersonalProjectHomepage(id int, slug string) string {
|
func BuildPersonalProject(id int, slug string) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
return Url(fmt.Sprintf("/p/%d/%s", id, slug), nil)
|
return Url(fmt.Sprintf("/p/%d/%s", id, slug), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexProjectEdit = regexp.MustCompile("^/p/(?P<slug>.+)/edit$")
|
var RegexProjectEdit = regexp.MustCompile("^/edit$")
|
||||||
|
|
||||||
func BuildProjectEdit(slug string, section string) string {
|
func BuildProjectEdit(slug string, section string) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
|
@ -730,7 +728,7 @@ func BuildForumMarkRead(projectSlug string, subforumId int) string {
|
||||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
return ProjectUrl(builder.String(), nil, projectSlug)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexCatchAll = regexp.MustCompile("")
|
var RegexCatchAll = regexp.MustCompile("^")
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Helper functions
|
* Helper functions
|
||||||
|
|
|
@ -62,7 +62,7 @@ var LifecycleBadgeStrings = map[models.ProjectLifecycle]string{
|
||||||
func ProjectUrl(p *models.Project) string {
|
func ProjectUrl(p *models.Project) string {
|
||||||
var url string
|
var url string
|
||||||
if p.Personal {
|
if p.Personal {
|
||||||
url = hmnurl.BuildPersonalProjectHomepage(p.ID, models.GeneratePersonalProjectSlug(p.Name))
|
url = hmnurl.BuildPersonalProject(p.ID, models.GeneratePersonalProjectSlug(p.Name))
|
||||||
} else {
|
} else {
|
||||||
url = hmnurl.BuildOfficialProjectHomepage(p.Slug)
|
url = hmnurl.BuildOfficialProjectHomepage(p.Slug)
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,7 +26,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
|
||||||
notices := getNoticesFromCookie(c)
|
notices := getNoticesFromCookie(c)
|
||||||
|
|
||||||
if len(breadcrumbs) > 0 {
|
if len(breadcrumbs) > 0 {
|
||||||
projectUrl := hmnurl.BuildProjectHomepage(c.CurrentProject.Slug)
|
projectUrl := UrlForProject(c.CurrentProject)
|
||||||
if breadcrumbs[0].Url != projectUrl {
|
if breadcrumbs[0].Url != projectUrl {
|
||||||
rootBreadcrumb := templates.Breadcrumb{
|
rootBreadcrumb := templates.Breadcrumb{
|
||||||
Name: c.CurrentProject.Name,
|
Name: c.CurrentProject.Name,
|
||||||
|
@ -42,7 +42,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
|
||||||
Breadcrumbs: breadcrumbs,
|
Breadcrumbs: breadcrumbs,
|
||||||
|
|
||||||
CurrentUrl: c.FullUrl(),
|
CurrentUrl: c.FullUrl(),
|
||||||
CurrentProjectUrl: hmnurl.BuildProjectHomepage(c.CurrentProject.Slug),
|
CurrentProjectUrl: UrlForProject(c.CurrentProject),
|
||||||
LoginPageUrl: hmnurl.BuildLoginPage(c.FullUrl()),
|
LoginPageUrl: hmnurl.BuildLoginPage(c.FullUrl()),
|
||||||
ProjectCSSUrl: hmnurl.BuildProjectCSS(c.CurrentProject.Color1),
|
ProjectCSSUrl: hmnurl.BuildProjectCSS(c.CurrentProject.Color1),
|
||||||
|
|
||||||
|
|
|
@ -517,7 +517,7 @@ func BlogPostDeleteSubmit(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
|
|
||||||
if threadDeleted {
|
if threadDeleted {
|
||||||
projectUrl := hmnurl.BuildProjectHomepage(c.CurrentProject.Slug)
|
projectUrl := UrlForProject(c.CurrentProject)
|
||||||
return c.Redirect(projectUrl, http.StatusSeeOther)
|
return c.Redirect(projectUrl, http.StatusSeeOther)
|
||||||
} else {
|
} else {
|
||||||
thread, err := FetchThread(c.Context(), c.Conn, c.CurrentUser, cd.ThreadID, ThreadsQuery{
|
thread, err := FetchThread(c.Context(), c.Conn, c.CurrentUser, cd.ThreadID, ThreadsQuery{
|
||||||
|
|
|
@ -9,7 +9,7 @@ import (
|
||||||
func ProjectBreadcrumb(project *models.Project) templates.Breadcrumb {
|
func ProjectBreadcrumb(project *models.Project) templates.Breadcrumb {
|
||||||
return templates.Breadcrumb{
|
return templates.Breadcrumb{
|
||||||
Name: project.Name,
|
Name: project.Name,
|
||||||
Url: hmnurl.BuildProjectHomepage(project.Slug),
|
Url: UrlForProject(project),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -53,7 +53,7 @@ func EpisodeList(c *RequestContext) ResponseData {
|
||||||
defaultTopic, hasEpisodeGuide := config.Config.EpisodeGuide.Projects[slug]
|
defaultTopic, hasEpisodeGuide := config.Config.EpisodeGuide.Projects[slug]
|
||||||
|
|
||||||
if !hasEpisodeGuide {
|
if !hasEpisodeGuide {
|
||||||
return c.Redirect(hmnurl.BuildProjectHomepage(slug), http.StatusSeeOther)
|
return c.Redirect(UrlForProject(c.CurrentProject), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
if topic == "" {
|
if topic == "" {
|
||||||
|
@ -114,7 +114,7 @@ func Episode(c *RequestContext) ResponseData {
|
||||||
_, hasEpisodeGuide := config.Config.EpisodeGuide.Projects[slug]
|
_, hasEpisodeGuide := config.Config.EpisodeGuide.Projects[slug]
|
||||||
|
|
||||||
if !hasEpisodeGuide {
|
if !hasEpisodeGuide {
|
||||||
return c.Redirect(hmnurl.BuildProjectHomepage(slug), http.StatusSeeOther)
|
return c.Redirect(UrlForProject(c.CurrentProject), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, foundTopic := topicsForProject(slug, topic)
|
_, foundTopic := topicsForProject(slug, topic)
|
||||||
|
|
|
@ -15,7 +15,7 @@ func UrlForGenericThread(thread *models.Thread, lineageBuilder *models.SubforumL
|
||||||
return hmnurl.BuildForumThread(projectSlug, lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), thread.ID, thread.Title, 1)
|
return hmnurl.BuildForumThread(projectSlug, lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), thread.ID, thread.Title, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
return hmnurl.BuildProjectHomepage(projectSlug)
|
return hmnurl.BuildOfficialProjectHomepage(projectSlug) // TODO: both official and personal projects
|
||||||
}
|
}
|
||||||
|
|
||||||
func UrlForGenericPost(thread *models.Thread, post *models.Post, lineageBuilder *models.SubforumLineageBuilder, projectSlug string) string {
|
func UrlForGenericPost(thread *models.Thread, post *models.Post, lineageBuilder *models.SubforumLineageBuilder, projectSlug string) string {
|
||||||
|
@ -26,7 +26,7 @@ func UrlForGenericPost(thread *models.Thread, post *models.Post, lineageBuilder
|
||||||
return hmnurl.BuildForumPost(projectSlug, lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), post.ThreadID, post.ID)
|
return hmnurl.BuildForumPost(projectSlug, lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), post.ThreadID, post.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return hmnurl.BuildProjectHomepage(projectSlug)
|
return hmnurl.BuildOfficialProjectHomepage(projectSlug) // TODO: both official and personal projects
|
||||||
}
|
}
|
||||||
|
|
||||||
var PostTypeMap = map[models.ThreadType][]templates.PostType{
|
var PostTypeMap = map[models.ThreadType][]templates.PostType{
|
||||||
|
@ -55,7 +55,7 @@ func GenericThreadBreadcrumbs(lineageBuilder *models.SubforumLineageBuilder, pro
|
||||||
result = []templates.Breadcrumb{
|
result = []templates.Breadcrumb{
|
||||||
{
|
{
|
||||||
Name: project.Name,
|
Name: project.Name,
|
||||||
Url: hmnurl.BuildProjectHomepage(project.Slug),
|
Url: UrlForProject(project),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: ThreadTypeDisplayNames[thread.Type],
|
Name: ThreadTypeDisplayNames[thread.Type],
|
||||||
|
@ -73,7 +73,7 @@ func BuildProjectRootResourceUrl(projectSlug string, kind models.ThreadType) str
|
||||||
case models.ThreadTypeForumPost:
|
case models.ThreadTypeForumPost:
|
||||||
return hmnurl.BuildForum(projectSlug, nil, 1)
|
return hmnurl.BuildForum(projectSlug, nil, 1)
|
||||||
}
|
}
|
||||||
return hmnurl.BuildProjectHomepage(projectSlug)
|
return hmnurl.BuildOfficialProjectHomepage(projectSlug) // TODO: both official and personal projects
|
||||||
}
|
}
|
||||||
|
|
||||||
func MakePostListItem(
|
func MakePostListItem(
|
||||||
|
|
|
@ -18,8 +18,9 @@ const (
|
||||||
|
|
||||||
type ProjectsQuery struct {
|
type ProjectsQuery struct {
|
||||||
// Available on all project queries
|
// Available on all project queries
|
||||||
Lifecycles []models.ProjectLifecycle // if empty, defaults to models.VisibleProjectLifecycles
|
Lifecycles []models.ProjectLifecycle // if empty, defaults to models.VisibleProjectLifecycles
|
||||||
Types ProjectTypeQuery // bitfield
|
Types ProjectTypeQuery // bitfield
|
||||||
|
IncludeHidden bool
|
||||||
|
|
||||||
// Ignored when using FetchProject
|
// Ignored when using FetchProject
|
||||||
ProjectIDs []int // if empty, all projects
|
ProjectIDs []int // if empty, all projects
|
||||||
|
@ -62,8 +63,11 @@ func FetchProjects(
|
||||||
FROM
|
FROM
|
||||||
handmade_project AS project
|
handmade_project AS project
|
||||||
WHERE
|
WHERE
|
||||||
NOT hidden
|
TRUE
|
||||||
`)
|
`)
|
||||||
|
if !q.IncludeHidden {
|
||||||
|
qb.Add(`AND NOT hidden`)
|
||||||
|
}
|
||||||
if len(q.ProjectIDs) > 0 {
|
if len(q.ProjectIDs) > 0 {
|
||||||
qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs)
|
qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs)
|
||||||
}
|
}
|
||||||
|
@ -386,7 +390,7 @@ func FetchProjectOwners(
|
||||||
|
|
||||||
func UrlForProject(p *models.Project) string {
|
func UrlForProject(p *models.Project) string {
|
||||||
if p.Personal {
|
if p.Personal {
|
||||||
return hmnurl.BuildPersonalProjectHomepage(p.ID, models.GeneratePersonalProjectSlug(p.Name))
|
return hmnurl.BuildPersonalProject(p.ID, models.GeneratePersonalProjectSlug(p.Name))
|
||||||
} else {
|
} else {
|
||||||
return hmnurl.BuildOfficialProjectHomepage(p.Slug)
|
return hmnurl.BuildOfficialProjectHomepage(p.Slug)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,10 @@
|
||||||
package website
|
package website
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/db"
|
"git.handmade.network/hmn/hmn/src/db"
|
||||||
|
@ -155,49 +153,15 @@ type ProjectHomepageData struct {
|
||||||
|
|
||||||
func ProjectHomepage(c *RequestContext) ResponseData {
|
func ProjectHomepage(c *RequestContext) ResponseData {
|
||||||
maxRecentActivity := 15
|
maxRecentActivity := 15
|
||||||
var project *models.Project
|
|
||||||
|
|
||||||
if c.CurrentProject.IsHMN() {
|
if c.CurrentProject == nil {
|
||||||
// Viewing a personal project
|
|
||||||
idStr := c.PathParams["id"]
|
|
||||||
slug := c.PathParams["slug"]
|
|
||||||
|
|
||||||
id, err := strconv.Atoi(idStr)
|
|
||||||
if err != nil {
|
|
||||||
panic(oops.New(err, "id was not numeric (bad regex in routing)"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if id == models.HMNProjectID {
|
|
||||||
return c.Redirect(hmnurl.BuildHomepage(), http.StatusPermanentRedirect)
|
|
||||||
}
|
|
||||||
|
|
||||||
p, err := FetchProject(c.Context(), c.Conn, c.CurrentUser, id, ProjectsQuery{})
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, db.NotFound) {
|
|
||||||
return FourOhFour(c)
|
|
||||||
} else {
|
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project by slug"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
correctSlug := models.GeneratePersonalProjectSlug(p.Project.Name)
|
|
||||||
if slug != correctSlug {
|
|
||||||
return c.Redirect(hmnurl.BuildPersonalProjectHomepage(id, correctSlug), http.StatusPermanentRedirect)
|
|
||||||
}
|
|
||||||
|
|
||||||
project = &p.Project
|
|
||||||
} else {
|
|
||||||
project = c.CurrentProject
|
|
||||||
}
|
|
||||||
|
|
||||||
if project == nil {
|
|
||||||
return FourOhFour(c)
|
return FourOhFour(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// There are no further permission checks to do, because permissions are
|
// There are no further permission checks to do, because permissions are
|
||||||
// checked whatever way we fetch the project.
|
// checked whatever way we fetch the project.
|
||||||
|
|
||||||
owners, err := FetchProjectOwners(c.Context(), c.Conn, project.ID)
|
owners, err := FetchProjectOwners(c.Context(), c.Conn, c.CurrentProject.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, err)
|
return c.ErrorResponse(http.StatusInternalServerError, err)
|
||||||
}
|
}
|
||||||
|
@ -215,7 +179,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
||||||
WHERE
|
WHERE
|
||||||
handmade_project_screenshots.project_id = $1
|
handmade_project_screenshots.project_id = $1
|
||||||
`,
|
`,
|
||||||
project.ID,
|
c.CurrentProject.ID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch screenshots for project"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch screenshots for project"))
|
||||||
|
@ -235,7 +199,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
||||||
link.project_id = $1
|
link.project_id = $1
|
||||||
ORDER BY link.ordering ASC
|
ORDER BY link.ordering ASC
|
||||||
`,
|
`,
|
||||||
project.ID,
|
c.CurrentProject.ID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project links"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project links"))
|
||||||
|
@ -265,7 +229,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
||||||
ORDER BY post.postdate DESC
|
ORDER BY post.postdate DESC
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
`,
|
`,
|
||||||
project.ID,
|
c.CurrentProject.ID,
|
||||||
maxRecentActivity,
|
maxRecentActivity,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -275,36 +239,36 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
||||||
|
|
||||||
var projectHomepageData ProjectHomepageData
|
var projectHomepageData ProjectHomepageData
|
||||||
|
|
||||||
projectHomepageData.BaseData = getBaseData(c, project.Name, nil)
|
projectHomepageData.BaseData = getBaseData(c, c.CurrentProject.Name, nil)
|
||||||
if canEdit {
|
//if canEdit {
|
||||||
// TODO: Move to project-specific navigation
|
// // TODO: Move to project-specific navigation
|
||||||
// projectHomepageData.BaseData.Header.EditURL = hmnurl.BuildProjectEdit(project.Slug, "")
|
// // projectHomepageData.BaseData.Header.EditURL = hmnurl.BuildProjectEdit(project.Slug, "")
|
||||||
}
|
//}
|
||||||
projectHomepageData.BaseData.OpenGraphItems = append(projectHomepageData.BaseData.OpenGraphItems, templates.OpenGraphItem{
|
projectHomepageData.BaseData.OpenGraphItems = append(projectHomepageData.BaseData.OpenGraphItems, templates.OpenGraphItem{
|
||||||
Property: "og:description",
|
Property: "og:description",
|
||||||
Value: project.Blurb,
|
Value: c.CurrentProject.Blurb,
|
||||||
})
|
})
|
||||||
|
|
||||||
projectHomepageData.Project = templates.ProjectToTemplate(project, c.Theme)
|
projectHomepageData.Project = templates.ProjectToTemplate(c.CurrentProject, c.Theme)
|
||||||
for _, owner := range owners {
|
for _, owner := range owners {
|
||||||
projectHomepageData.Owners = append(projectHomepageData.Owners, templates.UserToTemplate(owner, c.Theme))
|
projectHomepageData.Owners = append(projectHomepageData.Owners, templates.UserToTemplate(owner, c.Theme))
|
||||||
}
|
}
|
||||||
|
|
||||||
if project.Hidden {
|
if c.CurrentProject.Hidden {
|
||||||
projectHomepageData.BaseData.AddImmediateNotice(
|
projectHomepageData.BaseData.AddImmediateNotice(
|
||||||
"hidden",
|
"hidden",
|
||||||
"NOTICE: This project is hidden. It is currently visible only to owners and site admins.",
|
"NOTICE: This project is hidden. It is currently visible only to owners and site admins.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if project.Lifecycle != models.ProjectLifecycleActive {
|
if c.CurrentProject.Lifecycle != models.ProjectLifecycleActive {
|
||||||
switch project.Lifecycle {
|
switch c.CurrentProject.Lifecycle {
|
||||||
case models.ProjectLifecycleUnapproved:
|
case models.ProjectLifecycleUnapproved:
|
||||||
projectHomepageData.BaseData.AddImmediateNotice(
|
projectHomepageData.BaseData.AddImmediateNotice(
|
||||||
"unapproved",
|
"unapproved",
|
||||||
fmt.Sprintf(
|
fmt.Sprintf(
|
||||||
"NOTICE: This project has not yet been submitted for approval. It is only visible to owners. Please <a href=\"%s\">submit it for approval</a> when the project content is ready for review.",
|
"NOTICE: This project has not yet been submitted for approval. It is only visible to owners. Please <a href=\"%s\">submit it for approval</a> when the project content is ready for review.",
|
||||||
hmnurl.BuildProjectEdit(project.Slug, "submit"),
|
hmnurl.BuildProjectEdit(c.CurrentProject.Slug, "submit"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
case models.ProjectLifecycleApprovalRequired:
|
case models.ProjectLifecycleApprovalRequired:
|
||||||
|
@ -348,7 +312,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
||||||
lineageBuilder,
|
lineageBuilder,
|
||||||
&post.(*postQuery).Post,
|
&post.(*postQuery).Post,
|
||||||
&post.(*postQuery).Thread,
|
&post.(*postQuery).Thread,
|
||||||
project,
|
c.CurrentProject,
|
||||||
&post.(*postQuery).Author,
|
&post.(*postQuery).Author,
|
||||||
c.Theme,
|
c.Theme,
|
||||||
))
|
))
|
||||||
|
|
|
@ -30,12 +30,13 @@ type Router struct {
|
||||||
|
|
||||||
type Route struct {
|
type Route struct {
|
||||||
Method string
|
Method string
|
||||||
Regex *regexp.Regexp
|
Regexes []*regexp.Regexp
|
||||||
Handler Handler
|
Handler Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
type RouteBuilder struct {
|
type RouteBuilder struct {
|
||||||
Router *Router
|
Router *Router
|
||||||
|
Prefixes []*regexp.Regexp
|
||||||
Middleware Middleware
|
Middleware Middleware
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -44,11 +45,17 @@ type Handler func(c *RequestContext) ResponseData
|
||||||
type Middleware func(h Handler) Handler
|
type Middleware func(h Handler) Handler
|
||||||
|
|
||||||
func (rb *RouteBuilder) Handle(methods []string, regex *regexp.Regexp, h Handler) {
|
func (rb *RouteBuilder) Handle(methods []string, regex *regexp.Regexp, h Handler) {
|
||||||
|
// Ensure that this regex matches the start of the string
|
||||||
|
regexStr := regex.String()
|
||||||
|
if len(regexStr) == 0 || regexStr[0] != '^' {
|
||||||
|
panic("All routing regexes must begin with '^'")
|
||||||
|
}
|
||||||
|
|
||||||
h = rb.Middleware(h)
|
h = rb.Middleware(h)
|
||||||
for _, method := range methods {
|
for _, method := range methods {
|
||||||
rb.Router.Routes = append(rb.Router.Routes, Route{
|
rb.Router.Routes = append(rb.Router.Routes, Route{
|
||||||
Method: method,
|
Method: method,
|
||||||
Regex: regex,
|
Regexes: append(rb.Prefixes, regex),
|
||||||
Handler: h,
|
Handler: h,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -66,33 +73,36 @@ func (rb *RouteBuilder) POST(regex *regexp.Regexp, h Handler) {
|
||||||
rb.Handle([]string{http.MethodPost}, regex, h)
|
rb.Handle([]string{http.MethodPost}, regex, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rb *RouteBuilder) Group(regex *regexp.Regexp, addRoutes func(rb *RouteBuilder)) {
|
||||||
|
newRb := *rb
|
||||||
|
newRb.Prefixes = append(newRb.Prefixes, regex)
|
||||||
|
addRoutes(&newRb)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||||
path := req.URL.Path
|
nextroute:
|
||||||
for _, route := range r.Routes {
|
for _, route := range r.Routes {
|
||||||
if route.Method != "" && req.Method != route.Method {
|
if route.Method != "" && req.Method != route.Method {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
path = strings.TrimSuffix(path, "/")
|
currentPath := strings.TrimSuffix(req.URL.Path, "/")
|
||||||
if path == "" {
|
if currentPath == "" {
|
||||||
path = "/"
|
currentPath = "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
match := route.Regex.FindStringSubmatch(path)
|
var params map[string]string
|
||||||
if match == nil {
|
for _, regex := range route.Regexes {
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
c := &RequestContext{
|
match := regex.FindStringSubmatch(currentPath)
|
||||||
Route: route.Regex.String(),
|
if len(match) == 0 {
|
||||||
Logger: logging.GlobalLogger(),
|
continue nextroute
|
||||||
Req: req,
|
}
|
||||||
Res: rw,
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(match) > 0 {
|
if params == nil {
|
||||||
params := map[string]string{}
|
params = map[string]string{}
|
||||||
subexpNames := route.Regex.SubexpNames()
|
}
|
||||||
|
subexpNames := regex.SubexpNames()
|
||||||
for i, paramValue := range match {
|
for i, paramValue := range match {
|
||||||
paramName := subexpNames[i]
|
paramName := subexpNames[i]
|
||||||
if paramName == "" {
|
if paramName == "" {
|
||||||
|
@ -100,15 +110,35 @@ func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||||
}
|
}
|
||||||
params[paramName] = paramValue
|
params[paramName] = paramValue
|
||||||
}
|
}
|
||||||
c.PathParams = params
|
|
||||||
|
// Make sure that we never consume trailing slashes even if the route regex matches them
|
||||||
|
toConsume := strings.TrimSuffix(match[0], "/")
|
||||||
|
currentPath = currentPath[len(toConsume):]
|
||||||
|
if currentPath == "" {
|
||||||
|
currentPath = "/"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var routeStrings []string
|
||||||
|
for _, regex := range route.Regexes {
|
||||||
|
routeStrings = append(routeStrings, regex.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &RequestContext{
|
||||||
|
Route: fmt.Sprintf("%v", routeStrings),
|
||||||
|
Logger: logging.GlobalLogger(),
|
||||||
|
Req: req,
|
||||||
|
Res: rw,
|
||||||
|
PathParams: params,
|
||||||
|
}
|
||||||
|
c.PathParams = params
|
||||||
|
|
||||||
doRequest(rw, c, route.Handler)
|
doRequest(rw, c, route.Handler)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
panic(fmt.Sprintf("Path '%s' did not match any routes! Make sure to register a wildcard route to act as a 404.", path))
|
panic(fmt.Sprintf("Path '%s' did not match any routes! Make sure to register a wildcard route to act as a 404.", req.URL))
|
||||||
}
|
}
|
||||||
|
|
||||||
type RequestContext struct {
|
type RequestContext struct {
|
||||||
|
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -154,14 +155,6 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
|
||||||
return res
|
return res
|
||||||
})
|
})
|
||||||
|
|
||||||
anyProject.GET(hmnurl.RegexHomepage, func(c *RequestContext) ResponseData {
|
|
||||||
if c.CurrentProject.IsHMN() {
|
|
||||||
return Index(c)
|
|
||||||
} else {
|
|
||||||
return ProjectHomepage(c)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// NOTE(asaf): HMN-only routes:
|
// NOTE(asaf): HMN-only routes:
|
||||||
hmnOnly.GET(hmnurl.RegexManifesto, Manifesto)
|
hmnOnly.GET(hmnurl.RegexManifesto, Manifesto)
|
||||||
hmnOnly.GET(hmnurl.RegexAbout, About)
|
hmnOnly.GET(hmnurl.RegexAbout, About)
|
||||||
|
@ -175,14 +168,13 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
|
||||||
|
|
||||||
hmnOnly.GET(hmnurl.RegexOldHome, Index)
|
hmnOnly.GET(hmnurl.RegexOldHome, Index)
|
||||||
|
|
||||||
hmnOnly.POST(hmnurl.RegexLoginAction, securityTimerMiddleware(time.Millisecond*100, Login)) // TODO(asaf): Adjust this after launch
|
hmnOnly.POST(hmnurl.RegexLoginAction, securityTimerMiddleware(time.Millisecond*100, Login))
|
||||||
hmnOnly.GET(hmnurl.RegexLogoutAction, Logout)
|
hmnOnly.GET(hmnurl.RegexLogoutAction, Logout)
|
||||||
hmnOnly.GET(hmnurl.RegexLoginPage, LoginPage)
|
hmnOnly.GET(hmnurl.RegexLoginPage, LoginPage)
|
||||||
|
|
||||||
hmnOnly.GET(hmnurl.RegexRegister, RegisterNewUser)
|
hmnOnly.GET(hmnurl.RegexRegister, RegisterNewUser)
|
||||||
hmnOnly.POST(hmnurl.RegexRegister, securityTimerMiddleware(email.ExpectedEmailSendDuration, RegisterNewUserSubmit))
|
hmnOnly.POST(hmnurl.RegexRegister, securityTimerMiddleware(email.ExpectedEmailSendDuration, RegisterNewUserSubmit))
|
||||||
hmnOnly.GET(hmnurl.RegexRegistrationSuccess, RegisterNewUserSuccess)
|
hmnOnly.GET(hmnurl.RegexRegistrationSuccess, RegisterNewUserSuccess)
|
||||||
hmnOnly.GET(hmnurl.RegexOldEmailConfirmation, EmailConfirmation) // TODO(asaf): Delete this a bit after launch
|
|
||||||
hmnOnly.GET(hmnurl.RegexEmailConfirmation, EmailConfirmation)
|
hmnOnly.GET(hmnurl.RegexEmailConfirmation, EmailConfirmation)
|
||||||
hmnOnly.POST(hmnurl.RegexEmailConfirmation, EmailConfirmationSubmit)
|
hmnOnly.POST(hmnurl.RegexEmailConfirmation, EmailConfirmationSubmit)
|
||||||
|
|
||||||
|
@ -202,7 +194,6 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
|
||||||
hmnOnly.GET(hmnurl.RegexShowcase, Showcase)
|
hmnOnly.GET(hmnurl.RegexShowcase, Showcase)
|
||||||
hmnOnly.GET(hmnurl.RegexSnippet, Snippet)
|
hmnOnly.GET(hmnurl.RegexSnippet, Snippet)
|
||||||
hmnOnly.GET(hmnurl.RegexProjectIndex, ProjectIndex)
|
hmnOnly.GET(hmnurl.RegexProjectIndex, ProjectIndex)
|
||||||
hmnOnly.GET(hmnurl.RegexPersonalProjectHomepage, ProjectHomepage)
|
|
||||||
|
|
||||||
hmnOnly.GET(hmnurl.RegexDiscordOAuthCallback, authMiddleware(DiscordOAuthCallback))
|
hmnOnly.GET(hmnurl.RegexDiscordOAuthCallback, authMiddleware(DiscordOAuthCallback))
|
||||||
hmnOnly.POST(hmnurl.RegexDiscordUnlink, authMiddleware(csrfMiddleware(DiscordUnlink)))
|
hmnOnly.POST(hmnurl.RegexDiscordUnlink, authMiddleware(csrfMiddleware(DiscordUnlink)))
|
||||||
|
@ -225,36 +216,97 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
|
||||||
hmnOnly.GET(hmnurl.RegexLibraryAny, LibraryNotPortedYet)
|
hmnOnly.GET(hmnurl.RegexLibraryAny, LibraryNotPortedYet)
|
||||||
|
|
||||||
// NOTE(asaf): Any-project routes:
|
// NOTE(asaf): Any-project routes:
|
||||||
anyProject.GET(hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread))
|
attachProjectRoutes := func(rb *RouteBuilder) {
|
||||||
anyProject.POST(hmnurl.RegexForumNewThreadSubmit, authMiddleware(csrfMiddleware(ForumNewThreadSubmit)))
|
rb.GET(hmnurl.RegexHomepage, func(c *RequestContext) ResponseData {
|
||||||
anyProject.GET(hmnurl.RegexForumThread, ForumThread)
|
if c.CurrentProject.IsHMN() {
|
||||||
anyProject.GET(hmnurl.RegexForum, Forum)
|
return Index(c)
|
||||||
anyProject.POST(hmnurl.RegexForumMarkRead, authMiddleware(csrfMiddleware(ForumMarkRead)))
|
} else {
|
||||||
anyProject.GET(hmnurl.RegexForumPost, ForumPostRedirect)
|
return ProjectHomepage(c)
|
||||||
anyProject.GET(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReply))
|
}
|
||||||
anyProject.POST(hmnurl.RegexForumPostReply, authMiddleware(csrfMiddleware(ForumPostReplySubmit)))
|
})
|
||||||
anyProject.GET(hmnurl.RegexForumPostEdit, authMiddleware(ForumPostEdit))
|
|
||||||
anyProject.POST(hmnurl.RegexForumPostEdit, authMiddleware(csrfMiddleware(ForumPostEditSubmit)))
|
|
||||||
anyProject.GET(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDelete))
|
|
||||||
anyProject.POST(hmnurl.RegexForumPostDelete, authMiddleware(csrfMiddleware(ForumPostDeleteSubmit)))
|
|
||||||
anyProject.GET(hmnurl.RegexWikiArticle, WikiArticleRedirect)
|
|
||||||
|
|
||||||
anyProject.GET(hmnurl.RegexBlog, BlogIndex)
|
rb.POST(hmnurl.RegexForumNewThreadSubmit, authMiddleware(csrfMiddleware(ForumNewThreadSubmit)))
|
||||||
anyProject.GET(hmnurl.RegexBlogNewThread, authMiddleware(BlogNewThread))
|
rb.GET(hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread))
|
||||||
anyProject.POST(hmnurl.RegexBlogNewThread, authMiddleware(csrfMiddleware(BlogNewThreadSubmit)))
|
rb.GET(hmnurl.RegexForumThread, ForumThread)
|
||||||
anyProject.GET(hmnurl.RegexBlogThread, BlogThread)
|
rb.GET(hmnurl.RegexForum, Forum)
|
||||||
anyProject.GET(hmnurl.RegexBlogPost, BlogPostRedirectToThread)
|
rb.POST(hmnurl.RegexForumMarkRead, authMiddleware(csrfMiddleware(ForumMarkRead)))
|
||||||
anyProject.GET(hmnurl.RegexBlogPostReply, authMiddleware(BlogPostReply))
|
rb.GET(hmnurl.RegexForumPost, ForumPostRedirect)
|
||||||
anyProject.POST(hmnurl.RegexBlogPostReply, authMiddleware(csrfMiddleware(BlogPostReplySubmit)))
|
rb.GET(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReply))
|
||||||
anyProject.GET(hmnurl.RegexBlogPostEdit, authMiddleware(BlogPostEdit))
|
rb.POST(hmnurl.RegexForumPostReply, authMiddleware(csrfMiddleware(ForumPostReplySubmit)))
|
||||||
anyProject.POST(hmnurl.RegexBlogPostEdit, authMiddleware(csrfMiddleware(BlogPostEditSubmit)))
|
rb.GET(hmnurl.RegexForumPostEdit, authMiddleware(ForumPostEdit))
|
||||||
anyProject.GET(hmnurl.RegexBlogPostDelete, authMiddleware(BlogPostDelete))
|
rb.POST(hmnurl.RegexForumPostEdit, authMiddleware(csrfMiddleware(ForumPostEditSubmit)))
|
||||||
anyProject.POST(hmnurl.RegexBlogPostDelete, authMiddleware(csrfMiddleware(BlogPostDeleteSubmit)))
|
rb.GET(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDelete))
|
||||||
anyProject.GET(hmnurl.RegexBlogsRedirect, func(c *RequestContext) ResponseData {
|
rb.POST(hmnurl.RegexForumPostDelete, authMiddleware(csrfMiddleware(ForumPostDeleteSubmit)))
|
||||||
return c.Redirect(hmnurl.ProjectUrl(
|
rb.GET(hmnurl.RegexWikiArticle, WikiArticleRedirect)
|
||||||
fmt.Sprintf("blog%s", c.PathParams["remainder"]), nil,
|
|
||||||
c.CurrentProject.Slug,
|
rb.GET(hmnurl.RegexBlog, BlogIndex)
|
||||||
), http.StatusMovedPermanently)
|
rb.GET(hmnurl.RegexBlogNewThread, authMiddleware(BlogNewThread))
|
||||||
|
rb.POST(hmnurl.RegexBlogNewThread, authMiddleware(csrfMiddleware(BlogNewThreadSubmit)))
|
||||||
|
rb.GET(hmnurl.RegexBlogThread, BlogThread)
|
||||||
|
rb.GET(hmnurl.RegexBlogPost, BlogPostRedirectToThread)
|
||||||
|
rb.GET(hmnurl.RegexBlogPostReply, authMiddleware(BlogPostReply))
|
||||||
|
rb.POST(hmnurl.RegexBlogPostReply, authMiddleware(csrfMiddleware(BlogPostReplySubmit)))
|
||||||
|
rb.GET(hmnurl.RegexBlogPostEdit, authMiddleware(BlogPostEdit))
|
||||||
|
rb.POST(hmnurl.RegexBlogPostEdit, authMiddleware(csrfMiddleware(BlogPostEditSubmit)))
|
||||||
|
rb.GET(hmnurl.RegexBlogPostDelete, authMiddleware(BlogPostDelete))
|
||||||
|
rb.POST(hmnurl.RegexBlogPostDelete, authMiddleware(csrfMiddleware(BlogPostDeleteSubmit)))
|
||||||
|
rb.GET(hmnurl.RegexBlogsRedirect, func(c *RequestContext) ResponseData {
|
||||||
|
return c.Redirect(hmnurl.ProjectUrl(
|
||||||
|
fmt.Sprintf("blog%s", c.PathParams["remainder"]), nil,
|
||||||
|
c.CurrentProject.Slug,
|
||||||
|
), http.StatusMovedPermanently)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
hmnOnly.Group(hmnurl.RegexPersonalProject, func(rb *RouteBuilder) {
|
||||||
|
// TODO(ben): Perhaps someday we can make this middleware modification feel better? It seems
|
||||||
|
// pretty common to run the outermost middleware first before doing other stuff, but having
|
||||||
|
// to nest functions this way feels real bad.
|
||||||
|
rb.Middleware = func(h Handler) Handler {
|
||||||
|
return hmnOnly.Middleware(func(c *RequestContext) ResponseData {
|
||||||
|
// At this point we are definitely on the plain old HMN subdomain.
|
||||||
|
|
||||||
|
// Fetch personal project and do whatever
|
||||||
|
id, err := strconv.Atoi(c.PathParams["projectid"])
|
||||||
|
if err != nil {
|
||||||
|
panic(oops.New(err, "project id was not numeric (bad regex in routing)"))
|
||||||
|
}
|
||||||
|
p, err := FetchProject(c.Context(), c.Conn, c.CurrentUser, id, ProjectsQuery{})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, db.NotFound) {
|
||||||
|
return FourOhFour(c)
|
||||||
|
} else {
|
||||||
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch personal project"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !p.Project.Personal {
|
||||||
|
// TODO: Redirect to the same page on the other prefix
|
||||||
|
return c.Redirect(hmnurl.BuildOfficialProjectHomepage(p.Project.Slug), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.CurrentProject = &p.Project
|
||||||
|
|
||||||
|
return h(c)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
attachProjectRoutes(rb)
|
||||||
|
})
|
||||||
|
anyProject.Group(hmnurl.RegexHomepage, func(rb *RouteBuilder) {
|
||||||
|
rb.Middleware = func(h Handler) Handler {
|
||||||
|
return anyProject.Middleware(func(c *RequestContext) ResponseData {
|
||||||
|
// We could be on any project's subdomain.
|
||||||
|
|
||||||
|
// Check if the current project (matched by subdomain) is actually no longer official
|
||||||
|
// and therefore needs to be redirected to the personal project version of the route.
|
||||||
|
if c.CurrentProject.Personal {
|
||||||
|
// TODO: Redirect to the same page on the other prefix
|
||||||
|
return c.Redirect(hmnurl.BuildPersonalProject(c.CurrentProject.ID, c.CurrentProject.Slug), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
return h(c)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
attachProjectRoutes(rb)
|
||||||
})
|
})
|
||||||
|
|
||||||
anyProject.POST(hmnurl.RegexAssetUpload, AssetUpload)
|
anyProject.POST(hmnurl.RegexAssetUpload, AssetUpload)
|
||||||
|
@ -378,15 +430,29 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
|
||||||
slug := strings.TrimRight(hostPrefix, ".")
|
slug := strings.TrimRight(hostPrefix, ".")
|
||||||
|
|
||||||
dbProject, err := FetchProjectBySlug(c.Context(), c.Conn, c.CurrentUser, slug, ProjectsQuery{})
|
dbProject, err := FetchProjectBySlug(c.Context(), c.Conn, c.CurrentUser, slug, ProjectsQuery{})
|
||||||
if err != nil {
|
if err == nil {
|
||||||
|
c.CurrentProject = &dbProject.Project
|
||||||
|
} else {
|
||||||
if errors.Is(err, db.NotFound) {
|
if errors.Is(err, db.NotFound) {
|
||||||
return false, c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
|
// do nothing, this is fine
|
||||||
} else {
|
} else {
|
||||||
return false, c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch current project"))
|
return false, c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch current project"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.CurrentProject = &dbProject.Project
|
if c.CurrentProject == nil {
|
||||||
|
dbProject, err := FetchProject(c.Context(), c.Conn, c.CurrentUser, models.HMNProjectID, ProjectsQuery{
|
||||||
|
IncludeHidden: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(oops.New(err, "failed to fetch HMN project"))
|
||||||
|
}
|
||||||
|
c.CurrentProject = &dbProject.Project
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.CurrentProject == nil {
|
||||||
|
panic("failed to load project data")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
theme := "light"
|
theme := "light"
|
||||||
|
|
Loading…
Reference in New Issue