diff --git a/layout/sidebar/sidebar.pixy b/layout/sidebar/sidebar.pixy index 1c1a14cd..36fa518f 100644 --- a/layout/sidebar/sidebar.pixy +++ b/layout/sidebar/sidebar.pixy @@ -17,6 +17,7 @@ component Sidebar(user *arn.User) SidebarButton("Explore", "/explore", "th") SidebarButton("Calendar", "/calendar", "calendar") SidebarButton("Soundtracks", "/soundtracks", "headphones") + SidebarButton("Quotes", "/quotes", "quote-left") SidebarButton("Users", "/users", "globe") if user != nil diff --git a/mixins/Quote.pixy b/mixins/Quote.pixy new file mode 100644 index 00000000..4eca694f --- /dev/null +++ b/mixins/Quote.pixy @@ -0,0 +1,23 @@ +component Quote(quote *arn.Quote) + .quote.mountable(id=quote.ID) + QuoteContent(quote) + QuoteFooter(quote) + +component QuoteContent(quote *arn.Quote) + .quote-content + a.quotation.ajax(href=quote.Link()) + blockquote + p + q= quote.Description + if quote.Character() != nil + footer.quote-character + span= "by" + a.ajax(href=quote.Character().Link())= quote.Character().Name + CharacterSmall(quote.Character()) + +component QuoteFooter(quote *arn.Quote) + .quote-footer + span posted + span.utc-date(data-date=quote.Created) + span by + a.ajax(href=quote.Creator().Link())= quote.Creator().Nick + " " diff --git a/mixins/TabLikeQuote.pixy b/mixins/TabLikeQuote.pixy new file mode 100644 index 00000000..0abd565b --- /dev/null +++ b/mixins/TabLikeQuote.pixy @@ -0,0 +1,16 @@ +// This should be made abstract for every Likeable so we avoid repeating ourselves but I'm unsure on +// how to do it +component TabLikeQuote(label string, icon string, quote *arn.Quote, user *arn.User) + if user == nil + .tab.action(aria-label=label, title="Login to like this quote") + Icon(icon) + span.tab-text= label + else + if quote.LikedBy(user.ID) + .tab.action(data-api="/api" + quote.Link(), data-action="unlike", data-trigger="click", aria-label=label, title="Click to unlike this quote") + Icon(icon) + span.tab-text= label + else + .tab.action(data-api="/api" + quote.Link(), data-action="like", data-trigger="click", aria-label=label, title="Click to like this quote") + Icon(icon + "-o") + span.tab-text= label diff --git a/pages/character/character.go b/pages/character/character.go index 9d6508ef..2ed5ac1b 100644 --- a/pages/character/character.go +++ b/pages/character/character.go @@ -36,6 +36,13 @@ func Get(ctx *aero.Context) string { return characterAnime[i].StartDate < characterAnime[j].StartDate }) + // Quotes + quotes := arn.FilterQuotes(func(quote *arn.Quote) bool { + return !quote.IsDraft && len(quote.Description) > 0 && quote.CharacterID == character.ID + }) + + arn.SortQuotesPopularFirst(quotes) + // Set OpenGraph attributes description := character.Description @@ -61,5 +68,5 @@ func Get(ctx *aero.Context) string { }, } - return ctx.HTML(components.CharacterDetails(character, characterAnime, user)) + return ctx.HTML(components.CharacterDetails(character, characterAnime, quotes, user)) } diff --git a/pages/character/character.pixy b/pages/character/character.pixy index 5dd50624..ca598cdd 100644 --- a/pages/character/character.pixy +++ b/pages/character/character.pixy @@ -1,4 +1,4 @@ -component CharacterDetails(character *arn.Character, characterAnime []*arn.Anime, user *arn.User) +component CharacterDetails(character *arn.Character, characterAnime []*arn.Anime, quotes []*arn.Quote, user *arn.User) .character-page .character-left-column .character-header @@ -15,6 +15,12 @@ component CharacterDetails(character *arn.Character, characterAnime []*arn.Anime each anime in characterAnime a.character-anime-item.ajax(href=anime.Link(), title=anime.Title.ByUser(user)) img.character-anime-item-image.lazy(data-src=anime.Image("small"), data-webp="true", alt=anime.Title.ByUser(user)) + if len(quotes) >0 + h3 Quotes + .character-quotes + each quote in quotes + .character-quote + Quote(quote) if len(character.Attributes) > 0 .character-sidebar diff --git a/pages/character/character.scarlet b/pages/character/character.scarlet index 5926e034..0da011c3 100644 --- a/pages/character/character.scarlet +++ b/pages/character/character.scarlet @@ -41,6 +41,16 @@ .character-anime-item-image anime-mini-item-image +.character-quotes + horizontal-wrap + justify-content space-around + +.character-quote + flex-basis 400px + + footer + display none + > 1250px .character-page horizontal diff --git a/pages/index.go b/pages/index.go index ad7db770..c0daf691 100644 --- a/pages/index.go +++ b/pages/index.go @@ -43,6 +43,8 @@ import ( "github.com/animenotifier/notify.moe/pages/posts" "github.com/animenotifier/notify.moe/pages/profile" "github.com/animenotifier/notify.moe/pages/recommended" + "github.com/animenotifier/notify.moe/pages/quote" + "github.com/animenotifier/notify.moe/pages/quotes" "github.com/animenotifier/notify.moe/pages/search" "github.com/animenotifier/notify.moe/pages/settings" "github.com/animenotifier/notify.moe/pages/shop" @@ -102,6 +104,14 @@ func Configure(app *aero.Application) { // Characters l.Page("/character/:id", character.Get) + // Quotes + l.Page("/quote/:id", quote.Get) + l.Page("/quote/:id/edit", quote.Edit) + l.Page("/quotes", quotes.Latest) + l.Page("/quotes/from/:index", quotes.LatestFrom) + l.Page("/quotes/best", quotes.Best) + l.Page("/quotes/best/from/:index", quotes.BestFrom) + // Calendar l.Page("/calendar", calendar.Get) diff --git a/pages/quote/edit.go b/pages/quote/edit.go new file mode 100644 index 00000000..ad1f521b --- /dev/null +++ b/pages/quote/edit.go @@ -0,0 +1,36 @@ +package quote + +import ( + "net/http" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" + "github.com/animenotifier/notify.moe/utils/editform" +) + +// Edit quote. +func Edit(ctx *aero.Context) string { + user := utils.GetUser(ctx) + id := ctx.Get("id") + quote, err := arn.GetQuote(id) + + if err != nil { + return ctx.Error(http.StatusNotFound, "Quote not found", err) + } + + ctx.Data = &arn.OpenGraph{ + Tags: map[string]string{ + "og:title": quote.Description, + "og:url": "https://" + ctx.App.Config.Domain + quote.Link(), + "og:site_name": "notify.moe", + }, + } + + if quote.Character() != nil { + ctx.Data.(*arn.OpenGraph).Tags["og:image"] = quote.Character().Image + } + + return ctx.HTML(components.QuoteTabs(quote, user) + editform.Render(quote, "Edit quote", user)) +} diff --git a/pages/quote/quote.go b/pages/quote/quote.go new file mode 100644 index 00000000..5e1dcd7a --- /dev/null +++ b/pages/quote/quote.go @@ -0,0 +1,39 @@ +package quote + +import ( + "net/http" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" +) + +// Get quote. +func Get(ctx *aero.Context) string { + user := utils.GetUser(ctx) + id := ctx.Get("id") + quote, err := arn.GetQuote(id) + + if err != nil { + return ctx.Error(http.StatusNotFound, "Quote not found", err) + } + + character, err := arn.GetCharacter(quote.CharacterID) + if err != nil { + return ctx.Error(http.StatusNotFound, "Quote not found", err) + } + + openGraph := &arn.OpenGraph{ + Tags: map[string]string{ + "og:title": quote.Description, + "og:description": quote.Description, + "og:url": "https://" + ctx.App.Config.Domain + quote.Link(), + "og:site_name": "notify.moe", + "og:type": "article", + }, + } + + ctx.Data = openGraph + return ctx.HTML(components.QuotePage(quote, character, user)) +} diff --git a/pages/quote/quote.pixy b/pages/quote/quote.pixy new file mode 100644 index 00000000..913b4a1c --- /dev/null +++ b/pages/quote/quote.pixy @@ -0,0 +1,57 @@ +component QuotePage(quote *arn.Quote, character *arn.Character, user *arn.User) + QuoteTabs(quote, user) + .quote-full-page + .quote-main-column + QuoteMainColumn(quote, user) + .quote-side-column + QuoteSideColumn(quote, user) + +component QuoteMainColumn(quote *arn.Quote, user *arn.User) + .widget-form + QuoteContent(quote) + + .footer.mountable + if quote.EditedBy != "" + span Edited + span.utc-date(data-date=quote.Edited) + span by + a.ajax(href=quote.EditedByUser().Link())= quote.EditedByUser().Nick + else + span Posted + span.utc-date(data-date=quote.Created) + span by + a.ajax(href=quote.Creator().Link())= quote.Creator().Nick + span . + +component QuoteSideColumn(quote *arn.Quote, user *arn.User) + QuoteInformation(quote, user) + +component QuoteInformation(quote *arn.Quote, user *arn.User) + section.quote-section.mountable + h3.quote-section-name Information + table.quote-info-table + if quote.Anime() != nil + tr.mountable(data-mountable-type="info") + td.quote-info-key Anime: + td.quote-info-value + QuoteAnime(quote.Anime(), user) + + if quote.EpisodeNumber != 0 + tr.mountable(data-mountable-type="info") + td.quote-info-key Episode: + td.quote-info-value= quote.EpisodeNumber + + if quote.Time != 0 + tr.mountable(data-mountable-type="info") + td.anime-info-key Time: + td.anime-info-value= strconv.Itoa(quote.Time) + " min" + +component QuoteTabs(quote *arn.Quote, user *arn.User) + .tabs + TabLikeQuote(strconv.Itoa(len(quote.Likes)), "heart", quote, user) + Tab("Quote", "quote-left", quote.Link()) + Tab("Edit", "pencil", quote.Link() + "/edit") + +component QuoteAnime(anime *arn.Anime, user *arn.User) + a.quote-anime-list-item.ajax(href=anime.Link(), title=anime.Title.ByUser(user)) + img.quote-anime-list-item-image.lazy(data-src=anime.Image("small"), data-webp="true", alt=anime.Title.ByUser(user)) \ No newline at end of file diff --git a/pages/quote/quote.scarlet b/pages/quote/quote.scarlet new file mode 100644 index 00000000..d46ebe77 --- /dev/null +++ b/pages/quote/quote.scarlet @@ -0,0 +1,48 @@ +quote-full-page + vertical + +.quote-main-column + vertical + flex 1 + +.quote-side-column + sidebar + +> 1250px + .quote-full-page + horizontal + + .quote-side-column + sidebar-medium + +> 1400px + .quote-side-column + sidebar-big + +.quote-media + iframe + width 100% + +.quote-info-table + margin 0 + +.quote-info-value + text-align right + +.quote-section + margin-top 1rem + + :first-child + margin-top 0 !important + +.quote-section-name + font-weight bold + +.quote-anime-list + horizontal-wrap + +.quote-anime-list-item + anime-mini-item + +.quote-anime-list-item-image + anime-mini-item-image \ No newline at end of file diff --git a/pages/quotes/best.go b/pages/quotes/best.go new file mode 100644 index 00000000..e86df8ea --- /dev/null +++ b/pages/quotes/best.go @@ -0,0 +1,66 @@ +package quotes + +import ( + "net/http" + "strconv" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" +) + +// Best renders the quotes page ordered by the most favorites first. +func Best(ctx *aero.Context) string { + user := utils.GetUser(ctx) + + quotes := arn.FilterQuotes(func(track *arn.Quote) bool { + return !track.IsDraft && len(track.Description) > 0 + }) + + arn.SortQuotesPopularFirst(quotes) + + if len(quotes) > maxQuotes { + quotes = quotes[:maxQuotes] + } + + return ctx.HTML(components.Quotes(quotes, maxQuotes, user)) +} + +// BestFrom renders the quotes from the given index. +func BestFrom(ctx *aero.Context) string { + user := utils.GetUser(ctx) + index, err := ctx.GetInt("index") + + if err != nil { + return ctx.Error(http.StatusBadRequest, "Invalid start index", err) + } + + allQuotes := arn.FilterQuotes(func(track *arn.Quote) bool { + return !track.IsDraft && len(track.Description) > 0 + }) + + if index < 0 || index >= len(allQuotes) { + return ctx.Error(http.StatusBadRequest, "Invalid start index (maximum is "+strconv.Itoa(len(allQuotes))+")", nil) + } + + arn.SortQuotesPopularFirst(allQuotes) + + quotes := allQuotes[index:] + + if len(quotes) > maxQuotes { + quotes = quotes[:maxQuotes] + } + + nextIndex := index + maxQuotes + + if nextIndex >= len(allQuotes) { + // End of data - no more scrolling + ctx.Response().Header().Set("X-LoadMore-Index", "-1") + } else { + // Send the index for the next request + ctx.Response().Header().Set("X-LoadMore-Index", strconv.Itoa(nextIndex)) + } + + return ctx.HTML(components.QuotesScrollable(quotes, user)) +} diff --git a/pages/quotes/quotes.go b/pages/quotes/quotes.go new file mode 100644 index 00000000..35b13068 --- /dev/null +++ b/pages/quotes/quotes.go @@ -0,0 +1,66 @@ +package quotes + +import ( + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" + "net/http" + "strconv" +) + +const maxQuotes = 12 + +// Latest renders the quotes page. +func Latest(ctx *aero.Context) string { + user := utils.GetUser(ctx) + + quotes := arn.FilterQuotes(func(track *arn.Quote) bool { + return !track.IsDraft && len(track.Description) > 0 + }) + + arn.SortQuotesLatestFirst(quotes) + + if len(quotes) > maxQuotes { + quotes = quotes[:maxQuotes] + } + return ctx.HTML(components.Quotes(quotes, maxQuotes, user)) +} + +// LatestFrom renders the quotes from the given index. +func LatestFrom(ctx *aero.Context) string { + user := utils.GetUser(ctx) + index, err := ctx.GetInt("index") + + if err != nil { + return ctx.Error(http.StatusBadRequest, "Invalid start index", err) + } + + allQuotes := arn.FilterQuotes(func(track *arn.Quote) bool { + return !track.IsDraft && len(track.Description) > 0 + }) + + if index < 0 || index >= len(allQuotes) { + return ctx.Error(http.StatusBadRequest, "Invalid start index (maximum is "+strconv.Itoa(len(allQuotes))+")", nil) + } + + arn.SortQuotesLatestFirst(allQuotes) + + quotes := allQuotes[index:] + + if len(quotes) > maxQuotes { + quotes = quotes[:maxQuotes] + } + + nextIndex := index + maxQuotes + + if nextIndex >= len(allQuotes) { + // End of data - no more scrolling + ctx.Response().Header().Set("X-LoadMore-Index", "-1") + } else { + // Send the index for the next request + ctx.Response().Header().Set("X-LoadMore-Index", strconv.Itoa(nextIndex)) + } + + return ctx.HTML(components.QuotesScrollable(quotes, user)) +} diff --git a/pages/quotes/quotes.pixy b/pages/quotes/quotes.pixy new file mode 100644 index 00000000..7d947aa5 --- /dev/null +++ b/pages/quotes/quotes.pixy @@ -0,0 +1,31 @@ +component Quotes(quotes []*arn.Quote, quotesPerPage int, user *arn.User) + h1.page-title Quotes + + QuotesTabs + + .corner-buttons + if user != nil + if user.DraftIndex().QuoteID == "" + button.action(data-action="newObject", data-trigger="click", data-type="quote") + Icon("plus") + span Add quote + else + a.button.ajax(href="/quote/" + user.DraftIndex().QuoteID + "/edit") + Icon("pencil") + span Edit draft + + #load-more-target.quotes + QuotesScrollable(quotes, user) + + if len(quotes) == quotesPerPage + .buttons + LoadMore(quotesPerPage) + +component QuotesScrollable(quotes []*arn.Quote, user *arn.User) + each quote in quotes + Quote(quote) + +component QuotesTabs + .tabs + Tab("Latest", "quote-left", "/quotes") + Tab("Best", "heart", "/quotes/best") \ No newline at end of file diff --git a/pages/quotes/quotes.scarlet b/pages/quotes/quotes.scarlet new file mode 100644 index 00000000..99e99da8 --- /dev/null +++ b/pages/quotes/quotes.scarlet @@ -0,0 +1,87 @@ +.quotes + horizontal-wrap + justify-content space-around + +.quote + vertical + flex 1 + flex-basis 500px + padding 1rem + +.quote-content + vertical + border-radius 3px + border-left 5px solid quote-side-border-color + overflow hidden + box-shadow shadow-light + align-items stretch + align-content stretch + +.quote-character + horizontal + align-self flex-end + align-items baseline + justify-content space-around + padding 0 1em 1em 0 + + span + opacity 0.65 + + a + display inline + margin-left 0.5em + + +.quote-footer + text-align center + margin-bottom 1rem + margin-top 0.4rem + font-size 0.9em + + span + opacity 0.65 + +.quotation + height 100% + display flex + flex-grow 1 + align-items stretch + align-content stretch + +blockquote + flex-grow 1 + padding 1em + + p + line-height 2em + + q + quotes "\201C""\201D" + + :before + color quote-color + content open-quote + font-size 4em + line-height 0.1em + margin-right 0.25em + vertical-align -0.4em + + :after + color quote-color + content close-quote + font-size 4em + line-height 0.1em + margin-left 0.25em + vertical-align -0.4em + + +.character + display none !important + +> 800px + .character + margin 0.5em 0 0 0.5em !important + display block !important + + .quote-character a + display none \ No newline at end of file diff --git a/scripts/Actions/InfiniteScroller.ts b/scripts/Actions/InfiniteScroller.ts index 591f630a..6bbaeb86 100644 --- a/scripts/Actions/InfiniteScroller.ts +++ b/scripts/Actions/InfiniteScroller.ts @@ -3,7 +3,7 @@ import { AnimeNotifier } from "../AnimeNotifier" // Load more export function loadMore(arn: AnimeNotifier, button: HTMLButtonElement) { // Prevent firing this event multiple times - if(arn.isLoading || button.disabled) { + if(arn.isLoading || button.disabled || button.classList.contains("hidden")) { return } diff --git a/scripts/Actions/Theme.ts b/scripts/Actions/Theme.ts index 79ddd84d..851d967e 100644 --- a/scripts/Actions/Theme.ts +++ b/scripts/Actions/Theme.ts @@ -35,7 +35,9 @@ let dark = { "post-like-color": "var(--link-color)", "post-unlike-color": "var(--link-color)", - "post-permalink-color": "var(--link-color)" + "post-permalink-color": "var(--link-color)", + + "quote-color" : "var(--text-color)" } // Toggle theme diff --git a/styles/include/config.scarlet b/styles/include/config.scarlet index 21941f6d..0e8b60da 100644 --- a/styles/include/config.scarlet +++ b/styles/include/config.scarlet @@ -59,6 +59,10 @@ nav-link-hover-slide-color = main-color // nav-link-color = rgb(160, 160, 160) // nav-link-hover-color = rgb(80, 80, 80) +// Quote +quote-color = hsl(0, 0%, 45%) +quote-side-border-color = quote-color + // Forum post-like-color = green !important post-unlike-color = rgb(255, 32, 12) !important diff --git a/styles/include/dark.scarlet b/styles/include/dark.scarlet index b55c961d..a0207da9 100644 --- a/styles/include/dark.scarlet +++ b/styles/include/dark.scarlet @@ -34,4 +34,7 @@ // // Forum // post-like-color = link-color // post-unlike-color = link-color -// post-permalink-color = link-color \ No newline at end of file +// post-permalink-color = link-color + +// // Quote +// quote-color = text-color \ No newline at end of file diff --git a/tests.go b/tests.go index 01464a23..0a137597 100644 --- a/tests.go +++ b/tests.go @@ -87,6 +87,30 @@ var routeTests = map[string][]string{ "/search/Dragon Ball", }, + "/quote/:id": []string{ + "/quote/-8I3JKykR", + }, + + "/quote/:id/edit": []string{ + "/quote/-8I3JKykR/edit", + }, + + "/quotes": []string{ + "/quotes", + }, + + "/quotes/best": []string{ + "/quotes/best", + }, + + "/quotes/from/:index": []string{ + "/quotes/from/2", + }, + + "/quotes/best/from/:index": []string{ + "/quotes/best/from/2", + }, + "/soundtrack/:id": []string{ "/soundtrack/h0ac8sKkg", }, @@ -95,10 +119,22 @@ var routeTests = map[string][]string{ "/soundtrack/h0ac8sKkg/edit", }, + "/soundtracks": []string{ + "/soundtracks", + }, + + "/soundtracks/best": []string{ + "/soundtracks/best", + }, + "/soundtracks/from/:index": []string{ "/soundtracks/from/12", }, + "/soundtracks/best/from/:index": []string{ + "/soundtracks/best/from/12", + }, + "/character/:id": []string{ "/character/6556", },