Project page
This commit is contained in:
		
							parent
							
								
									98df5773a5
								
							
						
					
					
						commit
						6c53688e06
					
				| 
						 | 
				
			
			@ -3231,7 +3231,7 @@ code, .code {
 | 
			
		|||
.w1 {
 | 
			
		||||
  width: 1rem; }
 | 
			
		||||
 | 
			
		||||
.w2, .project-carousel-container .project-carousel-button.active {
 | 
			
		||||
.w2, .carousel-container .carousel-button.active {
 | 
			
		||||
  width: 2rem; }
 | 
			
		||||
 | 
			
		||||
.w3 {
 | 
			
		||||
| 
						 | 
				
			
			@ -9288,52 +9288,6 @@ span.icon-rss::before {
 | 
			
		|||
  background-color: #aa7d30;
 | 
			
		||||
  background-color: var(--notice-lts-reqd-color); }
 | 
			
		||||
 | 
			
		||||
.project-carousel-container {
 | 
			
		||||
  width: 50rem; }
 | 
			
		||||
  .project-carousel-container .project-carousel {
 | 
			
		||||
    box-sizing: content-box;
 | 
			
		||||
    position: relative;
 | 
			
		||||
    height: 12rem; }
 | 
			
		||||
    @media screen and (min-width: 60em) {
 | 
			
		||||
      .project-carousel-container .project-carousel {
 | 
			
		||||
        height: 16rem; } }
 | 
			
		||||
  .project-carousel-container .project-carousel-item {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    top: 0;
 | 
			
		||||
    left: 0; }
 | 
			
		||||
    .project-carousel-container .project-carousel-item:not(.active) {
 | 
			
		||||
      display: none; }
 | 
			
		||||
    .project-carousel-container .project-carousel-item br {
 | 
			
		||||
      line-height: 0.6em; }
 | 
			
		||||
  .project-carousel-container .project-carousel-description {
 | 
			
		||||
    max-height: 14rem;
 | 
			
		||||
    overflow: hidden; }
 | 
			
		||||
  .project-carousel-container .project-carousel-fade {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    left: 0;
 | 
			
		||||
    right: 0;
 | 
			
		||||
    bottom: 0;
 | 
			
		||||
    height: 30px;
 | 
			
		||||
    background: linear-gradient( rgba(240, 240, 240, 0) , #f0f0f0 );
 | 
			
		||||
    background: linear-gradient( var(--dim-background-transparent) , var(--dim-background) ); }
 | 
			
		||||
  .project-carousel-container .project-carousel-item-small:not(.active) {
 | 
			
		||||
    display: none; }
 | 
			
		||||
  .project-carousel-container .project-carousel-button {
 | 
			
		||||
    border: 1px solid;
 | 
			
		||||
    border-color: #999;
 | 
			
		||||
    border-color: var(--dimmer-color);
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
    transition: all 100ms ease-in-out; }
 | 
			
		||||
    .project-carousel-container .project-carousel-button:hover {
 | 
			
		||||
      background-color: #bbb;
 | 
			
		||||
      background-color: var(--dimmest-color); }
 | 
			
		||||
    .project-carousel-container .project-carousel-button.active {
 | 
			
		||||
      border-color: #666;
 | 
			
		||||
      border-color: var(--theme-color); }
 | 
			
		||||
      .project-carousel-container .project-carousel-button.active:hover {
 | 
			
		||||
        background-color: #ccc;
 | 
			
		||||
        background-color: var(--theme-color-dimmest); }
 | 
			
		||||
 | 
			
		||||
.project-card {
 | 
			
		||||
  color: black;
 | 
			
		||||
  color: var(--fg-font-color);
 | 
			
		||||
| 
						 | 
				
			
			@ -9546,3 +9500,49 @@ span.icon-rss::before {
 | 
			
		|||
    .timeline-modal .container {
 | 
			
		||||
      width: auto;
 | 
			
		||||
      max-height: calc(100vh - 2rem); } }
 | 
			
		||||
 | 
			
		||||
.carousel-container {
 | 
			
		||||
  width: 50rem; }
 | 
			
		||||
  .carousel-container .carousel {
 | 
			
		||||
    box-sizing: content-box;
 | 
			
		||||
    position: relative; }
 | 
			
		||||
  .carousel-container .carousel-item {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    top: 0;
 | 
			
		||||
    left: 0; }
 | 
			
		||||
    .carousel-container .carousel-item:not(.active) {
 | 
			
		||||
      display: none; }
 | 
			
		||||
    .carousel-container .carousel-item br {
 | 
			
		||||
      line-height: 0.6em; }
 | 
			
		||||
  .carousel-container .carousel-description {
 | 
			
		||||
    max-height: 14rem;
 | 
			
		||||
    overflow: hidden; }
 | 
			
		||||
  .carousel-container .carousel-fade {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    left: 0;
 | 
			
		||||
    right: 0;
 | 
			
		||||
    bottom: 0;
 | 
			
		||||
    height: 30px;
 | 
			
		||||
    background: linear-gradient( rgba(240, 240, 240, 0) , #f0f0f0 );
 | 
			
		||||
    background: linear-gradient( var(--dim-background-transparent) , var(--dim-background) ); }
 | 
			
		||||
  .carousel-container .carousel-item-small {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    top: 0;
 | 
			
		||||
    left: 0; }
 | 
			
		||||
    .carousel-container .carousel-item-small:not(.active) {
 | 
			
		||||
      display: none; }
 | 
			
		||||
  .carousel-container .carousel-button {
 | 
			
		||||
    border: 1px solid;
 | 
			
		||||
    border-color: #999;
 | 
			
		||||
    border-color: var(--dimmer-color);
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
    transition: all 100ms ease-in-out; }
 | 
			
		||||
    .carousel-container .carousel-button:hover {
 | 
			
		||||
      background-color: #bbb;
 | 
			
		||||
      background-color: var(--dimmest-color); }
 | 
			
		||||
    .carousel-container .carousel-button.active {
 | 
			
		||||
      border-color: #666;
 | 
			
		||||
      border-color: var(--theme-color); }
 | 
			
		||||
      .carousel-container .carousel-button.active:hover {
 | 
			
		||||
        background-color: #ccc;
 | 
			
		||||
        background-color: var(--theme-color-dimmest); }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -112,6 +112,10 @@ func TestProjectNotApproved(t *testing.T) {
 | 
			
		|||
	AssertRegexMatch(t, BuildProjectNotApproved("test"), RegexProjectNotApproved, map[string]string{"slug": "test"})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestProjectEdit(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildProjectEdit("test", "foo"), RegexProjectEdit, map[string]string{"slug": "test"})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestPodcast(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildPodcast(""), RegexPodcast, nil)
 | 
			
		||||
	AssertSubdomain(t, BuildPodcast(""), "")
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -232,6 +232,14 @@ func BuildProjectNotApproved(slug string) string {
 | 
			
		|||
	return Url(fmt.Sprintf("/p/%s", slug), nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexProjectEdit = regexp.MustCompile("^/p/(?P<slug>.+)/edit$")
 | 
			
		||||
 | 
			
		||||
func BuildProjectEdit(slug string, section string) string {
 | 
			
		||||
	defer CatchPanic()
 | 
			
		||||
 | 
			
		||||
	return ProjectUrlWithFragment(fmt.Sprintf("/p/%s/edit", slug), nil, "", section)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
* Podcast
 | 
			
		||||
 */
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,74 @@
 | 
			
		|||
.carousel-container {
 | 
			
		||||
  width: 50rem;
 | 
			
		||||
 | 
			
		||||
  .carousel {
 | 
			
		||||
    box-sizing: content-box;
 | 
			
		||||
    position: relative;
 | 
			
		||||
    // height: 12rem;
 | 
			
		||||
 | 
			
		||||
    @media #{$breakpoint-large} {
 | 
			
		||||
      // height: $height-5;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .carousel-item {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    top: 0;
 | 
			
		||||
    left: 0;
 | 
			
		||||
 | 
			
		||||
    &:not(.active) {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    br {
 | 
			
		||||
      line-height: 0.6em;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .carousel-description {
 | 
			
		||||
    max-height: 14rem;
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .carousel-fade {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    left: 0;
 | 
			
		||||
    right: 0;
 | 
			
		||||
    bottom: 0;
 | 
			
		||||
    height: 30px;
 | 
			
		||||
 | 
			
		||||
    @include usevar(background, "linear-gradient(" dim-background-transparent "," dim-background ")")
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .carousel-item-small {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    top: 0;
 | 
			
		||||
    left: 0;
 | 
			
		||||
 | 
			
		||||
    &:not(.active) {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .carousel-button {
 | 
			
		||||
    border: 1px solid;
 | 
			
		||||
    @include usevar(border-color, dimmer-color);
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
 | 
			
		||||
    transition: all 100ms ease-in-out;
 | 
			
		||||
 | 
			
		||||
    &:hover {
 | 
			
		||||
      @include usevar(background-color, dimmest-color);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &.active {
 | 
			
		||||
      @include usevar(border-color, theme-color);
 | 
			
		||||
      @extend .w2;
 | 
			
		||||
 | 
			
		||||
      &:hover {
 | 
			
		||||
        @include usevar(background-color, theme-color-dimmest);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -93,76 +93,6 @@
 | 
			
		|||
  @include usevar(background-color, notice-lts-reqd-color);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.project-carousel-container {
 | 
			
		||||
  width: 50rem;
 | 
			
		||||
 | 
			
		||||
  .project-carousel {
 | 
			
		||||
    box-sizing: content-box;
 | 
			
		||||
    position: relative;
 | 
			
		||||
    height: 12rem;
 | 
			
		||||
 | 
			
		||||
    @media #{$breakpoint-large} {
 | 
			
		||||
      height: $height-5;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .project-carousel-item {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    top: 0;
 | 
			
		||||
    left: 0;
 | 
			
		||||
 | 
			
		||||
    &:not(.active) {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    br {
 | 
			
		||||
      line-height: 0.6em;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .project-carousel-description {
 | 
			
		||||
    max-height: 14rem;
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .project-carousel-fade {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    left: 0;
 | 
			
		||||
    right: 0;
 | 
			
		||||
    bottom: 0;
 | 
			
		||||
    height: 30px;
 | 
			
		||||
 | 
			
		||||
    @include usevar(background, "linear-gradient(" dim-background-transparent "," dim-background ")")
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .project-carousel-item-small {
 | 
			
		||||
    &:not(.active) {
 | 
			
		||||
      display: none;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .project-carousel-button {
 | 
			
		||||
    border: 1px solid;
 | 
			
		||||
    @include usevar(border-color, dimmer-color);
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
 | 
			
		||||
    transition: all 100ms ease-in-out;
 | 
			
		||||
 | 
			
		||||
    &:hover {
 | 
			
		||||
      @include usevar(background-color, dimmest-color);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &.active {
 | 
			
		||||
      @include usevar(border-color, theme-color);
 | 
			
		||||
      @extend .w2;
 | 
			
		||||
 | 
			
		||||
      &:hover {
 | 
			
		||||
        @include usevar(background-color, theme-color-dimmest);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.project-card {
 | 
			
		||||
  @include usevar(color, 'fg-font-color');
 | 
			
		||||
  @include usevar(background-color, 'card-background');
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -23,3 +23,4 @@
 | 
			
		|||
@import 'showcase';
 | 
			
		||||
@import 'streams';
 | 
			
		||||
@import 'timeline';
 | 
			
		||||
@import 'carousel';
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -216,16 +216,25 @@ func ParseKnownServicesForLink(link *models.Link) (serviceName string, userData
 | 
			
		|||
 | 
			
		||||
func LinkToTemplate(link *models.Link) Link {
 | 
			
		||||
	name := ""
 | 
			
		||||
	/*
 | 
			
		||||
		// NOTE(asaf): While Name and Key are separate things, Name is almost always the same as Key in the db, which looks weird.
 | 
			
		||||
		//             So we're just going to ignore Name until we decide it's worth reusing.
 | 
			
		||||
		if link.Name != nil {
 | 
			
		||||
			name = *link.Name
 | 
			
		||||
		}
 | 
			
		||||
	*/
 | 
			
		||||
	serviceName, serviceUserData := ParseKnownServicesForLink(link)
 | 
			
		||||
	if serviceUserData != "" {
 | 
			
		||||
		name = serviceUserData
 | 
			
		||||
	}
 | 
			
		||||
	if name == "" {
 | 
			
		||||
		name = link.Value
 | 
			
		||||
	}
 | 
			
		||||
	return Link{
 | 
			
		||||
		Key:  link.Key,
 | 
			
		||||
		ServiceName:     serviceName,
 | 
			
		||||
		ServiceUserData: serviceUserData,
 | 
			
		||||
		Name: name,
 | 
			
		||||
		Value:           link.Value,
 | 
			
		||||
		Icon: serviceName,
 | 
			
		||||
		Url:  link.Value,
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -64,10 +64,9 @@
 | 
			
		|||
                {{ if false }}
 | 
			
		||||
                    <a href="{{ .Header.EpisodeGuideUrl }}" class="annotations">Episode Guide</a>
 | 
			
		||||
                {{ end }}
 | 
			
		||||
                {{/* {% if showEditLink == True %} */}}
 | 
			
		||||
                {{/* {{ if false }}
 | 
			
		||||
                    <a class="edit" href="{{ .Header.EditUrl }}" title="Edit {{ project.name }}"><span class="icon">0</span> Settings</a>
 | 
			
		||||
                {{ end }} */}}
 | 
			
		||||
                {{ if .Header.EditUrl }}
 | 
			
		||||
                    <a class="edit" href="{{ .Header.EditUrl }}" title="Edit {{ .Project.Name }}"><span class="icon">0</span> Settings</a>
 | 
			
		||||
                {{ end }}
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <form class="dn ma0 flex-l flex-column justify-center items-end" method="post" action="{{ .Header.SearchActionUrl }}" target="_blank">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,33 @@
 | 
			
		|||
{{ if timelinepostitem . }}
 | 
			
		||||
	<div class="timeline-item flex pa3 mb2 br3 {{ .Class }}">
 | 
			
		||||
		<img class="avatar-icon big lite mr3" src="{{ .OwnerAvatarUrl }}"/>
 | 
			
		||||
		<div class="timeline-info overflow-hidden">
 | 
			
		||||
			{{ template "breadcrumbs.html" .Breadcrumbs }}
 | 
			
		||||
			<div class="title f5 b nowrap truncate"><span>{{ .TypeTitle }}</span>: <a href="{{ .Url }}" class="title-text normal">{{ .Title }}</a></div>
 | 
			
		||||
			<div class="details">
 | 
			
		||||
				<a class="user" href="{{ .OwnerUrl }}">{{ .OwnerName }}</a> — {{ timehtml (relativedate .Date) .Date }}
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
{{ else if timelinesnippetitem . }}
 | 
			
		||||
	<div class="timeline-item flex flex-column pa3 mb2 br3 {{ .Class }}">
 | 
			
		||||
		<div class="timeline-user-info mb2 flex items-center">
 | 
			
		||||
			<img class="avatar-icon lite mr2" src="{{ .OwnerAvatarUrl }}"/>
 | 
			
		||||
			<a class="user" href="{{ .OwnerUrl }}">{{ .OwnerName }}</a>
 | 
			
		||||
			<a class="datetime tr" style="flex: 1 1 auto;" href="{{ .Url }}">{{ timehtml (relativedate .Date) .Date }}</a>
 | 
			
		||||
		</div>
 | 
			
		||||
		<p class="timeline-snippet-title mb2">{{ .Description }}</p>
 | 
			
		||||
		<div class="timeline-content-box {{ if snippetyoutube . }}youtube{{ end }}">
 | 
			
		||||
			{{ if snippetvideo . }}
 | 
			
		||||
				<video src="{{ .AssetUrl }}" preload="metadata" controls />
 | 
			
		||||
			{{ else if snippetimage . }}
 | 
			
		||||
				<img src="{{ .AssetUrl }}" />
 | 
			
		||||
			{{ else if snippetaudio . }}
 | 
			
		||||
				<audio src="{{ .AssetUrl }}" controls />
 | 
			
		||||
			{{ else if snippetyoutube .}}
 | 
			
		||||
				<iframe src="https://www.youtube-nocookie.com/embed/{{ .YoutubeID }}" allow="accelerometer; encrypted-media; gyroscope;" allowfullscreen frameborder="0"></iframe>
 | 
			
		||||
			{{ end }}
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
{{ end }}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,108 @@
 | 
			
		|||
{{ template "base.html" . }}
 | 
			
		||||
 | 
			
		||||
{{ define "extrahead" }}
 | 
			
		||||
	{{ range .Screenshots }}
 | 
			
		||||
		<link rel="preload" href="{{ . }}" as="image">
 | 
			
		||||
	{{ end }}
 | 
			
		||||
{{ end }}
 | 
			
		||||
 | 
			
		||||
{{ define "content" }}
 | 
			
		||||
<div class="flex flex-column flex-row-l">
 | 
			
		||||
    <div class="flex-grow-1 overflow-hidden">
 | 
			
		||||
		{{ range .Notices }}
 | 
			
		||||
			<div class="content-block notice notice-{{ .Class }}">
 | 
			
		||||
				{{ .Content }}
 | 
			
		||||
			</div>
 | 
			
		||||
		{{ end }}
 | 
			
		||||
		{{ with .Screenshots }}
 | 
			
		||||
			<div class="carousel-container mw-100 mv2 mv3-ns margin-center">
 | 
			
		||||
				<div class="carousel aspect-ratio aspect-ratio--16x9 overflow-hidden bg--dim br2-ns">
 | 
			
		||||
					<div class="dn db-l">
 | 
			
		||||
						{{ range $index, $screenshot := . }}
 | 
			
		||||
							<div class="carousel-item aspect-ratio--object bg--dim {{ if eq $index 0 }}active{{ end }}">
 | 
			
		||||
								<div class="w-100 h-100" style="background:url('{{ $screenshot }}') no-repeat center / contain"></div>
 | 
			
		||||
							</div>
 | 
			
		||||
						{{ end }}
 | 
			
		||||
					</div>
 | 
			
		||||
					<div class="db dn-l">
 | 
			
		||||
						{{ range $index, $screenshot := . }}
 | 
			
		||||
							<div class="carousel-item-small aspect-ratio--object {{ if eq $index 0 }}active{{ end }}">
 | 
			
		||||
								<div class="w-100 h-100" style="background:url('{{ $screenshot }}') no-repeat center / contain"></div>
 | 
			
		||||
							</div>
 | 
			
		||||
						{{ end }}
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
				<div class="flex justify-center pv2">
 | 
			
		||||
					{{ range $index, $screenshot := . }}
 | 
			
		||||
						<div class="carousel-button br-pill w1 h1 mh2 {{ if eq $index 0 }}active{{ end }}" onclick="carouselButtonClick({{ $index }})"></div>
 | 
			
		||||
					{{ end }}
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		{{ end }}
 | 
			
		||||
		<div class="description">
 | 
			
		||||
			{{ .Project.ParsedDescription }}
 | 
			
		||||
		</div>
 | 
			
		||||
		{{ with .RecentActivity }}
 | 
			
		||||
			<div class="content-block timeline-container ph3 ph0-ns mv4">
 | 
			
		||||
				<h2>Recent Activity</h2>
 | 
			
		||||
				<div class="timeline">
 | 
			
		||||
					{{ range . }}
 | 
			
		||||
						{{ template "timeline_item.html" . }}
 | 
			
		||||
					{{ end }}
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		{{ end }}
 | 
			
		||||
		{{/* TODO(asaf): Add timeline items for project */}}
 | 
			
		||||
	</div>
 | 
			
		||||
    <div class="sidebar flex-shrink-0 mw6 w-30-l self-center self-start-l mh3 mh0-ns ml3-l overflow-hidden">
 | 
			
		||||
		<div class="content-block">
 | 
			
		||||
			<img alt="{{ .Project.Name }} Logo" class="br3" src="{{ .Project.Logo }}" />
 | 
			
		||||
			<div class="mv3 relative">
 | 
			
		||||
				<div class="mb3">
 | 
			
		||||
					{{ range $i, $owner := .Owners }}
 | 
			
		||||
						<div class="flex mv3 items-center {{ if eq $i 0 }}mt2{{ end }}">
 | 
			
		||||
							<img class="avatar-icon mr2" src="{{ $owner.AvatarUrl }}" />
 | 
			
		||||
							<a class="user-link" href="{{ $owner.ProfileUrl }}">{{ $owner.Name }}</a>
 | 
			
		||||
						</div>
 | 
			
		||||
					{{ end }}
 | 
			
		||||
				</div>
 | 
			
		||||
				{{ range .ProjectLinks }}
 | 
			
		||||
					<div class="pair flex flex-wrap">
 | 
			
		||||
						<div class="key flex-auto mr1">{{ .Key }}</div>
 | 
			
		||||
						<div class="value projectlink"><a class="external" href="{{ .Url }}" ><span class="icon-{{ .Icon }}"></span>{{ .Name }}</a></div>
 | 
			
		||||
					</div>
 | 
			
		||||
				{{ end }}
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
<script>
 | 
			
		||||
	const numCarouselItems = {{ len .Screenshots }};
 | 
			
		||||
 | 
			
		||||
	function activateCarouselItem(i) {
 | 
			
		||||
		const items = document.querySelectorAll('.carousel-item');
 | 
			
		||||
		items.forEach(item => item.classList.remove('active'));
 | 
			
		||||
		items[i].classList.add('active');
 | 
			
		||||
 | 
			
		||||
		const smallItems = document.querySelectorAll('.carousel-item-small');
 | 
			
		||||
		smallItems.forEach(item => item.classList.remove('active'));
 | 
			
		||||
		smallItems[i].classList.add('active');
 | 
			
		||||
 | 
			
		||||
		const buttons = document.querySelectorAll('.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;
 | 
			
		||||
		activateCarouselItem(next);
 | 
			
		||||
		carouselTimerCurrent = next;
 | 
			
		||||
	}, 10000);
 | 
			
		||||
 | 
			
		||||
	function carouselButtonClick(i) {
 | 
			
		||||
		activateCarouselItem(i);
 | 
			
		||||
		clearInterval(carouselTimer);
 | 
			
		||||
	}
 | 
			
		||||
</script>
 | 
			
		||||
{{ end }}
 | 
			
		||||
| 
						 | 
				
			
			@ -3,19 +3,19 @@
 | 
			
		|||
{{ 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="carousel-container mw-100 mv2 mv3-ns margin-center">
 | 
			
		||||
				<div class="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="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">
 | 
			
		||||
									<div class="carousel-description">
 | 
			
		||||
										{{ $project.ParsedDescription }}
 | 
			
		||||
									</div>
 | 
			
		||||
									<div class="project-carousel-fade"></div>
 | 
			
		||||
									<div class="carousel-fade"></div>
 | 
			
		||||
								</div>
 | 
			
		||||
								<div class="flex-shrink-0 order-0 order-1-ns">
 | 
			
		||||
									<a href="{{ $project.Url }}">
 | 
			
		||||
| 
						 | 
				
			
			@ -27,7 +27,7 @@
 | 
			
		|||
					</div>
 | 
			
		||||
					<div class="db dn-l"> <!-- mobile/tablet carousel -->
 | 
			
		||||
						{{ range $index, $project := . }}
 | 
			
		||||
							<div class="project-carousel-item-small {{ if eq $index 0 }}active{{ end }}">
 | 
			
		||||
							<div class="carousel-item-small {{ if eq $index 0 }}active{{ end }}">
 | 
			
		||||
								{{ template "project_card.html" projectcarddata $project "h-100" }}
 | 
			
		||||
							</div>
 | 
			
		||||
						{{ end }}
 | 
			
		||||
| 
						 | 
				
			
			@ -36,7 +36,7 @@
 | 
			
		|||
				<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 }}"
 | 
			
		||||
							class="carousel-button br-pill w1 h1 mh2 {{ if eq $index 0 }}active{{ end }}"
 | 
			
		||||
							onclick="carouselButtonClick({{ $index }})"
 | 
			
		||||
						></div>
 | 
			
		||||
					{{ end }}
 | 
			
		||||
| 
						 | 
				
			
			@ -123,16 +123,16 @@
 | 
			
		|||
	<script>
 | 
			
		||||
		const numCarouselItems = {{ len .CarouselProjects }};
 | 
			
		||||
 | 
			
		||||
		function activateCarouselProject(i) {
 | 
			
		||||
			const items = document.querySelectorAll('.project-carousel-item');
 | 
			
		||||
		function activateCarousel(i) {
 | 
			
		||||
			const items = document.querySelectorAll('.carousel-item');
 | 
			
		||||
			items.forEach(item => item.classList.remove('active'));
 | 
			
		||||
			items[i].classList.add('active');
 | 
			
		||||
 | 
			
		||||
			const smallItems = document.querySelectorAll('.project-carousel-item-small');
 | 
			
		||||
			const smallItems = document.querySelectorAll('.carousel-item-small');
 | 
			
		||||
			smallItems.forEach(item => item.classList.remove('active'));
 | 
			
		||||
			smallItems[i].classList.add('active');
 | 
			
		||||
 | 
			
		||||
			const buttons = document.querySelectorAll('.project-carousel-button');
 | 
			
		||||
			const buttons = document.querySelectorAll('.carousel-button');
 | 
			
		||||
			buttons.forEach(button => button.classList.remove('active'));
 | 
			
		||||
			buttons[i].classList.add('active');
 | 
			
		||||
		}
 | 
			
		||||
| 
						 | 
				
			
			@ -140,12 +140,12 @@
 | 
			
		|||
		let carouselTimerCurrent = 0;
 | 
			
		||||
		const carouselTimer = setInterval(() => {
 | 
			
		||||
			const next = (carouselTimerCurrent + 1) % numCarouselItems;
 | 
			
		||||
			activateCarouselProject(next);
 | 
			
		||||
			activateCarousel(next);
 | 
			
		||||
			carouselTimerCurrent = next;
 | 
			
		||||
		}, 10000);
 | 
			
		||||
 | 
			
		||||
		function carouselButtonClick(i) {
 | 
			
		||||
			activateCarouselProject(i);
 | 
			
		||||
			activateCarousel(i);
 | 
			
		||||
			clearInterval(carouselTimer);
 | 
			
		||||
		}
 | 
			
		||||
	</script>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -37,38 +37,7 @@
 | 
			
		|||
				</div>
 | 
			
		||||
				<div class="timeline">
 | 
			
		||||
					{{ range .TimelineItems }}
 | 
			
		||||
						{{ if timelinepostitem . }}
 | 
			
		||||
							<div class="timeline-item flex pa3 mb2 br3 {{ .Class }}">
 | 
			
		||||
								<img class="avatar-icon big lite mr3" src="{{ .OwnerAvatarUrl }}"/>
 | 
			
		||||
								<div class="timeline-info overflow-hidden">
 | 
			
		||||
									{{ template "breadcrumbs.html" .Breadcrumbs }}
 | 
			
		||||
									<div class="title f5 b nowrap truncate"><span>{{ .TypeTitle }}</span>: <a href="{{ .Url }}" class="title-text normal">{{ .Title }}</a></div>
 | 
			
		||||
									<div class="details">
 | 
			
		||||
										<a class="user" href="{{ .OwnerUrl }}">{{ .OwnerName }}</a> — {{ timehtml (relativedate .Date) .Date }}
 | 
			
		||||
									</div>
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
						{{ else if timelinesnippetitem . }}
 | 
			
		||||
							<div class="timeline-item flex flex-column pa3 mb2 br3 {{ .Class }}">
 | 
			
		||||
								<div class="timeline-user-info mb2 flex items-center">
 | 
			
		||||
									<img class="avatar-icon lite mr2" src="{{ .OwnerAvatarUrl }}"/>
 | 
			
		||||
									<a class="user" href="{{ .OwnerUrl }}">{{ .OwnerName }}</a>
 | 
			
		||||
									<a class="datetime tr" style="flex: 1 1 auto;" href="{{ .Url }}">{{ timehtml (relativedate .Date) .Date }}</a>
 | 
			
		||||
								</div>
 | 
			
		||||
								<p class="timeline-snippet-title mb2">{{ .Description }}</p>
 | 
			
		||||
								<div class="timeline-content-box {{ if snippetyoutube . }}youtube{{ end }}">
 | 
			
		||||
									{{ if snippetvideo . }}
 | 
			
		||||
										<video src="{{ .AssetUrl }}" preload="metadata" controls />
 | 
			
		||||
									{{ else if snippetimage . }}
 | 
			
		||||
										<img src="{{ .AssetUrl }}" />
 | 
			
		||||
									{{ else if snippetaudio . }}
 | 
			
		||||
										<audio src="{{ .AssetUrl }}" controls />
 | 
			
		||||
									{{ else if snippetyoutube .}}
 | 
			
		||||
										<iframe src="https://www.youtube-nocookie.com/embed/{{ .YoutubeID }}" allow="accelerometer; encrypted-media; gyroscope;" allowfullscreen frameborder="0"></iframe>
 | 
			
		||||
									{{ end }}
 | 
			
		||||
								</div>
 | 
			
		||||
							</div>
 | 
			
		||||
						{{ end }}
 | 
			
		||||
						{{ template "timeline_item.html" . }}
 | 
			
		||||
					{{ end }}
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -103,9 +72,9 @@
 | 
			
		|||
			{{ end }}
 | 
			
		||||
 | 
			
		||||
			{{ range .ProfileUserLinks }}
 | 
			
		||||
				<div class="pair flex flex-wra[">
 | 
			
		||||
				<div class="pair flex flex-wrap">
 | 
			
		||||
					<div class="key flex-auto mr1">{{ .Key }}</div>
 | 
			
		||||
					<div class="value projectlink"><a class="external" href="{{ .Value }}" ><span class="icon-{{ .ServiceName }}"></span> {{ if .ServiceUserData }}{{ .ServiceUserData }}{{ else }}{{ .Value }}{{ end }}</a></div>
 | 
			
		||||
					<div class="value projectlink"><a class="external" href="{{ .Url }}" ><span class="icon-{{ .Icon }}"></span>{{ .Name }}</a></div>
 | 
			
		||||
				</div>
 | 
			
		||||
			{{ end }}
 | 
			
		||||
		</div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -142,10 +142,14 @@ type User struct {
 | 
			
		|||
 | 
			
		||||
type Link struct {
 | 
			
		||||
	Key  string
 | 
			
		||||
	ServiceName     string
 | 
			
		||||
	ServiceUserData string
 | 
			
		||||
	Name string
 | 
			
		||||
	Value           string
 | 
			
		||||
	Url  string
 | 
			
		||||
	Icon string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Notice struct {
 | 
			
		||||
	Content template.HTML
 | 
			
		||||
	Class   string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Session struct {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -66,7 +66,7 @@ func ForumCategory(c *RequestContext) ResponseData {
 | 
			
		|||
	}
 | 
			
		||||
	c.Perf.EndBlock()
 | 
			
		||||
 | 
			
		||||
	numPages := int(math.Ceil(float64(numThreads) / threadsPerPage))
 | 
			
		||||
	numPages := utils.IntMax(int(math.Ceil(float64(numThreads)/threadsPerPage)), 1)
 | 
			
		||||
 | 
			
		||||
	page := 1
 | 
			
		||||
	pageString, hasPage := c.PathParams["page"]
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,10 +1,14 @@
 | 
			
		|||
package website
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"errors"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"html/template"
 | 
			
		||||
	"math"
 | 
			
		||||
	"math/rand"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/db"
 | 
			
		||||
| 
						 | 
				
			
			@ -204,3 +208,259 @@ func ProjectIndex(c *RequestContext) ResponseData {
 | 
			
		|||
	}
 | 
			
		||||
	return res
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ProjectHomepageData struct {
 | 
			
		||||
	templates.BaseData
 | 
			
		||||
	Project        templates.Project
 | 
			
		||||
	Owners         []templates.User
 | 
			
		||||
	Notices        []templates.Notice
 | 
			
		||||
	Screenshots    []string
 | 
			
		||||
	ProjectLinks   []templates.Link
 | 
			
		||||
	Licenses       []templates.Link
 | 
			
		||||
	RecentActivity []templates.TimelineItem
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func ProjectHomepage(c *RequestContext) ResponseData {
 | 
			
		||||
	maxRecentActivity := 15
 | 
			
		||||
	var project *models.Project
 | 
			
		||||
 | 
			
		||||
	if c.CurrentProject.IsHMN() {
 | 
			
		||||
		slug, hasSlug := c.PathParams["slug"]
 | 
			
		||||
		if hasSlug && slug != "" {
 | 
			
		||||
			slug = strings.ToLower(slug)
 | 
			
		||||
			if slug == models.HMNProjectSlug {
 | 
			
		||||
				return c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
 | 
			
		||||
			}
 | 
			
		||||
			c.Perf.StartBlock("SQL", "Fetching project by slug")
 | 
			
		||||
			type projectQuery struct {
 | 
			
		||||
				Project models.Project `db:"Project"`
 | 
			
		||||
			}
 | 
			
		||||
			projectQueryResult, err := db.QueryOne(c.Context(), c.Conn, projectQuery{},
 | 
			
		||||
				`
 | 
			
		||||
					SELECT $columns
 | 
			
		||||
					FROM
 | 
			
		||||
						handmade_project AS project
 | 
			
		||||
					WHERE
 | 
			
		||||
						LOWER(project.slug) = $1
 | 
			
		||||
				`,
 | 
			
		||||
				slug,
 | 
			
		||||
			)
 | 
			
		||||
			c.Perf.EndBlock()
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				if errors.Is(err, db.ErrNoMatchingRows) {
 | 
			
		||||
					return FourOhFour(c)
 | 
			
		||||
				} else {
 | 
			
		||||
					return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project by slug"))
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			project = &projectQueryResult.(*projectQuery).Project
 | 
			
		||||
			if project.Lifecycle != models.ProjectLifecycleUnapproved && project.Lifecycle != models.ProjectLifecycleApprovalRequired {
 | 
			
		||||
				return c.Redirect(hmnurl.BuildProjectHomepage(project.Slug), http.StatusSeeOther)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	} else {
 | 
			
		||||
		project = c.CurrentProject
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if project == nil {
 | 
			
		||||
		return FourOhFour(c)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetching project owners")
 | 
			
		||||
	type ownerQuery struct {
 | 
			
		||||
		Owner models.User `db:"auth_user"`
 | 
			
		||||
	}
 | 
			
		||||
	ownerQueryResult, err := db.Query(c.Context(), c.Conn, ownerQuery{},
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM
 | 
			
		||||
			auth_user
 | 
			
		||||
			INNER JOIN auth_user_groups AS user_groups ON auth_user.id = user_groups.user_id
 | 
			
		||||
			INNER JOIN handmade_project_groups AS project_groups ON user_groups.group_id = project_groups.group_id
 | 
			
		||||
		WHERE
 | 
			
		||||
			project_groups.project_id = $1
 | 
			
		||||
		`,
 | 
			
		||||
		project.ID,
 | 
			
		||||
	)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch owners for project"))
 | 
			
		||||
	}
 | 
			
		||||
	ownerQueryData := ownerQueryResult.ToSlice()
 | 
			
		||||
	c.Perf.EndBlock()
 | 
			
		||||
 | 
			
		||||
	canView := false
 | 
			
		||||
	canEdit := false
 | 
			
		||||
	if c.CurrentUser != nil {
 | 
			
		||||
		if c.CurrentUser.IsSuperuser {
 | 
			
		||||
			canView = true
 | 
			
		||||
			canEdit = true
 | 
			
		||||
		} else {
 | 
			
		||||
			for _, ownerRow := range ownerQueryData {
 | 
			
		||||
				if ownerRow.(*ownerQuery).Owner.ID == c.CurrentUser.ID {
 | 
			
		||||
					canView = true
 | 
			
		||||
					canEdit = true
 | 
			
		||||
					break
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	if !canView {
 | 
			
		||||
		if project.Flags == 0 {
 | 
			
		||||
			for _, lc := range models.VisibleProjectLifecycles {
 | 
			
		||||
				if project.Lifecycle == lc {
 | 
			
		||||
					canView = true
 | 
			
		||||
					break
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if !canView {
 | 
			
		||||
		return FourOhFour(c)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetching screenshots")
 | 
			
		||||
	type screenshotQuery struct {
 | 
			
		||||
		Filename string `db:"screenshot.file"`
 | 
			
		||||
	}
 | 
			
		||||
	screenshotQueryResult, err := db.Query(c.Context(), c.Conn, screenshotQuery{},
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM
 | 
			
		||||
			handmade_imagefile AS screenshot
 | 
			
		||||
			INNER JOIN handmade_project_screenshots ON screenshot.id = handmade_project_screenshots.imagefile_id
 | 
			
		||||
		WHERE
 | 
			
		||||
			handmade_project_screenshots.project_id = $1
 | 
			
		||||
		`,
 | 
			
		||||
		project.ID,
 | 
			
		||||
	)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch screenshots for project"))
 | 
			
		||||
	}
 | 
			
		||||
	c.Perf.EndBlock()
 | 
			
		||||
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetching project links")
 | 
			
		||||
	type projectLinkQuery struct {
 | 
			
		||||
		Link models.Link `db:"link"`
 | 
			
		||||
	}
 | 
			
		||||
	projectLinkResult, err := db.Query(c.Context(), c.Conn, projectLinkQuery{},
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM
 | 
			
		||||
			handmade_links as link
 | 
			
		||||
		WHERE
 | 
			
		||||
			link.project_id = $1
 | 
			
		||||
		ORDER BY link.ordering ASC
 | 
			
		||||
		`,
 | 
			
		||||
		project.ID,
 | 
			
		||||
	)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project links"))
 | 
			
		||||
	}
 | 
			
		||||
	c.Perf.EndBlock()
 | 
			
		||||
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetch category tree")
 | 
			
		||||
	categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
 | 
			
		||||
	lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
 | 
			
		||||
	c.Perf.EndBlock()
 | 
			
		||||
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetching project timeline")
 | 
			
		||||
	type postQuery struct {
 | 
			
		||||
		Post   models.Post   `db:"post"`
 | 
			
		||||
		Thread models.Thread `db:"thread"`
 | 
			
		||||
		Author models.User   `db:"author"`
 | 
			
		||||
	}
 | 
			
		||||
	postQueryResult, err := db.Query(c.Context(), c.Conn, postQuery{},
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM
 | 
			
		||||
			handmade_post AS post
 | 
			
		||||
			INNER JOIN handmade_thread AS thread ON thread.id = post.thread_id
 | 
			
		||||
			INNER JOIN auth_user AS author ON author.id = post.author_id
 | 
			
		||||
		WHERE
 | 
			
		||||
			post.project_id = $1
 | 
			
		||||
		ORDER BY post.postdate DESC
 | 
			
		||||
		LIMIT $2
 | 
			
		||||
		`,
 | 
			
		||||
		project.ID,
 | 
			
		||||
		maxRecentActivity,
 | 
			
		||||
	)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project posts"))
 | 
			
		||||
	}
 | 
			
		||||
	c.Perf.EndBlock()
 | 
			
		||||
 | 
			
		||||
	var projectHomepageData ProjectHomepageData
 | 
			
		||||
 | 
			
		||||
	projectHomepageData.BaseData = getBaseData(c)
 | 
			
		||||
	if canEdit {
 | 
			
		||||
		projectHomepageData.BaseData.Header.EditUrl = hmnurl.BuildProjectEdit(project.Slug, "")
 | 
			
		||||
	}
 | 
			
		||||
	projectHomepageData.Project = templates.ProjectToTemplate(project, c.Theme)
 | 
			
		||||
	for _, ownerRow := range ownerQueryData {
 | 
			
		||||
		projectHomepageData.Owners = append(projectHomepageData.Owners, templates.UserToTemplate(&ownerRow.(*ownerQuery).Owner, c.Theme))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if project.Flags == 1 {
 | 
			
		||||
		hiddenNotice := templates.Notice{
 | 
			
		||||
			Class:   "hidden",
 | 
			
		||||
			Content: "NOTICE: This project is hidden. It is currently visible only to owners and site admins.",
 | 
			
		||||
		}
 | 
			
		||||
		projectHomepageData.Notices = append(projectHomepageData.Notices, hiddenNotice)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if project.Lifecycle != models.ProjectLifecycleActive {
 | 
			
		||||
		var lifecycleNotice templates.Notice
 | 
			
		||||
		switch project.Lifecycle {
 | 
			
		||||
		case models.ProjectLifecycleUnapproved:
 | 
			
		||||
			lifecycleNotice.Class = "unapproved"
 | 
			
		||||
			lifecycleNotice.Content = template.HTML(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.",
 | 
			
		||||
				hmnurl.BuildProjectEdit(project.Slug, "submit"),
 | 
			
		||||
			))
 | 
			
		||||
		case models.ProjectLifecycleApprovalRequired:
 | 
			
		||||
			lifecycleNotice.Class = "unapproved"
 | 
			
		||||
			lifecycleNotice.Content = template.HTML("NOTICE: This project is awaiting approval. It is only visible to owners and site admins.")
 | 
			
		||||
		case models.ProjectLifecycleHiatus:
 | 
			
		||||
			lifecycleNotice.Class = "hiatus"
 | 
			
		||||
			lifecycleNotice.Content = template.HTML("NOTICE: This project is on hiatus and may not update for a while.")
 | 
			
		||||
		case models.ProjectLifecycleDead:
 | 
			
		||||
			lifecycleNotice.Class = "dead"
 | 
			
		||||
			lifecycleNotice.Content = template.HTML("NOTICE: Site staff have marked this project as being dead. If you intend to revive it, please contact a member of the Handmade Network staff.")
 | 
			
		||||
		case models.ProjectLifecycleLTSRequired:
 | 
			
		||||
			lifecycleNotice.Class = "lts-reqd"
 | 
			
		||||
			lifecycleNotice.Content = template.HTML("NOTICE: This project is awaiting approval for maintenance-mode status.")
 | 
			
		||||
		case models.ProjectLifecycleLTS:
 | 
			
		||||
			lifecycleNotice.Class = "lts"
 | 
			
		||||
			lifecycleNotice.Content = template.HTML("NOTICE: This project has reached a state of completion.")
 | 
			
		||||
		}
 | 
			
		||||
		projectHomepageData.Notices = append(projectHomepageData.Notices, lifecycleNotice)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, screenshot := range screenshotQueryResult.ToSlice() {
 | 
			
		||||
		projectHomepageData.Screenshots = append(projectHomepageData.Screenshots, hmnurl.BuildUserFile(screenshot.(*screenshotQuery).Filename))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, link := range projectLinkResult.ToSlice() {
 | 
			
		||||
		projectHomepageData.ProjectLinks = append(projectHomepageData.ProjectLinks, templates.LinkToTemplate(&link.(*projectLinkQuery).Link))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, post := range postQueryResult.ToSlice() {
 | 
			
		||||
		projectHomepageData.RecentActivity = append(projectHomepageData.RecentActivity, PostToTimelineItem(
 | 
			
		||||
			lineageBuilder,
 | 
			
		||||
			&post.(*postQuery).Post,
 | 
			
		||||
			&post.(*postQuery).Thread,
 | 
			
		||||
			project,
 | 
			
		||||
			nil,
 | 
			
		||||
			&post.(*postQuery).Author,
 | 
			
		||||
			c.Theme,
 | 
			
		||||
		))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var res ResponseData
 | 
			
		||||
	err = res.WriteTemplate("project_homepage.html", projectHomepageData, c.Perf)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render project homepage template"))
 | 
			
		||||
	}
 | 
			
		||||
	return res
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -38,13 +38,6 @@ type RouteBuilder struct {
 | 
			
		|||
 | 
			
		||||
type Handler func(c *RequestContext) ResponseData
 | 
			
		||||
 | 
			
		||||
func WrapStdHandler(h http.Handler) Handler {
 | 
			
		||||
	return func(c *RequestContext) (res ResponseData) {
 | 
			
		||||
		h.ServeHTTP(&res, c.Req)
 | 
			
		||||
		return res
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Middleware func(h Handler) Handler
 | 
			
		||||
 | 
			
		||||
func (rb *RouteBuilder) Handle(methods []string, regex *regexp.Regexp, h Handler) {
 | 
			
		||||
| 
						 | 
				
			
			@ -70,10 +63,6 @@ func (rb *RouteBuilder) POST(regex *regexp.Regexp, h Handler) {
 | 
			
		|||
	rb.Handle([]string{http.MethodPost}, regex, h)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (rb *RouteBuilder) StdHandler(regex *regexp.Regexp, h http.Handler) {
 | 
			
		||||
	rb.Handle([]string{""}, regex, WrapStdHandler(h))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
 | 
			
		||||
	path := req.URL.Path
 | 
			
		||||
	for _, route := range r.Routes {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,10 +5,12 @@ import (
 | 
			
		|||
	"errors"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"net/url"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/auth"
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/config"
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/db"
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/hmnurl"
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/logging"
 | 
			
		||||
| 
						 | 
				
			
			@ -117,16 +119,19 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
 | 
			
		|||
	// TODO(asaf): login/logout shouldn't happen on subdomains. We should verify that in the middleware.
 | 
			
		||||
	routes.POST(hmnurl.RegexLoginAction, Login)
 | 
			
		||||
	routes.GET(hmnurl.RegexLogoutAction, Logout)
 | 
			
		||||
	routes.StdHandler(hmnurl.RegexPublic,
 | 
			
		||||
		http.StripPrefix("/public/", http.FileServer(http.Dir("public"))),
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	routes.GET(hmnurl.RegexPublic, func(c *RequestContext) ResponseData {
 | 
			
		||||
		var res ResponseData
 | 
			
		||||
		http.StripPrefix("/public/", http.FileServer(http.Dir("public"))).ServeHTTP(&res, c.Req)
 | 
			
		||||
		AddCORSHeaders(c, &res)
 | 
			
		||||
		return res
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	mainRoutes.GET(hmnurl.RegexHomepage, func(c *RequestContext) ResponseData {
 | 
			
		||||
		if c.CurrentProject.IsHMN() {
 | 
			
		||||
			return Index(c)
 | 
			
		||||
		} else {
 | 
			
		||||
			// TODO: Return the project landing page
 | 
			
		||||
			panic("route not implemented")
 | 
			
		||||
			return ProjectHomepage(c)
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
	staticPages.GET(hmnurl.RegexManifesto, Manifesto)
 | 
			
		||||
| 
						 | 
				
			
			@ -145,6 +150,7 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
 | 
			
		|||
	mainRoutes.GET(hmnurl.RegexSnippet, Snippet)
 | 
			
		||||
	mainRoutes.GET(hmnurl.RegexProjectIndex, ProjectIndex)
 | 
			
		||||
	mainRoutes.GET(hmnurl.RegexUserProfile, UserProfile)
 | 
			
		||||
	mainRoutes.GET(hmnurl.RegexProjectNotApproved, ProjectHomepage)
 | 
			
		||||
 | 
			
		||||
	// NOTE(asaf): Any-project routes:
 | 
			
		||||
	mainRoutes.Handle([]string{http.MethodGet, http.MethodPost}, hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread))
 | 
			
		||||
| 
						 | 
				
			
			@ -198,7 +204,7 @@ func getBaseData(c *RequestContext) templates.BaseData {
 | 
			
		|||
			LibraryUrl:         hmnurl.BuildLibrary(c.CurrentProject.Slug),
 | 
			
		||||
			ManifestoUrl:       hmnurl.BuildManifesto(),
 | 
			
		||||
			EpisodeGuideUrl:    hmnurl.BuildHomepage(), // TODO(asaf)
 | 
			
		||||
			EditUrl:            hmnurl.BuildHomepage(), // TODO(asaf)
 | 
			
		||||
			EditUrl:            "",
 | 
			
		||||
			SearchActionUrl:    hmnurl.BuildHomepage(), // TODO(asaf)
 | 
			
		||||
		},
 | 
			
		||||
		Footer: templates.Footer{
 | 
			
		||||
| 
						 | 
				
			
			@ -349,6 +355,22 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
 | 
			
		|||
	return true, ResponseData{}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func AddCORSHeaders(c *RequestContext, res *ResponseData) {
 | 
			
		||||
	parsed, err := url.Parse(config.Config.BaseUrl)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		c.Logger.Error().Str("Config.BaseUrl", config.Config.BaseUrl).Msg("Config.BaseUrl cannot be parsed. Skipping CORS headers")
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	origin := ""
 | 
			
		||||
	origins, found := c.Req.Header["Origin"]
 | 
			
		||||
	if found {
 | 
			
		||||
		origin = origins[0]
 | 
			
		||||
	}
 | 
			
		||||
	if strings.HasSuffix(origin, parsed.Host) {
 | 
			
		||||
		res.Header().Add("Access-Control-Allow-Origin", origin)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Given a session id, fetches user data from the database. Will return nil if
 | 
			
		||||
// the user cannot be found, and will only return an error if it's serious.
 | 
			
		||||
func getCurrentUserAndSession(c *RequestContext, sessionId string) (*models.User, *models.Session, error) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue