Podcasts
This commit is contained in:
parent
6c53688e06
commit
a46fd988f5
2
go.mod
2
go.mod
|
@ -19,11 +19,13 @@ require (
|
|||
github.com/rs/zerolog v1.21.0
|
||||
github.com/spf13/cobra v1.1.3
|
||||
github.com/stretchr/testify v1.7.0
|
||||
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300
|
||||
github.com/teacat/noire v1.1.0
|
||||
github.com/wellington/go-libsass v0.9.2
|
||||
github.com/yuin/goldmark v1.4.1
|
||||
github.com/yuin/goldmark-highlighting v0.0.0-20210516132338-9216f9c5aa01
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2
|
||||
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d
|
||||
)
|
||||
|
||||
replace (
|
||||
|
|
7
go.sum
7
go.sum
|
@ -298,6 +298,8 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
||||
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300 h1:XQdibLKagjdevRB6vAjVY4qbSr8rQ610YzTkWcxzxSI=
|
||||
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300/go.mod h1:FNa/dfN95vAYCNFrIKRrlRo+MBLbwmR9Asa5f2ljmBI=
|
||||
github.com/teacat/noire v1.1.0 h1:5IgJ1H8jodiSSYnrVadV2JjbAnEgCCjYUQxSUuaQ7Sg=
|
||||
github.com/teacat/noire v1.1.0/go.mod h1:cetGlnqr+9yKJcFgRgYXOWJY66XIrrjUsGBwNlNNtAk=
|
||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
||||
|
@ -340,6 +342,8 @@ golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm0
|
|||
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d h1:RNPAfi2nHY7C2srAV8A49jpsYr0ADedCk1wq6fTMTvs=
|
||||
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
|
@ -409,8 +413,9 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
|
|||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
|
|
@ -9212,15 +9212,6 @@ span.icon-rss::before {
|
|||
padding: 0px;
|
||||
min-height: 0em; }
|
||||
|
||||
.project .notice {
|
||||
color: #fff;
|
||||
color: var(--project-notice-text-color); }
|
||||
.project .notice a {
|
||||
color: #fff;
|
||||
color: var(--project-notice-text-color);
|
||||
border-bottom-color: #fff;
|
||||
border-bottom-color: var(--project-notice-text-color); }
|
||||
|
||||
.project .pair {
|
||||
display: flex;
|
||||
align-items: flex-start; }
|
||||
|
@ -9264,30 +9255,6 @@ span.icon-rss::before {
|
|||
.project .forum .thread-entry-right {
|
||||
display: none; }
|
||||
|
||||
.notice-unapproved {
|
||||
background-color: #b42222;
|
||||
background-color: var(--notice-unapproved-color); }
|
||||
|
||||
.notice-hidden {
|
||||
background-color: #b6b6b6;
|
||||
background-color: var(--notice-hidden-color); }
|
||||
|
||||
.notice-hiatus {
|
||||
background-color: #aa7d30;
|
||||
background-color: var(--notice-hiatus-color); }
|
||||
|
||||
.notice-dead {
|
||||
background-color: #b42222;
|
||||
background-color: var(--notice-dead-color); }
|
||||
|
||||
.notice-lts {
|
||||
background-color: #43a52f;
|
||||
background-color: var(--notice-lts-color); }
|
||||
|
||||
.notice-lts-reqd {
|
||||
background-color: #aa7d30;
|
||||
background-color: var(--notice-lts-reqd-color); }
|
||||
|
||||
.project-card {
|
||||
color: black;
|
||||
color: var(--fg-font-color);
|
||||
|
@ -9546,3 +9513,40 @@ span.icon-rss::before {
|
|||
.carousel-container .carousel-button.active:hover {
|
||||
background-color: #ccc;
|
||||
background-color: var(--theme-color-dimmest); }
|
||||
|
||||
.notice {
|
||||
color: #fff;
|
||||
color: var(--notice-text-color); }
|
||||
.notice a {
|
||||
color: #fff;
|
||||
color: var(--notice-text-color);
|
||||
border-bottom-color: #fff;
|
||||
border-bottom-color: var(--notice-text-color); }
|
||||
|
||||
.notice-unapproved {
|
||||
background-color: #b42222;
|
||||
background-color: var(--notice-unapproved-color); }
|
||||
|
||||
.notice-hidden {
|
||||
background-color: #b6b6b6;
|
||||
background-color: var(--notice-hidden-color); }
|
||||
|
||||
.notice-hiatus {
|
||||
background-color: #aa7d30;
|
||||
background-color: var(--notice-hiatus-color); }
|
||||
|
||||
.notice-dead {
|
||||
background-color: #b42222;
|
||||
background-color: var(--notice-dead-color); }
|
||||
|
||||
.notice-lts {
|
||||
background-color: #43a52f;
|
||||
background-color: var(--notice-lts-color); }
|
||||
|
||||
.notice-lts-reqd {
|
||||
background-color: #aa7d30;
|
||||
background-color: var(--notice-lts-reqd-color); }
|
||||
|
||||
.notice-success {
|
||||
background-color: #43a52f;
|
||||
background-color: var(--notice-success-color); }
|
||||
|
|
|
@ -234,19 +234,20 @@ will throw an error.
|
|||
--text-background: #181818;
|
||||
--spoiler-border: #777;
|
||||
--background-even-background: #242424;
|
||||
--project-notice-text-color: #eee;
|
||||
--project-card-border-color: #333;
|
||||
--project-user-suggestions-background: #222;
|
||||
--project-user-suggestions-border-color: #444;
|
||||
--project-edit-logo-previw-border-color: #444;
|
||||
--project-edit-quota-bar-border-color: #444;
|
||||
--project-edit-quota-bar-filled-background: #888;
|
||||
--notice-text-color: #eee;
|
||||
--notice-unapproved-color: #7a2020;
|
||||
--notice-hidden-color: #494949;
|
||||
--notice-hiatus-color: #876327;
|
||||
--notice-dead-color: #7a2020;
|
||||
--notice-lts-color: #2a681d;
|
||||
--notice-lts-reqd-color: #876327;
|
||||
--notice-success-color: #2a681d;
|
||||
--optionbar-border-color: #333;
|
||||
--tab-background: #181818;
|
||||
--tab-border-color: #3f3f3f;
|
||||
|
|
|
@ -252,19 +252,20 @@ will throw an error.
|
|||
--text-background: #f9f9f9;
|
||||
--spoiler-border: #aaa;
|
||||
--background-even-background: #f8f8f8;
|
||||
--project-notice-text-color: #fff;
|
||||
--project-card-border-color: #aaa;
|
||||
--project-user-suggestions-background: #fff;
|
||||
--project-user-suggestions-border-color: #ddd;
|
||||
--project-edit-logo-previw-border-color: #999;
|
||||
--project-edit-quota-bar-border-color: #999;
|
||||
--project-edit-quota-bar-filled-background: #444;
|
||||
--notice-text-color: #fff;
|
||||
--notice-unapproved-color: #b42222;
|
||||
--notice-hidden-color: #b6b6b6;
|
||||
--notice-hiatus-color: #aa7d30;
|
||||
--notice-dead-color: #b42222;
|
||||
--notice-lts-color: #43a52f;
|
||||
--notice-lts-reqd-color: #aa7d30;
|
||||
--notice-success-color: #43a52f;
|
||||
--optionbar-border-color: #ccc;
|
||||
--tab-background: #fff;
|
||||
--tab-border-color: #d8d8d8;
|
||||
|
|
|
@ -85,7 +85,7 @@ func TestUserProfile(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestSnippet(t *testing.T) {
|
||||
AssetRegexMatch(t, BuildSnippet(15), RegexSnippet, map[string]string{"snippetid": "15"})
|
||||
AssertRegexMatch(t, BuildSnippet(15), RegexSnippet, map[string]string{"snippetid": "15"})
|
||||
}
|
||||
|
||||
func TestFeed(t *testing.T) {
|
||||
|
@ -123,6 +123,28 @@ func TestPodcast(t *testing.T) {
|
|||
AssertSubdomain(t, BuildPodcast("hero"), "hero")
|
||||
}
|
||||
|
||||
func TestPodcastEdit(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildPodcastEdit(""), RegexPodcastEdit, nil)
|
||||
AssertRegexMatch(t, BuildPodcastEditSuccess(""), RegexPodcastEdit, nil)
|
||||
}
|
||||
|
||||
func TestPodcastEpisode(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildPodcastEpisode("", "test"), RegexPodcastEpisode, map[string]string{"episodeid": "test"})
|
||||
}
|
||||
|
||||
func TestPodcastEpisodeNew(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildPodcastEpisodeNew(""), RegexPodcastEpisodeNew, nil)
|
||||
}
|
||||
|
||||
func TestPodcastEpisodeEdit(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildPodcastEpisodeEdit("", "test"), RegexPodcastEpisodeEdit, map[string]string{"episodeid": "test"})
|
||||
AssertRegexMatch(t, BuildPodcastEpisodeEditSuccess("", "test"), RegexPodcastEpisodeEdit, map[string]string{"episodeid": "test"})
|
||||
}
|
||||
|
||||
func TestPodcastRSS(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildPodcastRSS(""), RegexPodcastRSS, nil)
|
||||
}
|
||||
|
||||
func TestForumCategory(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildForumCategory("", nil, 1), RegexForumCategory, nil)
|
||||
AssertRegexMatch(t, BuildForumCategory("", []string{"wip"}, 2), RegexForumCategory, map[string]string{"cats": "wip", "page": "2"})
|
||||
|
@ -137,7 +159,8 @@ func TestForumCategory(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestForumNewThread(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildForumNewThread("", []string{"sub", "wip"}), RegexForumNewThread, map[string]string{"cats": "sub/wip"})
|
||||
AssertRegexMatch(t, BuildForumNewThread("", []string{"sub", "wip"}, false), RegexForumNewThread, map[string]string{"cats": "sub/wip"})
|
||||
AssertRegexMatch(t, BuildForumNewThread("", []string{"sub", "wip"}, true), RegexForumNewThreadSubmit, map[string]string{"cats": "sub/wip"})
|
||||
}
|
||||
|
||||
func TestForumThread(t *testing.T) {
|
||||
|
|
|
@ -251,6 +251,56 @@ func BuildPodcast(projectSlug string) string {
|
|||
return ProjectUrl("/podcast", nil, projectSlug)
|
||||
}
|
||||
|
||||
var RegexPodcastEdit = regexp.MustCompile(`^/podcast/edit$`)
|
||||
|
||||
func BuildPodcastEdit(projectSlug string) string {
|
||||
defer CatchPanic()
|
||||
return ProjectUrl("/podcast/edit", nil, projectSlug)
|
||||
}
|
||||
|
||||
func BuildPodcastEditSuccess(projectSlug string) string {
|
||||
defer CatchPanic()
|
||||
return ProjectUrl("/podcast/edit", []Q{Q{"success", "true"}}, projectSlug)
|
||||
}
|
||||
|
||||
var RegexPodcastEpisode = regexp.MustCompile(`^/podcast/ep/(?P<episodeid>[^/]+)$`)
|
||||
|
||||
func BuildPodcastEpisode(projectSlug string, episodeGUID string) string {
|
||||
defer CatchPanic()
|
||||
return ProjectUrl(fmt.Sprintf("/podcast/ep/%s", episodeGUID), nil, projectSlug)
|
||||
}
|
||||
|
||||
var RegexPodcastEpisodeNew = regexp.MustCompile(`^/podcast/ep/new$`)
|
||||
|
||||
func BuildPodcastEpisodeNew(projectSlug string) string {
|
||||
defer CatchPanic()
|
||||
return ProjectUrl("/podcast/ep/new", nil, projectSlug)
|
||||
}
|
||||
|
||||
var RegexPodcastEpisodeEdit = regexp.MustCompile(`^/podcast/ep/(?P<episodeid>[^/]+)/edit$`)
|
||||
|
||||
func BuildPodcastEpisodeEdit(projectSlug string, episodeGUID string) string {
|
||||
defer CatchPanic()
|
||||
return ProjectUrl(fmt.Sprintf("/podcast/ep/%s/edit", episodeGUID), nil, projectSlug)
|
||||
}
|
||||
|
||||
func BuildPodcastEpisodeEditSuccess(projectSlug string, episodeGUID string) string {
|
||||
defer CatchPanic()
|
||||
return ProjectUrl(fmt.Sprintf("/podcast/ep/%s/edit", episodeGUID), []Q{Q{"success", "true"}}, projectSlug)
|
||||
}
|
||||
|
||||
var RegexPodcastRSS = regexp.MustCompile(`^/podcast/podcast.xml$`)
|
||||
|
||||
func BuildPodcastRSS(projectSlug string) string {
|
||||
defer CatchPanic()
|
||||
return ProjectUrl("/podcast/podcast.xml", nil, projectSlug)
|
||||
}
|
||||
|
||||
func BuildPodcastEpisodeFile(projectSlug string, filename string) string {
|
||||
defer CatchPanic()
|
||||
return BuildUserFile(fmt.Sprintf("podcast/%s/%s", projectSlug, filename))
|
||||
}
|
||||
|
||||
/*
|
||||
* Forums
|
||||
*/
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
package initimage
|
||||
|
||||
import (
|
||||
_ "golang.org/x/image/bmp"
|
||||
_ "golang.org/x/image/tiff"
|
||||
_ "golang.org/x/image/webp" // NOTE(asaf): webp handles vp8 and vp8l
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
)
|
|
@ -3,6 +3,7 @@ package main
|
|||
import (
|
||||
_ "git.handmade.network/hmn/hmn/src/admintools"
|
||||
_ "git.handmade.network/hmn/hmn/src/buildscss"
|
||||
_ "git.handmade.network/hmn/hmn/src/initimage"
|
||||
_ "git.handmade.network/hmn/hmn/src/migration"
|
||||
"git.handmade.network/hmn/hmn/src/website"
|
||||
)
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/migration/types"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
"github.com/jackc/pgx/v4"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerMigration(RenamePodcastColumns{})
|
||||
}
|
||||
|
||||
type RenamePodcastColumns struct{}
|
||||
|
||||
func (m RenamePodcastColumns) Version() types.MigrationVersion {
|
||||
return types.MigrationVersion(time.Date(2021, 7, 11, 6, 8, 38, 0, time.UTC))
|
||||
}
|
||||
|
||||
func (m RenamePodcastColumns) Name() string {
|
||||
return "RenamePodcastColumns"
|
||||
}
|
||||
|
||||
func (m RenamePodcastColumns) Description() string {
|
||||
return "Rename columns to lowercase"
|
||||
}
|
||||
|
||||
func (m RenamePodcastColumns) Up(ctx context.Context, tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
ALTER TABLE handmade_podcastepisode
|
||||
RENAME COLUMN "enclosureFile" TO "audio_filename";
|
||||
|
||||
ALTER TABLE handmade_podcastepisode
|
||||
RENAME COLUMN "pubDate" TO "pub_date";
|
||||
|
||||
ALTER TABLE handmade_podcastepisode
|
||||
RENAME COLUMN "episodeNumber" TO "episode_number";
|
||||
|
||||
ALTER TABLE handmade_podcastepisode
|
||||
RENAME COLUMN "seasonNumber" TO "season_number";
|
||||
`)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to rename podcast episode columns")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m RenamePodcastColumns) Down(ctx context.Context, tx pgx.Tx) error {
|
||||
panic("Implement me")
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Podcast struct {
|
||||
ID int `db:"id"`
|
||||
ImageID int `db:"image_id"`
|
||||
ProjectID int `db:"project_id"`
|
||||
|
||||
Title string `db:"title"`
|
||||
Description string `db:"description"`
|
||||
Language string `db:"language"`
|
||||
}
|
||||
|
||||
type PodcastEpisode struct {
|
||||
GUID uuid.UUID `db:"guid"`
|
||||
PodcastID int `db:"podcast_id"`
|
||||
|
||||
Title string `db:"title"`
|
||||
Description string `db:"description"`
|
||||
DescriptionHtml string `db:"description_rendered"`
|
||||
AudioFile string `db:"audio_filename"`
|
||||
PublicationDate time.Time `db:"pub_date"`
|
||||
Duration int `db:"duration"` // NOTE(asaf): In seconds
|
||||
EpisodeNumber int `db:"episode_number"`
|
||||
SeasonNumber *int `db:"season_number"` // TODO(asaf): Do we need this??
|
||||
}
|
|
@ -8,16 +8,18 @@ import (
|
|||
type RequestPerf struct {
|
||||
Route string
|
||||
Path string // the path actually matched
|
||||
Method string
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Blocks []PerfBlock
|
||||
}
|
||||
|
||||
func MakeNewRequestPerf(route string, path string) *RequestPerf {
|
||||
func MakeNewRequestPerf(route string, method string, path string) *RequestPerf {
|
||||
return &RequestPerf{
|
||||
Start: time.Now(),
|
||||
Route: route,
|
||||
Path: path,
|
||||
Start: time.Now(),
|
||||
Route: route,
|
||||
Path: path,
|
||||
Method: method,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
.notice {
|
||||
@include usevar(color, notice-text-color);
|
||||
|
||||
a {
|
||||
@include usevar(color, notice-text-color);
|
||||
@include usevar(border-bottom-color, notice-text-color);
|
||||
}
|
||||
}
|
||||
.notice-unapproved {
|
||||
@include usevar(background-color, notice-unapproved-color);
|
||||
}
|
||||
|
||||
.notice-hidden {
|
||||
@include usevar(background-color, notice-hidden-color);
|
||||
}
|
||||
|
||||
.notice-hiatus {
|
||||
@include usevar(background-color, notice-hiatus-color);
|
||||
}
|
||||
|
||||
.notice-dead {
|
||||
@include usevar(background-color, notice-dead-color);
|
||||
}
|
||||
|
||||
.notice-lts {
|
||||
@include usevar(background-color, notice-lts-color);
|
||||
}
|
||||
|
||||
.notice-lts-reqd {
|
||||
@include usevar(background-color, notice-lts-reqd-color);
|
||||
}
|
||||
|
||||
.notice-success {
|
||||
@include usevar(background-color, notice-success-color);
|
||||
}
|
|
@ -1,13 +1,4 @@
|
|||
.project {
|
||||
.notice {
|
||||
@include usevar(color, project-notice-text-color);
|
||||
|
||||
a {
|
||||
@include usevar(color, project-notice-text-color);
|
||||
@include usevar(border-bottom-color, project-notice-text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.pair {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
@ -69,30 +60,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.notice-unapproved {
|
||||
@include usevar(background-color, notice-unapproved-color);
|
||||
}
|
||||
|
||||
.notice-hidden {
|
||||
@include usevar(background-color, notice-hidden-color);
|
||||
}
|
||||
|
||||
.notice-hiatus {
|
||||
@include usevar(background-color, notice-hiatus-color);
|
||||
}
|
||||
|
||||
.notice-dead {
|
||||
@include usevar(background-color, notice-dead-color);
|
||||
}
|
||||
|
||||
.notice-lts {
|
||||
@include usevar(background-color, notice-lts-color);
|
||||
}
|
||||
|
||||
.notice-lts-reqd {
|
||||
@include usevar(background-color, notice-lts-reqd-color);
|
||||
}
|
||||
|
||||
.project-card {
|
||||
@include usevar(color, 'fg-font-color');
|
||||
@include usevar(background-color, 'card-background');
|
||||
|
|
|
@ -24,3 +24,4 @@
|
|||
@import 'streams';
|
||||
@import 'timeline';
|
||||
@import 'carousel';
|
||||
@import 'notices';
|
||||
|
|
|
@ -35,7 +35,6 @@ $vars: (
|
|||
|
||||
background-even-background: #242424,
|
||||
|
||||
project-notice-text-color: $fg-font-color,
|
||||
project-card-border-color: #333,
|
||||
project-user-suggestions-background: #222,
|
||||
project-user-suggestions-border-color: #444,
|
||||
|
@ -43,12 +42,14 @@ $vars: (
|
|||
project-edit-quota-bar-border-color: #444,
|
||||
project-edit-quota-bar-filled-background: #888,
|
||||
|
||||
notice-text-color: $fg-font-color,
|
||||
notice-unapproved-color: #7a2020,
|
||||
notice-hidden-color: #494949,
|
||||
notice-hiatus-color: #876327,
|
||||
notice-dead-color: #7a2020,
|
||||
notice-lts-color: #2a681d,
|
||||
notice-lts-reqd-color: #876327,
|
||||
notice-success-color: #2a681d,
|
||||
|
||||
optionbar-border-color: #333,
|
||||
|
||||
|
|
|
@ -35,7 +35,6 @@ $vars: (
|
|||
|
||||
background-even-background: #f8f8f8,
|
||||
|
||||
project-notice-text-color: #fff,
|
||||
project-card-border-color: #aaa,
|
||||
project-user-suggestions-background: #fff,
|
||||
project-user-suggestions-border-color: #ddd,
|
||||
|
@ -43,12 +42,14 @@ $vars: (
|
|||
project-edit-quota-bar-border-color: #999,
|
||||
project-edit-quota-bar-filled-background: #444,
|
||||
|
||||
notice-text-color: #fff,
|
||||
notice-unapproved-color: #b42222,
|
||||
notice-hidden-color: #b6b6b6,
|
||||
notice-hiatus-color: #aa7d30,
|
||||
notice-dead-color: #b42222,
|
||||
notice-lts-color: #43a52f,
|
||||
notice-lts-reqd-color: #aa7d30,
|
||||
notice-success-color: #43a52f,
|
||||
|
||||
optionbar-border-color: #ccc,
|
||||
|
||||
|
|
|
@ -304,6 +304,46 @@ func TimelineItemsToJSON(items []TimelineItem) string {
|
|||
return builder.String()
|
||||
}
|
||||
|
||||
func PodcastToTemplate(projectSlug string, podcast *models.Podcast, imageFilename string) Podcast {
|
||||
imageUrl := ""
|
||||
if imageFilename != "" {
|
||||
imageUrl = hmnurl.BuildUserFile(imageFilename)
|
||||
}
|
||||
return Podcast{
|
||||
Title: podcast.Title,
|
||||
Description: podcast.Description,
|
||||
Language: podcast.Language,
|
||||
ImageUrl: imageUrl,
|
||||
Url: hmnurl.BuildPodcast(projectSlug),
|
||||
|
||||
RSSUrl: hmnurl.BuildPodcastRSS(projectSlug),
|
||||
// TODO(asaf): Move this to the db if we want to support user podcasts
|
||||
AppleUrl: "https://podcasts.apple.com/us/podcast/the-handmade-network-podcast/id1507790631",
|
||||
GoogleUrl: "https://www.google.com/podcasts?feed=aHR0cHM6Ly9oYW5kbWFkZS5uZXR3b3JrL3BvZGNhc3QvcG9kY2FzdC54bWw%3D",
|
||||
SpotifyUrl: "https://open.spotify.com/show/2Nd9NjXscrBbQwYULiYKiU",
|
||||
}
|
||||
}
|
||||
|
||||
func PodcastEpisodeToTemplate(projectSlug string, episode *models.PodcastEpisode, audioFileSize int64, imageFilename string) PodcastEpisode {
|
||||
imageUrl := ""
|
||||
if imageFilename != "" {
|
||||
imageUrl = hmnurl.BuildUserFile(imageFilename)
|
||||
}
|
||||
return PodcastEpisode{
|
||||
GUID: episode.GUID.String(),
|
||||
Title: episode.Title,
|
||||
Description: episode.Description,
|
||||
DescriptionHtml: template.HTML(episode.DescriptionHtml),
|
||||
EpisodeNumber: episode.EpisodeNumber,
|
||||
Url: hmnurl.BuildPodcastEpisode(projectSlug, episode.GUID.String()),
|
||||
ImageUrl: imageUrl,
|
||||
FileUrl: hmnurl.BuildPodcastEpisodeFile(projectSlug, episode.AudioFile),
|
||||
FileSize: audioFileSize,
|
||||
PublicationDate: episode.PublicationDate,
|
||||
Duration: episode.Duration,
|
||||
}
|
||||
}
|
||||
|
||||
func maybeString(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
{{ range . }}
|
||||
<div class="content-block notice notice-{{ .Class }}">
|
||||
{{ .Content }}
|
||||
</div>
|
||||
{{ end }}
|
|
@ -0,0 +1,4 @@
|
|||
<a href="{{ .RSSUrl }}"><span class="icon big">4</span> Subscribe</a>
|
||||
<a class="pl2" target="_blank" href="{{ .AppleUrl }}"><span class="svgicon">{{ svg "appleinc" }}</span> Apple Podcasts</a>
|
||||
<a class="pl2" target="_blank" href="{{ .GoogleUrl }}"><span class="svgicon">{{ svg "google" }}</span> Google Podcasts</a>
|
||||
<a class="pl2" target="_blank" href="{{ .SpotifyUrl }}"><span class="svgicon">{{ svg "spotify" }}</span> Spotify</a>
|
|
@ -0,0 +1,32 @@
|
|||
{{ noescape "<?xml version=\"1.0\" encoding=\"utf-8\"?>" }}
|
||||
|
||||
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||||
<channel>
|
||||
<title>{{ .Podcast.Title }}</title>
|
||||
<link>{{ .Podcast.Url }}</link>
|
||||
<language>{{ .Podcast.Language }}</language>
|
||||
<copyright>© 2021 The Handmade Network</copyright>{{/* TODO(asaf): Change this in case we want to allow user podcasts */}}
|
||||
<itunes:author>The Handmade Network</itunes:author>
|
||||
<itunes:owner>
|
||||
<itunes:name>The Handmade Network</itunes:name>
|
||||
<itunes:email>team@handmadedev.org</itunes:email>
|
||||
</itunes:owner>
|
||||
<description>{{ .Podcast.Description }}</description>
|
||||
<itunes:image href="{{ .Podcast.ImageUrl }}" />
|
||||
<itunes:category text="Technology" />
|
||||
<itunes:explicit>false</itunes:explicit>
|
||||
{{ range .Episodes }}
|
||||
<item>
|
||||
<guid>{{ .GUID }}</guid>
|
||||
<title>{{ .Title }}</title>
|
||||
<description>{{ .Description }}</description>
|
||||
<itunes:episode>{{ .EpisodeNumber }}</itunes:episode>
|
||||
<itunes:order>{{ .EpisodeNumber }}</itunes:order>
|
||||
<enclosure url="{{ .FileUrl }}" length="{{ .FileSize }}" type="audio/mpeg" />
|
||||
<pubDate>{{ .PublicationDate }}</pubDate>
|
||||
<itunes:duration>{{ .Duration }}</itunes:duration>
|
||||
<link>{{ .Url }}</link>
|
||||
</item>
|
||||
{{ end }}
|
||||
</channel>
|
||||
</rss>
|
|
@ -0,0 +1,71 @@
|
|||
{{ template "base.html" . }}
|
||||
|
||||
{{ define "content" }}
|
||||
<div class="content-block">
|
||||
{{ template "notices.html" .Notices }}
|
||||
<form id="podcast_form" method="POST" enctype="multipart/form-data">
|
||||
{{ csrftoken .Session }}
|
||||
<input class="b w-100 mb1" type="text" name="title" required placeholder="Podcast title..." value="{{ .Podcast.Title }}" />
|
||||
<textarea name="description" required class="w-100 minw-100 mw-100 h3 minh-3">{{ .Podcast.Description }}</textarea>
|
||||
<div>
|
||||
<label for="podcast_image">Podcast image (2mb max): </label>
|
||||
<input id="file_input" type="file" accept="image/*" name="podcast_image" {{ if eq (len .Podcast.ImageUrl) 0 }}required{{ end }} />
|
||||
<a href="javascript:;" class="db" id="reset_image">Reset Image</a>
|
||||
<label style="display: none" id="file_too_big" for="podcast_image">File too big.</label>
|
||||
</div>
|
||||
<div>
|
||||
<img id="image" src="{{ .Podcast.ImageUrl }}" />
|
||||
</div>
|
||||
<input type="submit" name="submit" value="Submit" />
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
let fileInput = document.querySelector("#file_input");
|
||||
let image = document.querySelector("#image");
|
||||
let fileTooBigLabel = document.querySelector("#file_too_big");
|
||||
let resetImage = document.querySelector("#reset_image");
|
||||
let form = document.querySelector("#podcast_form");
|
||||
|
||||
let originalImageUrl = "{{ .Podcast.ImageUrl }}";
|
||||
|
||||
fileInput.value = "";
|
||||
let fileTooBig = false;
|
||||
let maxFileSize = 2*1024*1024;
|
||||
|
||||
fileInput.addEventListener("change", function(ev) {
|
||||
if (fileInput.files.length > 0) {
|
||||
let file = fileInput.files[0];
|
||||
handleNewImageFile(file);
|
||||
}
|
||||
});
|
||||
|
||||
function handleNewImageFile(file) {
|
||||
fileTooBig = false;
|
||||
if (file) {
|
||||
if (file.size > maxFileSize) {
|
||||
fileTooBig = true;
|
||||
}
|
||||
image.src = URL.createObjectURL(file);
|
||||
} else {
|
||||
image.src = originalImageUrl;
|
||||
}
|
||||
|
||||
if (fileTooBig) {
|
||||
fileTooBigLabel.style.display = "block";
|
||||
} else {
|
||||
fileTooBigLabel.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
resetImage.addEventListener("click", function(ev) {
|
||||
fileInput.value = "";
|
||||
handleNewImageFile(null);
|
||||
});
|
||||
|
||||
form.addEventListener("submit", function(ev) {
|
||||
if (fileTooBig) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{ end }}
|
|
@ -0,0 +1,31 @@
|
|||
{{ template "base.html" . }}
|
||||
|
||||
{{ define "content" }}
|
||||
<div class="content-block">
|
||||
<div class="ph2 ph0-ns">
|
||||
<h2>Episode {{ .Episode.EpisodeNumber }}: {{ .Episode.Title }}</h2>
|
||||
<div>
|
||||
{{ template "podcast_actions.html" .Podcast }}
|
||||
{{ with .EditUrl }}
|
||||
<a class="pl2" href="{{ . }}">✎ Edit Episode</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row-ns flex-column-reverse items-center">
|
||||
<div class="flex-grow-1 w-100">
|
||||
<audio preload="metadata" class="w-100" controls src="{{ .Episode.FileUrl }}"></audio>
|
||||
<div>
|
||||
<a download href="{{ .Episode.FileUrl }}">Download</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml3-ns flex-shrink-0 w4">
|
||||
<img class="br3" src="{{ .Podcast.ImageUrl }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pv2 p-spaced w-100 overflow-hidden">
|
||||
{{ .Episode.DescriptionHtml }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
|
@ -0,0 +1,23 @@
|
|||
{{ template "base.html" . }}
|
||||
|
||||
{{ define "content" }}
|
||||
<div class="content-block">
|
||||
{{ template "notices.html" .Notices }}
|
||||
<h1>{{ if .IsEdit }}Edit{{ else }}New{{ end }} Episode</h1>
|
||||
<form method="POST">
|
||||
{{ csrftoken .Session }}
|
||||
<input required type="text" class="b w-100 mb1" name="title" placeholder="Title" value="{{ .Title }}" />
|
||||
<label for="episode_number">Episode number: </label><input required type="number" class="" name="episode_number" value="{{ .EpisodeNumber }}" />
|
||||
<textarea required name="description" class="w-100 mv1" maxlength="4000" placeholder="Description (max 4000 chars)">{{ .Description }}</textarea>
|
||||
<select required name="episode_file">
|
||||
{{ $currentFile := .CurrentFile }}
|
||||
{{ range .EpisodeFiles }}
|
||||
<option {{ if eq $currentFile . }}selected{{ end }} value="{{ . }}">{{ . }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
<div class="mt3">
|
||||
<input type="submit" name="submit" value="Submit" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{ end }}
|
|
@ -0,0 +1,38 @@
|
|||
{{ template "base.html" . }}
|
||||
|
||||
{{ define "extrahead" }}
|
||||
<link type="application/rss+xml" rel="alternate" title="{{ .Podcast.Title }}" href="{{ .Podcast.RSSUrl }}"/>
|
||||
{{ end }}
|
||||
|
||||
{{ define "content" }}
|
||||
<div class="content-block">
|
||||
<div class="ph2 ph0-ns">
|
||||
<h2>{{ .Podcast.Title }}</h2>
|
||||
<div>
|
||||
{{ template "podcast_actions.html" .Podcast }}
|
||||
{{ with .EditUrl }}
|
||||
<a class="pl2" href="{{ . }}">✎ Edit Podcast</a>
|
||||
{{ end }}
|
||||
{{ with .NewEpisodeUrl }}
|
||||
<a class="pl2" href="{{ . }}">+ New Episode</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<p class="pt2 pb3">{{ .Podcast.Description }}</p>
|
||||
|
||||
<ul>
|
||||
{{ range .Episodes }}
|
||||
<li class="flex ml0 pl0 mw7 pb3">
|
||||
<div class="dn db-ns w4 h4 flex-shrink-0">
|
||||
<img class="br3" src="{{ .ImageUrl }}">
|
||||
</div>
|
||||
<div class="ph3-ns w-100 overflow-hidden">
|
||||
<a href="{{ .Url }}"><h3 class="f4">Episode {{ .EpisodeNumber }}: {{ .Title }}</h3></a>
|
||||
<div class="p-spaced">{{ .DescriptionHtml }}</div>
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
|
@ -9,11 +9,7 @@
|
|||
{{ 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 }}
|
||||
{{ template "notices.html" .Notices }}
|
||||
{{ 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">
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<title>appleinc</title>
|
||||
<path d="M24.734 17.003c-0.040-4.053 3.305-5.996 3.454-6.093-1.88-2.751-4.808-3.127-5.851-3.171-2.492-0.252-4.862 1.467-6.127 1.467-1.261 0-3.213-1.43-5.28-1.392-2.716 0.040-5.221 1.579-6.619 4.012-2.822 4.897-0.723 12.151 2.028 16.123 1.344 1.944 2.947 4.127 5.051 4.049 2.026-0.081 2.793-1.311 5.242-1.311s3.138 1.311 5.283 1.271c2.18-0.041 3.562-1.981 4.897-3.931 1.543-2.255 2.179-4.439 2.216-4.551-0.048-0.022-4.252-1.632-4.294-6.473zM20.705 5.11c1.117-1.355 1.871-3.235 1.665-5.11-1.609 0.066-3.559 1.072-4.713 2.423-1.036 1.199-1.942 3.113-1.699 4.951 1.796 0.14 3.629-0.913 4.747-2.264z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 766 B |
|
@ -0,0 +1,5 @@
|
|||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<title>google</title>
|
||||
<path d="M16.319 13.713v5.487h9.075c-0.369 2.356-2.744 6.9-9.075 6.9-5.463 0-9.919-4.525-9.919-10.1s4.456-10.1 9.919-10.1c3.106 0 5.188 1.325 6.375 2.469l4.344-4.181c-2.788-2.612-6.4-4.188-10.719-4.188-8.844 0-16 7.156-16 16s7.156 16 16 16c9.231 0 15.363-6.494 15.363-15.631 0-1.050-0.113-1.85-0.25-2.65l-15.113-0.006z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 488 B |
|
@ -0,0 +1,5 @@
|
|||
<!-- Generated by IcoMoon.io -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<title>spotify</title>
|
||||
<path d="M16 0c-8.8 0-16 7.2-16 16s7.2 16 16 16 16-7.2 16-16-7.119-16-16-16zM23.363 23.119c-0.319 0.481-0.881 0.637-1.363 0.319-3.762-2.319-8.481-2.8-14.081-1.519-0.563 0.163-1.037-0.238-1.2-0.719-0.162-0.563 0.237-1.038 0.719-1.2 6.081-1.363 11.363-0.8 15.519 1.762 0.563 0.238 0.644 0.875 0.406 1.356zM25.281 18.719c-0.4 0.563-1.119 0.8-1.681 0.4-4.319-2.637-10.881-3.438-15.919-1.837-0.638 0.163-1.362-0.163-1.519-0.8-0.162-0.637 0.162-1.363 0.8-1.519 5.838-1.762 13.037-0.881 18 2.163 0.475 0.238 0.719 1.038 0.319 1.594zM25.438 14.238c-5.119-3.037-13.681-3.363-18.563-1.838-0.8 0.238-1.6-0.238-1.838-0.963-0.237-0.8 0.237-1.6 0.963-1.838 5.681-1.681 15.038-1.363 20.962 2.162 0.719 0.4 0.962 1.363 0.563 2.081-0.406 0.556-1.363 0.794-2.087 0.394z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 922 B |
|
@ -81,10 +81,22 @@ var SVGChevronLeft string
|
|||
//go:embed svg/chevron-right.svg
|
||||
var SVGChevronRight string
|
||||
|
||||
//go:embed svg/appleinc.svg
|
||||
var SVGAppleInc string
|
||||
|
||||
//go:embed svg/google.svg
|
||||
var SVGGoogle string
|
||||
|
||||
//go:embed svg/spotify.svg
|
||||
var SVGSpotify string
|
||||
|
||||
var SVGMap = map[string]string{
|
||||
"close": SVGClose,
|
||||
"chevron-left": SVGChevronLeft,
|
||||
"chevron-right": SVGChevronRight,
|
||||
"appleinc": SVGAppleInc,
|
||||
"google": SVGGoogle,
|
||||
"spotify": SVGSpotify,
|
||||
}
|
||||
|
||||
var HMNTemplateFuncs = template.FuncMap{
|
||||
|
|
|
@ -147,6 +147,33 @@ type Link struct {
|
|||
Icon string
|
||||
}
|
||||
|
||||
type Podcast struct {
|
||||
Title string
|
||||
Description string
|
||||
Language string
|
||||
ImageUrl string
|
||||
Url string
|
||||
|
||||
RSSUrl string
|
||||
AppleUrl string
|
||||
GoogleUrl string
|
||||
SpotifyUrl string
|
||||
}
|
||||
|
||||
type PodcastEpisode struct {
|
||||
GUID string
|
||||
Title string
|
||||
Description string
|
||||
DescriptionHtml template.HTML
|
||||
EpisodeNumber int
|
||||
Url string
|
||||
ImageUrl string
|
||||
FileUrl string
|
||||
FileSize int64
|
||||
PublicationDate time.Time
|
||||
Duration int
|
||||
}
|
||||
|
||||
type Notice struct {
|
||||
Content template.HTML
|
||||
Class string
|
||||
|
|
|
@ -0,0 +1,679 @@
|
|||
package website
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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/parsing"
|
||||
"git.handmade.network/hmn/hmn/src/templates"
|
||||
"github.com/google/uuid"
|
||||
"github.com/tcolgate/mp3"
|
||||
)
|
||||
|
||||
type PodcastIndexData struct {
|
||||
templates.BaseData
|
||||
Podcast templates.Podcast
|
||||
Episodes []templates.PodcastEpisode
|
||||
EditUrl string
|
||||
NewEpisodeUrl string
|
||||
}
|
||||
|
||||
func PodcastIndex(c *RequestContext) ResponseData {
|
||||
podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, true, "")
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
if podcastResult.Podcast == nil {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
canEdit, err := CanEditProject(c, c.CurrentUser, podcastResult.Podcast.ProjectID)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
baseData := getBaseData(c)
|
||||
baseData.Title = podcastResult.Podcast.Title
|
||||
|
||||
podcastIndexData := PodcastIndexData{
|
||||
BaseData: baseData,
|
||||
Podcast: templates.PodcastToTemplate(c.CurrentProject.Slug, podcastResult.Podcast, podcastResult.ImageFile),
|
||||
}
|
||||
|
||||
if canEdit {
|
||||
podcastIndexData.EditUrl = hmnurl.BuildPodcastEdit(c.CurrentProject.Slug)
|
||||
podcastIndexData.NewEpisodeUrl = hmnurl.BuildPodcastEpisodeNew(c.CurrentProject.Slug)
|
||||
}
|
||||
|
||||
for _, episode := range podcastResult.Episodes {
|
||||
podcastIndexData.Episodes = append(podcastIndexData.Episodes, templates.PodcastEpisodeToTemplate(c.CurrentProject.Slug, episode, 0, podcastResult.ImageFile))
|
||||
}
|
||||
var res ResponseData
|
||||
err = res.WriteTemplate("podcast_index.html", podcastIndexData, c.Perf)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render podcast index page"))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
type PodcastEditData struct {
|
||||
templates.BaseData
|
||||
Podcast templates.Podcast
|
||||
Notices []templates.Notice
|
||||
}
|
||||
|
||||
func PodcastEdit(c *RequestContext) ResponseData {
|
||||
podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, false, "")
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
canEdit, err := CanEditProject(c, c.CurrentUser, podcastResult.Podcast.ProjectID)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
if podcastResult.Podcast == nil || !canEdit {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
podcast := templates.PodcastToTemplate(c.CurrentProject.Slug, podcastResult.Podcast, podcastResult.ImageFile)
|
||||
baseData := getBaseData(c)
|
||||
baseData.Breadcrumbs = []templates.Breadcrumb{{Name: podcast.Title, Url: podcast.Url}}
|
||||
podcastEditData := PodcastEditData{
|
||||
BaseData: baseData,
|
||||
Podcast: podcast,
|
||||
}
|
||||
|
||||
success := c.URL().Query().Get("success")
|
||||
if success != "" {
|
||||
podcastEditData.Notices = append(podcastEditData.Notices, templates.Notice{Class: "success", Content: "Podcast updated successfully."})
|
||||
}
|
||||
|
||||
var res ResponseData
|
||||
err = res.WriteTemplate("podcast_edit.html", podcastEditData, c.Perf)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render podcast edit page"))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func PodcastEditSubmit(c *RequestContext) ResponseData {
|
||||
podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, false, "")
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
canEdit, err := CanEditProject(c, c.CurrentUser, podcastResult.Podcast.ProjectID)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
if podcastResult.Podcast == nil || !canEdit {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
c.Perf.StartBlock("PODCAST", "Handling file upload")
|
||||
c.Perf.StartBlock("PODCAST", "Parsing form")
|
||||
maxFileSize := int64(2 * 1024 * 1024)
|
||||
maxBodySize := maxFileSize + 1024*1024
|
||||
c.Req.Body = http.MaxBytesReader(c.Res, c.Req.Body, maxBodySize)
|
||||
err = c.Req.ParseMultipartForm(maxBodySize)
|
||||
c.Perf.EndBlock()
|
||||
if err != nil {
|
||||
// NOTE(asaf): The error for exceeding the max filesize doesn't have a special type, so we can't easily detect it here.
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to parse form"))
|
||||
}
|
||||
|
||||
title := c.Req.Form.Get("title")
|
||||
if len(strings.TrimSpace(title)) == 0 {
|
||||
// TODO(asaf): Report this back to the user
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(nil, "Missing title"))
|
||||
}
|
||||
description := c.Req.Form.Get("description")
|
||||
if len(strings.TrimSpace(description)) == 0 {
|
||||
// TODO(asaf): Report this back to the user
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(nil, "Missing description"))
|
||||
}
|
||||
podcastImage, header, err := c.Req.FormFile("podcast_image")
|
||||
imageFilename := ""
|
||||
imageWidth := 0
|
||||
imageHeight := 0
|
||||
if err != nil && !errors.Is(err, http.ErrMissingFile) {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to read uploaded file"))
|
||||
}
|
||||
if header != nil {
|
||||
if header.Size > maxFileSize {
|
||||
// TODO(asaf): Report this back to the user
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(nil, "Filesize too big"))
|
||||
} else {
|
||||
c.Perf.StartBlock("PODCAST", "Decoding image")
|
||||
config, format, err := image.DecodeConfig(podcastImage)
|
||||
c.Perf.EndBlock()
|
||||
if err != nil {
|
||||
// TODO(asaf): Report this back to the user
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Can't parse podcast logo"))
|
||||
}
|
||||
imageWidth = config.Width
|
||||
imageHeight = config.Height
|
||||
if imageWidth == 0 || imageHeight == 0 {
|
||||
// TODO(asaf): Report this back to the user
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Invalid image size"))
|
||||
}
|
||||
|
||||
imageFilename = fmt.Sprintf("podcast/%s/logo%d.%s", c.CurrentProject.Slug, time.Now().UTC().Unix(), format)
|
||||
storageFilename := fmt.Sprintf("public/media/%s", imageFilename)
|
||||
c.Perf.StartBlock("PODCAST", "Writing image file")
|
||||
file, err := os.Create(storageFilename)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to create local image file"))
|
||||
}
|
||||
podcastImage.Seek(0, io.SeekStart)
|
||||
_, err = io.Copy(file, podcastImage)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to write image to file"))
|
||||
}
|
||||
file.Close()
|
||||
podcastImage.Close()
|
||||
c.Perf.EndBlock()
|
||||
}
|
||||
}
|
||||
c.Perf.EndBlock()
|
||||
|
||||
c.Perf.StartBlock("SQL", "Updating podcast")
|
||||
tx, err := c.Conn.Begin(c.Context())
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to start db transaction"))
|
||||
}
|
||||
defer tx.Rollback(c.Context())
|
||||
if imageFilename != "" {
|
||||
hasher := sha1.New()
|
||||
podcastImage.Seek(0, io.SeekStart)
|
||||
io.Copy(hasher, podcastImage) // NOTE(asaf): Writing to hash.Hash never returns an error according to the docs
|
||||
sha1sum := hasher.Sum(nil)
|
||||
var imageId int
|
||||
err = tx.QueryRow(c.Context(),
|
||||
`
|
||||
INSERT INTO handmade_imagefile (file, size, sha1sum, protected, width, height)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id
|
||||
`,
|
||||
imageFilename, header.Size, hex.EncodeToString(sha1sum), false, imageWidth, imageHeight,
|
||||
).Scan(&imageId)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to insert image file row"))
|
||||
}
|
||||
_, err = tx.Exec(c.Context(),
|
||||
`
|
||||
UPDATE handmade_podcast
|
||||
SET
|
||||
title = $1,
|
||||
description = $2,
|
||||
image_id = $3
|
||||
WHERE id = $4
|
||||
`,
|
||||
title,
|
||||
description,
|
||||
imageId,
|
||||
podcastResult.Podcast.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to update podcast"))
|
||||
}
|
||||
} else {
|
||||
_, err = tx.Exec(c.Context(),
|
||||
`
|
||||
UPDATE handmade_podcast
|
||||
SET
|
||||
title = $1,
|
||||
description = $2
|
||||
WHERE id = $3
|
||||
`,
|
||||
title,
|
||||
description,
|
||||
podcastResult.Podcast.ID,
|
||||
)
|
||||
}
|
||||
err = tx.Commit(c.Context())
|
||||
c.Perf.EndBlock()
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to commit db transaction"))
|
||||
}
|
||||
|
||||
return c.Redirect(hmnurl.BuildPodcastEditSuccess(c.CurrentProject.Slug), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type PodcastEpisodeData struct {
|
||||
templates.BaseData
|
||||
Podcast templates.Podcast
|
||||
Episode templates.PodcastEpisode
|
||||
EditUrl string
|
||||
}
|
||||
|
||||
func PodcastEpisode(c *RequestContext) ResponseData {
|
||||
episodeGUIDStr := c.PathParams["episodeid"]
|
||||
|
||||
podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, true, episodeGUIDStr)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
if podcastResult.Podcast == nil || len(podcastResult.Episodes) == 0 {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
canEdit, err := CanEditProject(c, c.CurrentUser, podcastResult.Podcast.ProjectID)
|
||||
if err != nil {
|
||||
c.Logger.Error().Err(err).Msg("Failed to check if user can edit podcast. Assuming they can't.") // NOTE(asaf): No need to return an error response here if it failed.
|
||||
canEdit = false
|
||||
}
|
||||
|
||||
editUrl := ""
|
||||
if canEdit {
|
||||
editUrl = hmnurl.BuildPodcastEpisodeEdit(c.CurrentProject.Slug, podcastResult.Episodes[0].GUID.String())
|
||||
}
|
||||
|
||||
podcast := templates.PodcastToTemplate(c.CurrentProject.Slug, podcastResult.Podcast, podcastResult.ImageFile)
|
||||
episode := templates.PodcastEpisodeToTemplate(c.CurrentProject.Slug, podcastResult.Episodes[0], 0, podcastResult.ImageFile)
|
||||
baseData := getBaseData(c)
|
||||
baseData.Title = podcastResult.Podcast.Title
|
||||
baseData.Breadcrumbs = []templates.Breadcrumb{{Name: podcast.Title, Url: podcast.Url}}
|
||||
|
||||
podcastEpisodeData := PodcastEpisodeData{
|
||||
BaseData: baseData,
|
||||
Podcast: podcast,
|
||||
Episode: episode,
|
||||
EditUrl: editUrl,
|
||||
}
|
||||
|
||||
var res ResponseData
|
||||
err = res.WriteTemplate("podcast_episode.html", podcastEpisodeData, c.Perf)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render podcast episode page"))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
type PodcastEpisodeEditData struct {
|
||||
templates.BaseData
|
||||
IsEdit bool
|
||||
Title string
|
||||
Description string
|
||||
EpisodeNumber int
|
||||
CurrentFile string
|
||||
EpisodeFiles []string
|
||||
Notices []templates.Notice
|
||||
}
|
||||
|
||||
func PodcastEpisodeNew(c *RequestContext) ResponseData {
|
||||
podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, false, "")
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
canEdit, err := CanEditProject(c, c.CurrentUser, podcastResult.Podcast.ProjectID)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
if podcastResult.Podcast == nil || !canEdit {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
episodeFiles, err := GetEpisodeFiles(c.CurrentProject.Slug)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to fetch podcast episode file list"))
|
||||
}
|
||||
|
||||
podcast := templates.PodcastToTemplate(c.CurrentProject.Slug, podcastResult.Podcast, "")
|
||||
var res ResponseData
|
||||
baseData := getBaseData(c)
|
||||
baseData.Breadcrumbs = []templates.Breadcrumb{{Name: podcast.Title, Url: podcast.Url}}
|
||||
err = res.WriteTemplate("podcast_episode_edit.html", PodcastEpisodeEditData{
|
||||
BaseData: baseData,
|
||||
IsEdit: false,
|
||||
EpisodeFiles: episodeFiles,
|
||||
}, c.Perf)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render podcast episode new page"))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func PodcastEpisodeEdit(c *RequestContext) ResponseData {
|
||||
episodeGUIDStr, found := c.PathParams["episodeid"]
|
||||
if !found || episodeGUIDStr == "" {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, true, episodeGUIDStr)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
canEdit, err := CanEditProject(c, c.CurrentUser, podcastResult.Podcast.ProjectID)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
if podcastResult.Podcast == nil || len(podcastResult.Episodes) == 0 || !canEdit {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
episodeFiles, err := GetEpisodeFiles(c.CurrentProject.Slug)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to fetch podcast episode file list"))
|
||||
}
|
||||
episode := podcastResult.Episodes[0]
|
||||
|
||||
podcast := templates.PodcastToTemplate(c.CurrentProject.Slug, podcastResult.Podcast, "")
|
||||
podcastEpisode := templates.PodcastEpisodeToTemplate(c.CurrentProject.Slug, episode, 0, "")
|
||||
baseData := getBaseData(c)
|
||||
baseData.Breadcrumbs = []templates.Breadcrumb{{Name: podcast.Title, Url: podcast.Url}, {Name: podcastEpisode.Title, Url: podcastEpisode.Url}}
|
||||
podcastEpisodeEditData := PodcastEpisodeEditData{
|
||||
BaseData: baseData,
|
||||
IsEdit: true,
|
||||
Title: episode.Title,
|
||||
Description: episode.Description,
|
||||
EpisodeNumber: episode.EpisodeNumber,
|
||||
CurrentFile: episode.AudioFile,
|
||||
EpisodeFiles: episodeFiles,
|
||||
}
|
||||
|
||||
success := c.URL().Query().Get("success")
|
||||
if success != "" {
|
||||
podcastEpisodeEditData.Notices = append(podcastEpisodeEditData.Notices, templates.Notice{Class: "success", Content: "Podcast episode updated successfully."})
|
||||
}
|
||||
|
||||
var res ResponseData
|
||||
err = res.WriteTemplate("podcast_episode_edit.html", podcastEpisodeEditData, c.Perf)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render podcast episode edit page"))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func PodcastEpisodeSubmit(c *RequestContext) ResponseData {
|
||||
episodeGUIDStr, found := c.PathParams["episodeid"]
|
||||
|
||||
isEdit := found && episodeGUIDStr != ""
|
||||
podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, isEdit, episodeGUIDStr)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
canEdit, err := CanEditProject(c, c.CurrentUser, podcastResult.Podcast.ProjectID)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
if podcastResult.Podcast == nil || (isEdit && len(podcastResult.Episodes) == 0) || !canEdit {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
c.Perf.StartBlock("OS", "Fetching podcast episode files")
|
||||
episodeFiles, err := GetEpisodeFiles(c.CurrentProject.Slug)
|
||||
c.Perf.EndBlock()
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to fetch podcast episode file list"))
|
||||
}
|
||||
|
||||
c.Req.ParseForm()
|
||||
title := c.Req.Form.Get("title")
|
||||
description := c.Req.Form.Get("description")
|
||||
episodeNumberStr := c.Req.Form.Get("episode_number")
|
||||
episodeNumber, err := strconv.Atoi(episodeNumberStr)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to parse episode number"))
|
||||
}
|
||||
episodeFile := c.Req.Form.Get("episode_file")
|
||||
found = false
|
||||
for _, ef := range episodeFiles {
|
||||
if episodeFile == ef {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "User-provided episode filename doesn't match existing files"))
|
||||
}
|
||||
|
||||
c.Perf.StartBlock("MP3", "Parsing mp3 file for duration")
|
||||
file, err := os.Open(fmt.Sprintf("public/media/podcast/%s/%s", c.CurrentProject.Slug, episodeFile))
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to open podcast file"))
|
||||
}
|
||||
|
||||
mp3Decoder := mp3.NewDecoder(file)
|
||||
var duration float64
|
||||
skipped := 0
|
||||
var decodingError error
|
||||
var f mp3.Frame
|
||||
for {
|
||||
if err = mp3Decoder.Decode(&f, &skipped); err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
} else {
|
||||
decodingError = err
|
||||
break
|
||||
}
|
||||
}
|
||||
duration = duration + f.Duration().Seconds()
|
||||
}
|
||||
file.Close()
|
||||
c.Perf.EndBlock()
|
||||
if decodingError != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to decode mp3 file"))
|
||||
}
|
||||
|
||||
c.Perf.StartBlock("MARKDOWN", "Parsing description")
|
||||
descriptionRendered := parsing.ParsePostInput(description, parsing.RealMarkdown)
|
||||
c.Perf.EndBlock()
|
||||
|
||||
guidStr := ""
|
||||
if isEdit {
|
||||
guidStr = podcastResult.Episodes[0].GUID.String()
|
||||
c.Perf.StartBlock("SQL", "Updating podcast episode")
|
||||
_, err := c.Conn.Exec(c.Context(),
|
||||
`
|
||||
UPDATE handmade_podcastepisode
|
||||
SET
|
||||
title = $1,
|
||||
description = $2,
|
||||
description_rendered = $3,
|
||||
audio_filename = $4,
|
||||
duration = $5,
|
||||
episode_number = $6
|
||||
WHERE
|
||||
guid = $7
|
||||
`,
|
||||
title,
|
||||
description,
|
||||
descriptionRendered,
|
||||
episodeFile,
|
||||
duration,
|
||||
episodeNumber,
|
||||
podcastResult.Episodes[0].GUID,
|
||||
)
|
||||
c.Perf.EndBlock()
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to update podcast episode"))
|
||||
}
|
||||
} else {
|
||||
guid := uuid.New()
|
||||
guidStr = guid.String()
|
||||
c.Perf.StartBlock("SQL", "Creating new podcast episode")
|
||||
_, err := c.Conn.Exec(c.Context(),
|
||||
`
|
||||
INSERT INTO handmade_podcastepisode
|
||||
(guid, title, description, description_rendered, audio_filename, duration, pub_date, episode_number, podcast_id)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`,
|
||||
guid,
|
||||
title,
|
||||
description,
|
||||
descriptionRendered,
|
||||
episodeFile,
|
||||
duration,
|
||||
time.Now(),
|
||||
episodeNumber,
|
||||
podcastResult.Podcast.ID,
|
||||
)
|
||||
c.Perf.EndBlock()
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to create podcast episode"))
|
||||
}
|
||||
}
|
||||
|
||||
return c.Redirect(hmnurl.BuildPodcastEpisodeEditSuccess(c.CurrentProject.Slug, guidStr), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func GetEpisodeFiles(projectSlug string) ([]string, error) {
|
||||
folderStr := fmt.Sprintf("public/media/podcast/%s/", projectSlug)
|
||||
folder := os.DirFS(folderStr)
|
||||
files, err := fs.Glob(folder, "*.mp3")
|
||||
return files, err
|
||||
}
|
||||
|
||||
type PodcastRSSData struct {
|
||||
Podcast templates.Podcast
|
||||
Episodes []templates.PodcastEpisode
|
||||
}
|
||||
|
||||
func PodcastRSS(c *RequestContext) ResponseData {
|
||||
podcastResult, err := FetchPodcast(c, c.CurrentProject.ID, true, "")
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
if podcastResult.Podcast == nil {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
podcastRSSData := PodcastRSSData{
|
||||
Podcast: templates.PodcastToTemplate(c.CurrentProject.Slug, podcastResult.Podcast, podcastResult.ImageFile),
|
||||
}
|
||||
|
||||
for _, episode := range podcastResult.Episodes {
|
||||
var filesize int64
|
||||
stat, err := os.Stat(fmt.Sprintf("./public/media/podcast/%s/%s", c.CurrentProject.Slug, episode.AudioFile))
|
||||
if err != nil {
|
||||
c.Logger.Err(err).Msg("Couldn't get filesize for podcast episode")
|
||||
} else {
|
||||
filesize = stat.Size()
|
||||
}
|
||||
podcastRSSData.Episodes = append(podcastRSSData.Episodes, templates.PodcastEpisodeToTemplate(c.CurrentProject.Slug, episode, filesize, podcastResult.ImageFile))
|
||||
}
|
||||
|
||||
var res ResponseData
|
||||
err = res.WriteTemplate("podcast.xml", podcastRSSData, c.Perf)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render podcast RSS"))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
type PodcastResult struct {
|
||||
Podcast *models.Podcast
|
||||
ImageFile string
|
||||
Episodes []*models.PodcastEpisode
|
||||
}
|
||||
|
||||
func FetchPodcast(c *RequestContext, projectId int, fetchEpisodes bool, episodeGUID string) (PodcastResult, error) {
|
||||
var result PodcastResult
|
||||
c.Perf.StartBlock("SQL", "Fetch podcast")
|
||||
type podcastQuery struct {
|
||||
Podcast models.Podcast `db:"podcast"`
|
||||
ImageFilename string `db:"imagefile.file"`
|
||||
}
|
||||
podcastQueryResult, err := db.QueryOne(c.Context(), c.Conn, podcastQuery{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM handmade_podcast AS podcast
|
||||
LEFT JOIN handmade_imagefile AS imagefile ON imagefile.id = podcast.image_id
|
||||
WHERE podcast.project_id = $1
|
||||
`,
|
||||
projectId,
|
||||
)
|
||||
c.Perf.EndBlock()
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoMatchingRows) {
|
||||
return result, nil
|
||||
} else {
|
||||
return result, oops.New(err, "failed to fetch podcast")
|
||||
}
|
||||
}
|
||||
podcast := podcastQueryResult.(*podcastQuery).Podcast
|
||||
podcastImageFilename := podcastQueryResult.(*podcastQuery).ImageFilename
|
||||
result.Podcast = &podcast
|
||||
result.ImageFile = podcastImageFilename
|
||||
|
||||
if fetchEpisodes {
|
||||
type podcastEpisodeQuery struct {
|
||||
Episode models.PodcastEpisode `db:"episode"`
|
||||
}
|
||||
if episodeGUID == "" {
|
||||
c.Perf.StartBlock("SQL", "Fetch podcast episodes")
|
||||
podcastEpisodeQueryResult, err := db.Query(c.Context(), c.Conn, podcastEpisodeQuery{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM handmade_podcastepisode AS episode
|
||||
WHERE episode.podcast_id = $1
|
||||
ORDER BY episode.season_number DESC, episode.episode_number DESC
|
||||
`,
|
||||
podcast.ID,
|
||||
)
|
||||
c.Perf.EndBlock()
|
||||
if err != nil {
|
||||
return result, oops.New(err, "failed to fetch podcast episodes")
|
||||
}
|
||||
for _, episodeRow := range podcastEpisodeQueryResult.ToSlice() {
|
||||
result.Episodes = append(result.Episodes, &episodeRow.(*podcastEpisodeQuery).Episode)
|
||||
}
|
||||
} else {
|
||||
guid, err := uuid.Parse(episodeGUID)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
c.Perf.StartBlock("SQL", "Fetch podcast episode")
|
||||
podcastEpisodeQueryResult, err := db.QueryOne(c.Context(), c.Conn, podcastEpisodeQuery{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM handmade_podcastepisode AS episode
|
||||
WHERE episode.podcast_id = $1 AND episode.guid = $2
|
||||
`,
|
||||
podcast.ID,
|
||||
guid,
|
||||
)
|
||||
c.Perf.EndBlock()
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoMatchingRows) {
|
||||
return result, nil
|
||||
} else {
|
||||
return result, oops.New(err, "failed to fetch podcast episode")
|
||||
}
|
||||
}
|
||||
episode := podcastEpisodeQueryResult.(*podcastEpisodeQuery).Episode
|
||||
result.Episodes = append(result.Episodes, &episode)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
package website
|
||||
|
||||
import (
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
)
|
||||
|
||||
func CanEditProject(c *RequestContext, user *models.User, projectId int) (bool, error) {
|
||||
if user != nil {
|
||||
if user.IsStaff {
|
||||
return true, nil
|
||||
} else {
|
||||
owners, err := FetchProjectOwners(c, projectId)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, owner := range owners {
|
||||
if owner.ID == user.ID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func FetchProjectOwners(c *RequestContext, projectId int) ([]*models.User, error) {
|
||||
var result []*models.User
|
||||
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
|
||||
`,
|
||||
projectId,
|
||||
)
|
||||
c.Perf.EndBlock()
|
||||
if err != nil {
|
||||
return result, oops.New(err, "failed to fetch owners for project")
|
||||
}
|
||||
for _, ownerRow := range ownerQueryResult.ToSlice() {
|
||||
result = append(result, &ownerRow.(*ownerQuery).Owner)
|
||||
}
|
||||
return result, nil
|
||||
}
|
|
@ -266,37 +266,20 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
|||
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,
|
||||
)
|
||||
owners, err := FetchProjectOwners(c, project.ID)
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch owners for project"))
|
||||
return ErrorResponse(http.StatusInternalServerError, err)
|
||||
}
|
||||
ownerQueryData := ownerQueryResult.ToSlice()
|
||||
c.Perf.EndBlock()
|
||||
|
||||
canView := false
|
||||
canEdit := false
|
||||
if c.CurrentUser != nil {
|
||||
if c.CurrentUser.IsSuperuser {
|
||||
if c.CurrentUser.IsStaff {
|
||||
canView = true
|
||||
canEdit = true
|
||||
} else {
|
||||
for _, ownerRow := range ownerQueryData {
|
||||
if ownerRow.(*ownerQuery).Owner.ID == c.CurrentUser.ID {
|
||||
for _, owner := range owners {
|
||||
if owner.ID == c.CurrentUser.ID {
|
||||
canView = true
|
||||
canEdit = true
|
||||
break
|
||||
|
@ -397,8 +380,8 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
|||
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))
|
||||
for _, owner := range owners {
|
||||
projectHomepageData.Owners = append(projectHomepageData.Owners, templates.UserToTemplate(owner, c.Theme))
|
||||
}
|
||||
|
||||
if project.Flags == 1 {
|
||||
|
|
|
@ -79,6 +79,7 @@ func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
|||
Route: route.Regex.String(),
|
||||
Logger: logging.GlobalLogger(),
|
||||
Req: req,
|
||||
Res: rw,
|
||||
}
|
||||
|
||||
if len(match) > 0 {
|
||||
|
@ -110,6 +111,10 @@ type RequestContext struct {
|
|||
Req *http.Request
|
||||
PathParams map[string]string
|
||||
|
||||
// NOTE(asaf): This is the http package's internal response object. Not just a ResponseWriter.
|
||||
// We sometimes need the original response object so that some functions of the http package can set connection-management flags on it.
|
||||
Res http.ResponseWriter
|
||||
|
||||
Conn *pgxpool.Pool
|
||||
CurrentProject *models.Project
|
||||
CurrentUser *models.User
|
||||
|
|
|
@ -159,6 +159,16 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
|
|||
mainRoutes.GET(hmnurl.RegexForumCategory, ForumCategory)
|
||||
mainRoutes.GET(hmnurl.RegexForumPost, ForumPostRedirect)
|
||||
|
||||
mainRoutes.GET(hmnurl.RegexPodcast, PodcastIndex)
|
||||
mainRoutes.GET(hmnurl.RegexPodcastEdit, PodcastEdit)
|
||||
mainRoutes.POST(hmnurl.RegexPodcastEdit, PodcastEditSubmit)
|
||||
mainRoutes.GET(hmnurl.RegexPodcastEpisodeNew, PodcastEpisodeNew)
|
||||
mainRoutes.POST(hmnurl.RegexPodcastEpisodeNew, PodcastEpisodeSubmit)
|
||||
mainRoutes.GET(hmnurl.RegexPodcastEpisodeEdit, PodcastEpisodeEdit)
|
||||
mainRoutes.POST(hmnurl.RegexPodcastEpisodeEdit, PodcastEpisodeSubmit)
|
||||
mainRoutes.GET(hmnurl.RegexPodcastEpisode, PodcastEpisode)
|
||||
mainRoutes.GET(hmnurl.RegexPodcastRSS, PodcastRSS)
|
||||
|
||||
mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS)
|
||||
|
||||
// Other
|
||||
|
@ -368,6 +378,7 @@ func AddCORSHeaders(c *RequestContext, res *ResponseData) {
|
|||
}
|
||||
if strings.HasSuffix(origin, parsed.Host) {
|
||||
res.Header().Add("Access-Control-Allow-Origin", origin)
|
||||
res.Header().Add("Vary", "Origin")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -398,7 +409,7 @@ func getCurrentUserAndSession(c *RequestContext, sessionId string) (*models.User
|
|||
}
|
||||
|
||||
func TrackRequestPerf(c *RequestContext, perfCollector *perf.PerfCollector) (after func()) {
|
||||
c.Perf = perf.MakeNewRequestPerf(c.Route, c.Req.URL.Path)
|
||||
c.Perf = perf.MakeNewRequestPerf(c.Route, c.Req.Method, c.Req.URL.Path)
|
||||
return func() {
|
||||
c.Perf.EndRequest()
|
||||
log := logging.Info()
|
||||
|
@ -410,7 +421,7 @@ func TrackRequestPerf(c *RequestContext, perfCollector *perf.PerfCollector) (aft
|
|||
log.Str(fmt.Sprintf("[%4.d] At %9.2fms", i, c.Perf.MsFromStart(&block)), fmt.Sprintf("%*.s[%s] %s (%.4fms)", len(blockStack)*2, "", block.Category, block.Description, block.DurationMs()))
|
||||
blockStack = append(blockStack, block.End)
|
||||
}
|
||||
log.Msg(fmt.Sprintf("Served %s in %.4fms", c.Perf.Path, float64(c.Perf.End.Sub(c.Perf.Start).Nanoseconds())/1000/1000))
|
||||
log.Msg(fmt.Sprintf("Served [%s] %s in %.4fms", c.Perf.Method, c.Perf.Path, float64(c.Perf.End.Sub(c.Perf.Start).Nanoseconds())/1000/1000))
|
||||
perfCollector.SubmitRun(c.Perf)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue