Merge pull request #3 from animenotifier/go

.
This commit is contained in:
Allen Lydiard 2017-06-26 12:43:11 -03:00 committed by GitHub
commit b1e9740a83
51 changed files with 556 additions and 223 deletions

View File

@ -6,10 +6,15 @@
"files.exclude": { "files.exclude": {
"**/*.js": { "**/*.js": {
"when": "$(basename).ts" "when": "$(basename).ts"
} },
"components/": true
}, },
"files.associations": { "files.associations": {
"*.pixy": "jade", "*.pixy": "jade",
"*.scarlet": "stylus" "*.scarlet": "stylus"
},
"search.exclude": {
"components/": true,
"**/*.svg": true
} }
} }

View File

@ -1,5 +1,9 @@
# Anime Notifier # Anime Notifier
## Info
notify.moe is powered by the [Aero framework](https://github.com/aerogo/aero) from the same author. The project also uses Go and Aerospike.
## Installation ## Installation
### Prerequisites ### Prerequisites

View File

@ -7,21 +7,21 @@ import (
"github.com/aerogo/aero" "github.com/aerogo/aero"
"github.com/animenotifier/arn" "github.com/animenotifier/arn"
"github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/components/js"
) )
func init() { func init() {
// Scripts // Scripts
js := components.JS() scripts := js.Bundle()
app.Get("/scripts", func(ctx *aero.Context) string { app.Get("/scripts", func(ctx *aero.Context) string {
ctx.SetResponseHeader("Content-Type", "application/javascript") ctx.SetResponseHeader("Content-Type", "application/javascript")
return js return scripts
}) })
app.Get("/scripts.js", func(ctx *aero.Context) string { app.Get("/scripts.js", func(ctx *aero.Context) string {
ctx.SetResponseHeader("Content-Type", "application/javascript") ctx.SetResponseHeader("Content-Type", "application/javascript")
return js return scripts
}) })
// Web manifest // Web manifest

View File

@ -0,0 +1,27 @@
package benchmarks
import (
"testing"
"github.com/animenotifier/arn"
"github.com/animenotifier/notify.moe/components"
)
func BenchmarkThread(b *testing.B) {
thread, _ := arn.GetThread("HJgS7c2K")
thread.HTML() // Pre-render markdown
replies, _ := arn.FilterPosts(func(post *arn.Post) bool {
post.HTML() // Pre-render markdown
return post.ThreadID == thread.ID
})
b.ReportAllocs()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
components.Thread(thread, replies, nil)
}
})
}

View File

@ -22,7 +22,7 @@
"loading", "loading",
"fade", "fade",
"mobile", "mobile",
"extension" "embedded"
], ],
"scripts": { "scripts": {
"main": "main" "main": "main"

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@ -3,6 +3,7 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"path"
"reflect" "reflect"
"runtime" "runtime"
"time" "time"
@ -29,7 +30,14 @@ func main() {
color.Yellow("Generating user avatars") color.Yellow("Generating user avatars")
// Switch to main directory // Switch to main directory
os.Chdir("../../") exe, err := os.Executable()
if err != nil {
panic(err)
}
root := path.Dir(exe)
os.Chdir(path.Join(root, "../../"))
// Log // Log
avatarLog.AddOutput(log.File("logs/avatar.log")) avatarLog.AddOutput(log.File("logs/avatar.log"))

123
jobs/main.go Normal file
View File

@ -0,0 +1,123 @@
package main
import (
"fmt"
"os"
"os/exec"
"os/signal"
"path"
"syscall"
"time"
"github.com/aerogo/log"
"github.com/fatih/color"
)
var colorPool = []*color.Color{
color.New(color.FgHiGreen),
color.New(color.FgHiYellow),
color.New(color.FgHiBlue),
color.New(color.FgHiMagenta),
color.New(color.FgHiCyan),
color.New(color.FgGreen),
}
var jobs = map[string]time.Duration{
"active-users": 1 * time.Minute,
"avatars": 1 * time.Hour,
"sync-anime": 10 * time.Hour,
"popular-anime": 11 * time.Hour,
"airing-anime": 12 * time.Hour,
"search-index": 13 * time.Hour,
}
func main() {
// Start all jobs defined in the map above
startJobs()
// Wait for program termination
wait()
}
func startJobs() {
// Get the directory the executable is in
exe, err := os.Executable()
if err != nil {
panic(err)
}
root := path.Dir(exe)
// Log paths
logsPath := path.Join(root, "../", "logs")
jobLogsPath := path.Join(root, "../", "logs", "jobs")
os.Mkdir(jobLogsPath, 0777)
// Scheduler log
mainLog := log.New()
mainLog.AddOutput(os.Stdout)
mainLog.AddOutput(log.File(path.Join(logsPath, "scheduler.log")))
schedulerLog := mainLog
// Color index
colorIndex := 0
// Start each job
for job, interval := range jobs {
jobName := job
jobInterval := interval
executable := path.Join(root, jobName, jobName)
jobColor := colorPool[colorIndex].SprintFunc()
jobLog := log.New()
jobLog.AddOutput(log.File(path.Join(jobLogsPath, jobName+".log")))
fmt.Printf("Registered job %s for execution every %v\n", jobColor(jobName), interval)
go func() {
ticker := time.NewTicker(jobInterval)
defer ticker.Stop()
var err error
for {
// Wait for the given interval first
<-ticker.C
// Now start
schedulerLog.Info("Starting " + jobColor(jobName))
cmd := exec.Command(executable)
cmd.Stdout = jobLog
cmd.Stderr = jobLog
err = cmd.Start()
if err != nil {
schedulerLog.Error("Error starting job", jobColor(jobName), err)
}
err = cmd.Wait()
if err != nil {
schedulerLog.Error("Job exited with error", jobColor(jobName), err)
}
schedulerLog.Info("Finished " + jobColor(jobName))
jobLog.Info("--------------------------------------------------------------------------------")
}
}()
colorIndex = (colorIndex + 1) % len(colorPool)
}
// Finished job registration
println("--------------------------------------------------------------------------------")
}
func wait() {
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
}

View File

@ -4,6 +4,7 @@ component Layout(app *aero.Application, ctx *aero.Context, user *arn.User, conte
title= app.Config.Title title= app.Config.Title
meta(name="viewport", content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes") meta(name="viewport", content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes")
meta(name="theme-color", content=app.Config.Manifest.ThemeColor) meta(name="theme-color", content=app.Config.Manifest.ThemeColor)
link(rel="chrome-webstore-item", href="https://chrome.google.com/webstore/detail/hajchfikckiofgilinkpifobdbiajfch")
link(rel="manifest", href="/manifest.json") link(rel="manifest", href="/manifest.json")
body body
#container(class=utils.GetContainerClass(ctx)) #container(class=utils.GetContainerClass(ctx))

View File

@ -5,7 +5,7 @@ import (
"github.com/aerogo/api" "github.com/aerogo/api"
"github.com/animenotifier/arn" "github.com/animenotifier/arn"
"github.com/animenotifier/notify.moe/auth" "github.com/animenotifier/notify.moe/auth"
"github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/components/css"
"github.com/animenotifier/notify.moe/layout" "github.com/animenotifier/notify.moe/layout"
"github.com/animenotifier/notify.moe/middleware" "github.com/animenotifier/notify.moe/middleware"
"github.com/animenotifier/notify.moe/pages/admin" "github.com/animenotifier/notify.moe/pages/admin"
@ -41,7 +41,7 @@ func configure(app *aero.Application) *aero.Application {
app.Security.Load("security/fullchain.pem", "security/privkey.pem") app.Security.Load("security/fullchain.pem", "security/privkey.pem")
// CSS // CSS
app.SetStyle(components.CSS()) app.SetStyle(css.Bundle())
// Sessions // Sessions
app.Sessions.Duration = 3600 * 24 app.Sessions.Duration = 3600 * 24

View File

@ -9,19 +9,22 @@ import (
) )
func TestRoutes(t *testing.T) { func TestRoutes(t *testing.T) {
expectedStatus := http.StatusOK
app := configure(aero.New()) app := configure(aero.New())
request, err := http.NewRequest("GET", "/", nil)
if err != nil { for _, examples := range tests {
t.Fatal(err) for _, example := range examples {
} request, err := http.NewRequest("GET", example, nil)
responseRecorder := httptest.NewRecorder() if err != nil {
app.Handler().ServeHTTP(responseRecorder, request) t.Fatal(err)
}
if status := responseRecorder.Code; status != expectedStatus { responseRecorder := httptest.NewRecorder()
t.Errorf("Wrong status code: %v instead of %v", status, expectedStatus) app.Handler().ServeHTTP(responseRecorder, request)
if status := responseRecorder.Code; status != http.StatusOK {
t.Errorf("%s | Wrong status code | %v instead of %v", example, status, http.StatusOK)
}
}
} }
} }

View File

@ -9,6 +9,6 @@ component ForumTags
ForumTag("Bugs", "bug", "list") ForumTag("Bugs", "bug", "list")
component ForumTag(title string, category string, icon string) component ForumTag(title string, category string, icon string)
a.button.forum-tag.ajax(href=strings.TrimSuffix("/forum/" + category, "/")) a.button.forum-tag.action(href=strings.TrimSuffix("/forum/" + category, "/"), data-action="diff", data-trigger="click")
Icon(arn.GetForumIcon(category)) Icon(arn.GetForumIcon(category))
span.forum-tag-text= title span.forum-tag-text= title

View File

@ -8,10 +8,10 @@ component InputTextArea(id string, value string, label string, placeholder strin
label(for=id)= label + ":" label(for=id)= label + ":"
textarea.widget-element.action(id=id, placeholder=placeholder, title=placeholder, data-action="save", data-trigger="change")= value textarea.widget-element.action(id=id, placeholder=placeholder, title=placeholder, data-action="save", data-trigger="change")= value
component InputNumber(id string, value int, label string, placeholder string, min string, max string) component InputNumber(id string, value float64, label string, placeholder string, min string, max string, step string)
.widget-input .widget-input
label(for=id)= label + ":" label(for=id)= label + ":"
input.widget-element.action(id=id, type="number", value=value, min=min, max=max, placeholder=placeholder, title=placeholder, data-action="save", data-trigger="change") input.widget-element.action(id=id, type="number", value=value, min=min, max=max, step=step, placeholder=placeholder, title=placeholder, data-action="save", data-trigger="change")
component InputSelection(id string, value string, label string, placeholder string) component InputSelection(id string, value string, label string, placeholder string)
.widget-input .widget-input

View File

@ -20,10 +20,13 @@ component LoggedOutMenu
component LoggedInMenu(user *arn.User) component LoggedInMenu(user *arn.User)
nav#navigation.logged-in nav#navigation.logged-in
NavigationButton("Dash", "/", "inbox") .extension-navigation
NavigationButton("Anime", "/anime", "television") NavigationButton("Watching list", "/extension/embed", "home")
NavigationButton("Dash", "/", "dashboard")
NavigationButton("Profile", "/+", "user") NavigationButton("Profile", "/+", "user")
NavigationButton("Forum", "/forum", "comment") NavigationButton("Forum", "/forum", "comment")
NavigationButton("Anime", "/anime", "television")
FuzzySearch FuzzySearch

View File

@ -7,7 +7,7 @@ component Postable(post arn.Postable, highlightAuthorID string)
//- a.user.post-recipient(href="/+" + post.recipient.nick, title=post.recipient.nick) //- a.user.post-recipient(href="/+" + post.recipient.nick, title=post.recipient.nick)
//- img.user-image(src=post.recipient.avatar ? (post.recipient.avatar + "?s=100&r=x&d=mm") : "/images/elements/no-gravatar.svg", alt=post.recipient.nick) //- img.user-image(src=post.recipient.avatar ? (post.recipient.avatar + "?s=100&r=x&d=mm") : "/images/elements/no-gravatar.svg", alt=post.recipient.nick)
.post-content .post-content
div(id="render-" + post.ID())!= aero.Markdown(post.Text()) div(id="render-" + post.ID())!= post.HTML()
//- if user && user.ID === post.authorId //- if user && user.ID === post.authorId
//- textarea.post-input.hidden(id="source-" + post.ID)= post.text //- textarea.post-input.hidden(id="source-" + post.ID)= post.text

View File

@ -41,9 +41,9 @@ component Anime(anime *arn.Anime, user *arn.User)
.anime-rating-category(title=toString(anime.Rating.Visuals / 10)) .anime-rating-category(title=toString(anime.Rating.Visuals / 10))
.anime-rating-category-name Visuals .anime-rating-category-name Visuals
Rating(anime.Rating.Visuals) Rating(anime.Rating.Visuals)
.anime-rating-category(title=toString(anime.Rating.Music / 10)) .anime-rating-category(title=toString(anime.Rating.Soundtrack / 10))
.anime-rating-category-name Music .anime-rating-category-name Soundtrack
Rating(anime.Rating.Music) Rating(anime.Rating.Soundtrack)
if len(anime.Trailers) > 0 && anime.Trailers[0].Service == "Youtube" && anime.Trailers[0].VideoID != "" if len(anime.Trailers) > 0 && anime.Trailers[0].Service == "Youtube" && anime.Trailers[0].VideoID != ""
h3.anime-section-name Video h3.anime-section-name Video

View File

@ -7,11 +7,13 @@ import (
"github.com/aerogo/aero" "github.com/aerogo/aero"
"github.com/animenotifier/arn" "github.com/animenotifier/arn"
"github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/components"
"github.com/animenotifier/notify.moe/utils"
) )
// Get anime list. // Get anime list.
func Get(ctx *aero.Context) string { func Get(ctx *aero.Context) string {
nick := ctx.Get("nick") nick := ctx.Get("nick")
user := utils.GetUser(ctx)
viewUser, err := arn.GetUserByNick(nick) viewUser, err := arn.GetUserByNick(nick)
if err != nil { if err != nil {
@ -25,8 +27,8 @@ func Get(ctx *aero.Context) string {
} }
sort.Slice(animeList.Items, func(i, j int) bool { sort.Slice(animeList.Items, func(i, j int) bool {
return animeList.Items[i].FinalRating() < animeList.Items[j].FinalRating() return animeList.Items[i].FinalRating() > animeList.Items[j].FinalRating()
}) })
return ctx.HTML(components.AnimeList(animeList)) return ctx.HTML(components.AnimeList(animeList, user))
} }

View File

@ -1,17 +1,26 @@
component AnimeList(animeList *arn.AnimeList) component AnimeList(animeList *arn.AnimeList, user *arn.User)
table.anime-list table.anime-list
thead thead
tr tr
th.anime-list-item-name Anime th.anime-list-item-name Anime
th.anime-list-item-episodes Progress th.anime-list-item-episodes Episodes
th.anime-list-item-rating Rating th.anime-list-item-rating Rating
if user != nil
th.anime-list-item-actions Actions
tbody tbody
each item in animeList.Items each item in animeList.Items
tr.anime-list-item.mountable(title=item.Notes) tr.anime-list-item.mountable(title=item.Notes)
td.anime-list-item-name td.anime-list-item-name
a.ajax(href=item.Anime().Link())= item.Anime().Title.Canonical a.ajax(href=item.Link(animeList.User().Nick))= item.Anime().Title.Canonical
td.anime-list-item-episodes td.anime-list-item-episodes
span.anime-list-item-episodes-watched= item.Episodes .anime-list-item-episodes-watched= item.Episodes
span.anime-list-item-episodes-separator / .anime-list-item-episodes-separator /
span.anime-list-item-episodes-max= item.Anime().EpisodeCountString() .anime-list-item-episodes-max= item.Anime().EpisodeCountString()
td.anime-list-item-rating= item.FinalRating() //- .anime-list-item-episodes-edit
//- a.ajax(href=, title="Edit anime")
//- RawIcon("pencil")
td.anime-list-item-rating= item.FinalRating()
if user != nil
td.anime-list-item-actions
a(href=arn.Nyaa.GetLink(item.Anime()), title="Search on Nyaa", target="_blank", rel="noopener")
RawIcon("download")

View File

@ -4,17 +4,53 @@
tr tr
horizontal horizontal
thead
display none
.anime-list-item-name .anime-list-item-name
flex 0.8 flex 1
white-space nowrap white-space nowrap
text-overflow ellipsis text-overflow ellipsis
overflow hidden overflow hidden
.anime-list-item-episodes .anime-list-item-episodes
flex 0.1 horizontal
text-align center justify-content flex-end
text-align right
white-space nowrap
flex-basis 120px
.anime-list-item-episodes-watched
flex 0.4
.anime-list-item-episodes-separator
flex 0.2
opacity 0.5
.anime-list-item-episodes-max
flex 0.4
opacity 0.5
// .anime-list-item-episodes-edit
// flex 0.5
// // Beautify icon alignment
// .raw-icon
// margin-bottom -2px
.anime-list-item-rating .anime-list-item-rating
flex 0.1 flex-basis 100px
text-align center text-align center
.anime-list-item-actions
flex-basis 40px
text-align right
// Beautify icon alignment
.raw-icon
margin-bottom -4px
< 1100px
.anime-list-item-rating
display none

View File

@ -3,8 +3,15 @@ component AnimeListItem(viewUser *arn.User, item *arn.AnimeListItem, anime *arn.
.widget.anime-list-item-view(data-api="/api/animelist/" + viewUser.ID + "/update/" + anime.ID) .widget.anime-list-item-view(data-api="/api/animelist/" + viewUser.ID + "/update/" + anime.ID)
h2= anime.Title.Canonical h2= anime.Title.Canonical
InputNumber("Episodes", item.Episodes, "Episodes", "Number of episodes you watched", "0", arn.EpisodeCountMax(anime.EpisodeCount)) InputNumber("Episodes", float64(item.Episodes), "Episodes", "Number of episodes you watched", "0", arn.EpisodeCountMax(anime.EpisodeCount), "1")
InputNumber("RewatchCount", item.RewatchCount, "Rewatched", "How often you rewatched this anime", "0", "100")
.anime-list-item-rating-edit
InputNumber("Rating.Overall", item.Rating.Overall, "Overall", "Overall rating on a scale of 0 to 10", "0", "10", "0.1")
InputNumber("Rating.Story", item.Rating.Story, "Story", "Story rating on a scale of 0 to 10", "0", "10", "0.1")
InputNumber("Rating.Visuals", item.Rating.Visuals, "Visuals", "Visuals rating on a scale of 0 to 10", "0", "10", "0.1")
InputNumber("Rating.Soundtrack", item.Rating.Soundtrack, "Soundtrack", "Soundtrack rating on a scale of 0 to 10", "0", "10", "0.1")
InputNumber("RewatchCount", float64(item.RewatchCount), "Rewatched", "How often you rewatched this anime", "0", "100", "1")
InputTextArea("Notes", item.Notes, "Notes", "Your notes") InputTextArea("Notes", item.Notes, "Notes", "Your notes")

View File

@ -1,7 +1,10 @@
.anime-list-item-view
textarea
height 10rem
.anime-list-item-view-image .anime-list-item-view-image
max-width 55px max-width 55px
margin-bottom 1rem margin-bottom 1rem
.anime-list-item-rating-edit
horizontal-wrap
justify-content space-between
width 100%
.widget-input
max-width 120px

View File

@ -19,7 +19,7 @@ func Get(ctx *aero.Context) string {
return frontpage.Get(ctx) return frontpage.Get(ctx)
} }
posts, err := arn.GetPosts() posts, err := arn.AllPostsSlice()
if err != nil { if err != nil {
return ctx.Error(500, "Error fetching posts", err) return ctx.Error(500, "Error fetching posts", err)
@ -32,6 +32,7 @@ func Get(ctx *aero.Context) string {
} }
followIDList := user.Following followIDList := user.Following
var followingList []*arn.User
if len(followIDList) > maxFollowing { if len(followIDList) > maxFollowing {
followIDList = followIDList[:maxFollowing] followIDList = followIDList[:maxFollowing]
@ -43,7 +44,7 @@ func Get(ctx *aero.Context) string {
return ctx.Error(500, "Error fetching followed users", err) return ctx.Error(500, "Error fetching followed users", err)
} }
followingList := userList.([]*arn.User) followingList = userList.([]*arn.User)
return ctx.HTML(components.Dashboard(posts, followingList)) return ctx.HTML(components.Dashboard(posts, followingList))
} }

View File

@ -14,7 +14,7 @@ func Get(ctx *aero.Context) string {
user := utils.GetUser(ctx) user := utils.GetUser(ctx)
if user == nil { if user == nil {
return ctx.Error(http.StatusUnauthorized, "Not logged in", nil) return utils.AllowEmbed(ctx, ctx.HTML(components.Login()))
} }
animeList := user.AnimeList() animeList := user.AnimeList()
@ -24,8 +24,8 @@ func Get(ctx *aero.Context) string {
} }
sort.Slice(animeList.Items, func(i, j int) bool { sort.Slice(animeList.Items, func(i, j int) bool {
return animeList.Items[i].FinalRating() < animeList.Items[j].FinalRating() return animeList.Items[i].FinalRating() > animeList.Items[j].FinalRating()
}) })
return utils.AllowEmbed(ctx, ctx.HTML(components.AnimeList(animeList))) return utils.AllowEmbed(ctx, ctx.HTML(components.AnimeList(animeList, user)))
} }

View File

@ -8,11 +8,18 @@
.forum-tag .forum-tag
color text-color !important color text-color !important
:hover
color white !important :hover,
&.active &.active
color white !important color white !important
background-color link-hover-color background-color forum-tag-hover-color !important
// color text-color !important
// :hover
// color white !important
// &.active
// color white !important
// background-color link-hover-color
< 920px < 920px
.forum-tag .forum-tag

View File

@ -1,7 +1,12 @@
component FrontPage component FrontPage
p Anime Notifier 4.0 is currently under construction. .frontpage
p h2 notify.moe
a(href="https://paypal.me/blitzprog", target="_blank", rel="noopener") Support the development
p img.action.screenshot(src="/images/elements/extension-screenshot.png", alt="Screenshot of the browser extension", title="Click to install the Chrome Extension", data-action="installExtension", data-trigger="click")
a(href="https://github.com/animenotifier/notify.moe", target="_blank", rel="noopener") Source on GitHub
Login
.footer
a(href="https://paypal.me/blitzprog", target="_blank", rel="noopener") Support the development
span |
a(href="https://github.com/animenotifier/notify.moe", target="_blank", rel="noopener") Source on GitHub

View File

@ -1,11 +1,22 @@
.login-buttons .frontpage
horizontal-wrap vertical
width 100% align-items center
justify-content center
.login-button h2
// font-size 2.5rem
font-weight normal
letter-spacing 3px
text-transform uppercase
.footer
text-align center
margin-top content-padding
.login-button-image .screenshot
max-width 236px max-width 100%
max-height 44px border-radius 3px
box-shadow shadow-medium
margin-bottom 2rem
:hover
cursor pointer

11
pages/login/login.scarlet Normal file
View File

@ -0,0 +1,11 @@
.login-buttons
horizontal-wrap
width 100%
justify-content center
.login-button
//
.login-button-image
max-width 236px
max-height 44px

View File

@ -16,5 +16,5 @@ func Get(ctx *aero.Context) string {
return ctx.Error(http.StatusInternalServerError, "Error fetching popular anime", err) return ctx.Error(http.StatusInternalServerError, "Error fetching popular anime", err)
} }
return ctx.HTML(components.AnimeGrid(animeList)) return ctx.HTML(components.PopularAnime(animeList))
} }

View File

@ -0,0 +1,6 @@
component PopularAnime(animeList []*arn.Anime)
h2 Top 3
AnimeGrid(animeList[:3])
h2 Popular
AnimeGrid(animeList[3:])

View File

@ -79,7 +79,7 @@ component Profile(viewUser *arn.User, user *arn.User, animeList *arn.AnimeList,
.post-author .post-author
Avatar(post.Author()) Avatar(post.Author())
.post-content .post-content
div!= aero.Markdown(post.Text) div!= post.HTML()
.post-toolbar.active .post-toolbar.active
.spacer .spacer
.post-likes= len(post.Likes) .post-likes= len(post.Likes)

View File

@ -80,6 +80,10 @@ profile-boot-duration = 2s
padding-left calc(content-padding * 2) padding-left calc(content-padding * 2)
max-width 900px max-width 900px
.website
a
color white
#nick #nick
margin-bottom 1rem margin-bottom 1rem

View File

@ -1,29 +1,39 @@
package threads package threads
import ( import (
"strings"
"github.com/aerogo/aero" "github.com/aerogo/aero"
"github.com/animenotifier/arn" "github.com/animenotifier/arn"
"github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/components"
"github.com/animenotifier/notify.moe/utils"
) )
// Get thread. // Get thread.
func Get(ctx *aero.Context) string { func Get(ctx *aero.Context) string {
id := ctx.Get("id") id := ctx.Get("id")
thread, err := arn.GetThread(id) thread, err := arn.GetThread(id)
user := utils.GetUser(ctx)
if err != nil { if err != nil {
return ctx.Error(404, "Thread not found", err) return ctx.Error(404, "Thread not found", err)
} }
replies, filterErr := arn.FilterPosts(func(post *arn.Post) bool { replies, filterErr := arn.FilterPosts(func(post *arn.Post) bool {
post.Text = strings.Replace(post.Text, "http://", "https://", -1)
return post.ThreadID == thread.ID return post.ThreadID == thread.ID
}) })
arn.SortPostsLatestLast(replies) arn.SortPostsLatestLast(replies)
// Benchmark
// for i := 0; i < 7; i++ {
// replies = append(replies, replies...)
// }
if filterErr != nil { if filterErr != nil {
return ctx.Error(500, "Error fetching thread replies", err) return ctx.Error(500, "Error fetching thread replies", err)
} }
return ctx.HTML(components.Thread(thread, replies)) return ctx.HTML(components.Thread(thread, replies, user))
} }

View File

@ -1,4 +1,4 @@
component Thread(thread *arn.Thread, posts []*arn.Post) component Thread(thread *arn.Thread, posts []*arn.Post, user *arn.User)
h2.thread-title= thread.Title h2.thread-title= thread.Title
.thread .thread
@ -7,3 +7,12 @@ component Thread(thread *arn.Thread, posts []*arn.Post)
each post in posts each post in posts
Postable(post.ToPostable(), thread.Author().ID) Postable(post.ToPostable(), thread.Author().ID)
// Reply
if user != nil
.post.mountable
.post-author
Avatar(user)
.post-content
textarea(id="new-reply", placeholder="Reply...")

View File

@ -1,46 +0,0 @@
html, body, div, span, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, address, cite, code, del, dfn, em, img, ins, kbd, q, samp, small, strong, sub, sup, var, b, i, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, canvas, details, embed, figure, figcaption, footer, header, hgroup, menu, nav, output, ruby, section, summary, time, mark, audio, video, main
outline 0
border 0
font-size 100%
font inherit
vertical-align baseline
background transparent
html
box-sizing border-box
textarea
resize vertical
article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section
display block
body
line-height 1
-webkit-font-smoothing antialiased
-moz-osx-font-smoothing grayscale
ol, ul
list-style none
blockquote, q
quotes none
blockquote:before, blockquote:after, q:before, q:after
content ''
content none
table
border-collapse collapse
border-spacing 0
audio, canvas, img, video, input, select
vertical-align middle
:focus
outline 0
*
margin 0
padding 0
box-sizing inherit

View File

@ -53,8 +53,8 @@ export class AnimeNotifier {
this.visibilityObserver.disconnect() this.visibilityObserver.disconnect()
// Update each of these asynchronously // Update each of these asynchronously
Promise.resolve().then(() => this.updateMountables()) Promise.resolve().then(() => this.mountMountables())
Promise.resolve().then(() => this.updateActions()) Promise.resolve().then(() => this.assignActions())
Promise.resolve().then(() => this.lazyLoadImages()) Promise.resolve().then(() => this.lazyLoadImages())
} }
@ -75,7 +75,7 @@ export class AnimeNotifier {
} }
} }
updateActions() { assignActions() {
for(let element of findAll("action")) { for(let element of findAll("action")) {
if(element["action assigned"]) { if(element["action assigned"]) {
continue continue
@ -85,6 +85,9 @@ export class AnimeNotifier {
element.addEventListener(element.dataset.trigger, e => { element.addEventListener(element.dataset.trigger, e => {
actions[actionName](this, element, e) actions[actionName](this, element, e)
e.stopPropagation()
e.preventDefault()
}) })
// Use "action assigned" flag instead of removing the class. // Use "action assigned" flag instead of removing the class.
@ -121,21 +124,31 @@ export class AnimeNotifier {
this.visibilityObserver.observe(img) this.visibilityObserver.observe(img)
} }
updateMountables() { mountMountables() {
this.modifyDelayed("mountable", element => element.classList.add("mounted"))
}
unmountMountables() {
for(let element of findAll("mountable")) {
element.classList.remove("mounted")
}
}
modifyDelayed(className: string, func: (element: HTMLElement) => void) {
const delay = 20 const delay = 20
const maxDelay = 1000 const maxDelay = 500
let time = 0 let time = 0
for(let element of findAll("mountable")) { for(let element of findAll(className)) {
setTimeout(() => {
window.requestAnimationFrame(() => element.classList.add("mounted"))
}, time)
time += delay time += delay
if(time > maxDelay) { if(time > maxDelay) {
time = maxDelay func(element)
} else {
setTimeout(() => {
window.requestAnimationFrame(() => func(element))
}, time)
} }
} }
} }

View File

@ -1,3 +1,5 @@
import { Diff } from "./Diff"
class LoadOptions { class LoadOptions {
addToHistory?: boolean addToHistory?: boolean
forceReload?: boolean forceReload?: boolean
@ -57,6 +59,10 @@ export class Application {
} }
load(url: string, options?: LoadOptions) { load(url: string, options?: LoadOptions) {
// Start sending a network request
let request = this.get("/_" + url).catch(error => error)
// Parse options
if(!options) { if(!options) {
options = new LoadOptions() options = new LoadOptions()
} }
@ -64,11 +70,13 @@ export class Application {
if(options.addToHistory === undefined) { if(options.addToHistory === undefined) {
options.addToHistory = true options.addToHistory = true
} }
// Set current path
this.currentPath = url this.currentPath = url
// Start sending a network request // Add to browser history
let request = this.get("/_" + url).catch(error => error) if(options.addToHistory)
history.pushState(url, null, url)
let onTransitionEnd = e => { let onTransitionEnd = e => {
// Ignore transitions of child elements. // Ignore transitions of child elements.
@ -82,13 +90,8 @@ export class Application {
// Wait for the network request to end. // Wait for the network request to end.
request.then(html => { request.then(html => {
// Add to browser history
if(options.addToHistory)
history.pushState(url, null, url)
// Set content // Set content
this.setContent(html) this.setContent(html, false)
this.scrollToTop()
// Fade animations // Fade animations
this.content.classList.remove(this.fadeOutClass) this.content.classList.remove(this.fadeOutClass)
@ -108,11 +111,16 @@ export class Application {
return request return request
} }
setContent(html: string) { setContent(html: string, diff: boolean) {
// Diff.innerHTML(this.content, html) if(diff) {
this.content.innerHTML = html Diff.innerHTML(this.content, html)
} else {
this.content.innerHTML = html
}
this.ajaxify(this.content) this.ajaxify(this.content)
this.markActiveLinks(this.content) this.markActiveLinks(this.content)
this.scrollToTop()
} }
markActiveLinks(element?: HTMLElement) { markActiveLinks(element?: HTMLElement) {

View File

@ -1,18 +1,18 @@
export class Diff { export class Diff {
static childNodes(aRoot: HTMLElement, bRoot: HTMLElement) { static childNodes(aRoot: Node, bRoot: Node) {
let aChild = [...aRoot.childNodes] let aChild = [...aRoot.childNodes]
let bChild = [...bRoot.childNodes] let bChild = [...bRoot.childNodes]
let numNodes = Math.max(aChild.length, bChild.length) let numNodes = Math.max(aChild.length, bChild.length)
for(let i = 0; i < numNodes; i++) { for(let i = 0; i < numNodes; i++) {
let a = aChild[i] as HTMLElement let a = aChild[i]
if(i >= bChild.length) { if(i >= bChild.length) {
aRoot.removeChild(a) aRoot.removeChild(a)
continue continue
} }
let b = bChild[i] as HTMLElement let b = bChild[i]
if(i >= aChild.length) { if(i >= aChild.length) {
aRoot.appendChild(b) aRoot.appendChild(b)
@ -24,38 +24,46 @@ export class Diff {
continue continue
} }
if(a.nodeType === Node.TEXT_NODE) {
a.textContent = b.textContent
continue
}
if(a.nodeType === Node.ELEMENT_NODE) { if(a.nodeType === Node.ELEMENT_NODE) {
if(a.tagName === "IFRAME") { let elemA = a as HTMLElement
let elemB = b as HTMLElement
if(elemA.tagName === "IFRAME") {
continue continue
} }
let removeAttributes: Attr[] = [] let removeAttributes: Attr[] = []
for(let x = 0; x < a.attributes.length; x++) { for(let x = 0; x < elemA.attributes.length; x++) {
let attrib = a.attributes[x] let attrib = elemA.attributes[x]
if(attrib.specified) { if(attrib.specified) {
if(!b.hasAttribute(attrib.name)) { if(!elemB.hasAttribute(attrib.name)) {
removeAttributes.push(attrib) removeAttributes.push(attrib)
} }
} }
} }
for(let attr of removeAttributes) { for(let attr of removeAttributes) {
a.removeAttributeNode(attr) elemA.removeAttributeNode(attr)
} }
for(let x = 0; x < b.attributes.length; x++) { for(let x = 0; x < elemB.attributes.length; x++) {
let attrib = b.attributes[x] let attrib = elemB.attributes[x]
if(attrib.specified) { if(attrib.specified) {
a.setAttribute(attrib.name, b.getAttribute(attrib.name)) elemA.setAttribute(attrib.name, elemB.getAttribute(attrib.name))
} }
} }
// Special case: Apply state of input elements // Special case: Apply state of input elements
if(a !== document.activeElement && a instanceof HTMLInputElement && b instanceof HTMLInputElement) { if(elemA !== document.activeElement && elemA instanceof HTMLInputElement && elemB instanceof HTMLInputElement) {
a.value = b.value elemA.value = elemB.value
} }
} }

View File

@ -1,6 +1,7 @@
import { Application } from "./Application" import { Application } from "./Application"
import { AnimeNotifier } from "./AnimeNotifier" import { AnimeNotifier } from "./AnimeNotifier"
import { Diff } from "./Diff" import { Diff } from "./Diff"
import { delay, findAll } from "./utils"
// Save new data from an input field // Save new data from an input field
export function save(arn: AnimeNotifier, input: HTMLInputElement | HTMLTextAreaElement) { export function save(arn: AnimeNotifier, input: HTMLInputElement | HTMLTextAreaElement) {
@ -10,7 +11,11 @@ export function save(arn: AnimeNotifier, input: HTMLInputElement | HTMLTextAreaE
let value = input.value let value = input.value
if(input.type === "number") { if(input.type === "number") {
obj[input.id] = parseInt(value) if(input.getAttribute("step") === "1") {
obj[input.id] = parseInt(value)
} else {
obj[input.id] = parseFloat(value)
}
} else { } else {
obj[input.id] = value obj[input.id] = value
} }
@ -53,6 +58,36 @@ export function save(arn: AnimeNotifier, input: HTMLInputElement | HTMLTextAreaE
}) })
} }
// Diff
export function diff(arn: AnimeNotifier, element: HTMLElement) {
let url = element.dataset.url || (element as HTMLAnchorElement).getAttribute("href")
let request = fetch("/_" + url).then(response => response.text())
history.pushState(url, null, url)
arn.app.currentPath = url
arn.app.markActiveLinks()
arn.loading(true)
arn.unmountMountables()
// for(let element of findAll("mountable")) {
// element.classList.remove("mountable")
// }
delay(300).then(() => {
request
.then(html => arn.app.setContent(html, true))
.then(() => arn.app.markActiveLinks())
// .then(() => {
// for(let element of findAll("mountable")) {
// element.classList.remove("mountable")
// }
// })
.then(() => arn.app.emit("DOMContentLoaded"))
.then(() => arn.loading(false))
.catch(console.error)
})
}
// Search // Search
export function search(arn: AnimeNotifier, search: HTMLInputElement, e: KeyboardEvent) { export function search(arn: AnimeNotifier, search: HTMLInputElement, e: KeyboardEvent) {
if(e.ctrlKey || e.altKey) { if(e.ctrlKey || e.altKey) {
@ -138,4 +173,10 @@ export function removeAnimeFromCollection(arn: AnimeNotifier, button: HTMLElemen
}) })
.catch(console.error) .catch(console.error)
.then(() => arn.loading(false)) .then(() => arn.loading(false))
}
// Chrome extension installation
export function installExtension(arn: AnimeNotifier, button: HTMLElement) {
let browser: any = window["chrome"]
browser.webstore.install()
} }

View File

@ -1,9 +1,14 @@
export function* findAll(className: string) { export function* findAll(className: string) {
// getElementsByClassName failed for some reason. // getElementsByClassName failed for some reason.
// TODO: Test getElementsByClassName again. // TODO: Test getElementsByClassName again.
let elements = document.querySelectorAll("." + className) // let elements = document.querySelectorAll("." + className)
let elements = document.getElementsByClassName(className)
for(let i = 0; i < elements.length; ++i) { for(let i = 0; i < elements.length; ++i) {
yield elements[i] as HTMLElement yield elements[i] as HTMLElement
} }
}
export function delay<T>(millis: number, value?: T): Promise<T> {
return new Promise(resolve => setTimeout(() => resolve(value), millis))
} }

View File

@ -8,6 +8,7 @@ body
overflow hidden overflow hidden
height 100% height 100%
color text-color color text-color
background-color bg-color
a a
color link-color color link-color
@ -21,8 +22,8 @@ a
:active :active
transform translateY(3px) transform translateY(3px)
&.active // &.active
color link-active-color // color link-active-color
strong strong
font-weight bold font-weight bold

13
styles/embedded.scarlet Normal file
View File

@ -0,0 +1,13 @@
.embedded
// Put navigation to the bottom of the screen
flex-direction column-reverse !important
.extension-navigation
display inline-block
.anime-list
max-width 500px
margin -1.1rem
#navigation
font-size 0.9rem

View File

@ -1,36 +0,0 @@
.embedded
flex-direction column-reverse !important
.anime-list
max-width 500px
margin -1.1rem
thead
display none
.anime-list-item
// ui-element
// margin-bottom 0.5rem
.anime-list-item-episodes
horizontal
text-align right
white-space nowrap
flex 0.2
.anime-list-item-episodes-watched
flex 0.4
.anime-list-item-episodes-max
opacity 0.5
flex 0.4
.anime-list-item-episodes-separator
opacity 0.5
flex 0.2
.anime-list-item-rating
display none
#navigation
font-size 0.9rem

View File

@ -1,19 +1,28 @@
// Colors // Colors
text-color = rgb(60, 60, 60) text-color = rgb(60, 60, 60)
main-color = rgb(248, 165, 130) main-color = rgb(215, 38, 15)
link-color = rgb(225, 38, 15) link-color = main-color
link-hover-color = rgb(242, 60, 30) link-hover-color = rgb(242, 60, 30)
link-active-color = rgb(100, 149, 237) link-active-color = link-hover-color
post-highlight-color = rgba(248, 165, 130, 0.7) post-highlight-color = rgba(248, 165, 130, 0.7)
bg-color = white bg-color = rgb(246, 246, 246)
// UI // UI
ui-border = 1px solid rgba(0, 0, 0, 0.1) ui-border-color = rgba(0, 0, 0, 0.1)
ui-hover-border = 1px solid rgba(0, 0, 0, 0.15) ui-hover-border-color = rgba(0, 0, 0, 0.15)
ui-background = linear-gradient(to bottom, rgba(0, 0, 0, 0.02) 0%, rgba(0, 0, 0, 0.037) 100%) ui-background = rgb(254, 254, 254)
ui-hover-background = linear-gradient(to bottom, rgba(0, 0, 0, 0.01) 0%, rgba(0, 0, 0, 0.027) 100%) // ui-hover-background = rgb(254, 254, 254)
// ui-background = linear-gradient(to bottom, rgba(0, 0, 0, 0.02) 0%, rgba(0, 0, 0, 0.037) 100%)
// ui-hover-background = linear-gradient(to bottom, rgba(0, 0, 0, 0.01) 0%, rgba(0, 0, 0, 0.027) 100%)
ui-disabled-color = rgb(224, 224, 224) ui-disabled-color = rgb(224, 224, 224)
// Input
input-focus-border-color = rgb(248, 165, 130)
// Button
button-hover-color = link-hover-color
forum-tag-hover-color = rgb(46, 85, 160)
// Forum // Forum
forum-width = 830px forum-width = 830px
@ -21,9 +30,10 @@ forum-width = 830px
avatar-size = 50px avatar-size = 50px
// Navigation // Navigation
nav-color = rgb(60, 60, 60) nav-color = text-color
nav-link-color = rgb(160, 160, 160) nav-link-color = rgba(255, 255, 255, 0.5)
nav-link-hover-color = rgb(255, 255, 255) nav-link-hover-color = white
nav-link-hover-slide-color = rgb(248, 165, 130)
// nav-color = rgb(245, 245, 245) // nav-color = rgb(245, 245, 245)
// nav-link-color = rgb(160, 160, 160) // nav-link-color = rgb(160, 160, 160)
// nav-link-hover-color = rgb(80, 80, 80) // nav-link-hover-color = rgb(80, 80, 80)
@ -40,7 +50,7 @@ outline-shadow-heavy = 0 0 6px rgba(0, 0, 0, 0.6)
// Distances // Distances
content-padding = 1.6rem content-padding = 1.6rem
content-padding-top = 1.6rem content-padding-top = 1.6rem
hover-line-size = 2px hover-line-size = 3px
nav-height = 3.11rem nav-height = 3.11rem
// Timings // Timings

View File

@ -18,13 +18,13 @@ mixin vertical-wrap
flex-flow column wrap flex-flow column wrap
mixin ui-element mixin ui-element
border ui-border border 1px solid ui-border-color
background ui-background background ui-background
border-radius 3px border-radius 3px
default-transition default-transition
:hover :hover
border ui-hover-border border-color ui-hover-border-color
background ui-hover-background // background ui-hover-background
// box-shadow outline-shadow-medium // box-shadow outline-shadow-medium
mixin ui-disabled mixin ui-disabled

View File

@ -1,7 +1,7 @@
mixin input-focus mixin input-focus
:focus :focus
color black color black
border 1px solid main-color border 1px solid input-focus-border-color
// TODO: Replace with alpha(main-color, 20%) function // TODO: Replace with alpha(main-color, 20%) function
box-shadow 0 0 6px rgba(248, 165, 130, 0.2) box-shadow 0 0 6px rgba(248, 165, 130, 0.2)
@ -16,15 +16,26 @@ input, textarea
border ui-border border ui-border
background white background white
box-shadow none box-shadow none
width 100%
input-focus input-focus
:disabled :disabled
ui-disabled ui-disabled
input
default-transition
:active
transform translateY(3px)
// We need this to have a selector with a higher priority than .widget-element:focus // We need this to have a selector with a higher priority than .widget-element:focus
input.widget-element input.widget-element,
textarea.widget-element
input-focus input-focus
textarea
height 10rem
button, .button button, .button
ui-element ui-element
horizontal horizontal
@ -33,10 +44,11 @@ button, .button
padding 0.75rem 1rem padding 0.75rem 1rem
color link-color color link-color
:hover :hover,
&.active
cursor pointer cursor pointer
color white color white
background-color link-hover-color background-color button-hover-color
:active :active
transform translateY(3px) transform translateY(3px)

View File

@ -5,4 +5,5 @@
#content-container #content-container
flex 1 flex 1
overflow-x hidden overflow-x hidden
overflow-y scroll overflow-y scroll
// will-change transform

View File

@ -12,8 +12,8 @@
:after :after
content "" content ""
display block display block
height 3px height hover-line-size
background-color main-color background-color nav-link-hover-slide-color
transform scaleX(0) transform scaleX(0)
opacity 0 opacity 0
default-transition default-transition
@ -45,13 +45,16 @@
#search #search
display none display none
border-radius 0 border-radius 0
background text-color background transparent
border none border none
color white color nav-link-hover-color
font-size 1em font-size 1em
min-width 0 min-width 0
::placeholder
color nav-link-color
:focus :focus
border none border none
box-shadow none box-shadow none
@ -59,6 +62,9 @@
.extra-navigation .extra-navigation
display none display none
.extension-navigation
display none
> 330px > 330px
.navigation-button, #search .navigation-button, #search
font-size 1.3em font-size 1.3em

View File

@ -20,4 +20,4 @@ th
tbody tbody
tr tr
:hover :hover
background-color rgba(0, 0, 0, 0.03) background-color rgba(0, 0, 0, 0.015)

View File

@ -8,6 +8,7 @@ p, h1, h2, h3, h4, h5, h6
margin-bottom 0 margin-bottom 0
h2 h2
margin-top content-padding
margin-bottom content-padding margin-bottom content-padding
p > img p > img

View File

@ -12,7 +12,7 @@
.widget-element .widget-element
vertical-wrap vertical-wrap
ui-element ui-element
transition border transition-speed ease, background transition-speed ease, transform transition-speed ease transition border transition-speed ease, background transition-speed ease, transform transition-speed ease, transform color ease
margin-bottom 1rem margin-bottom 1rem
padding 0.5rem 1rem padding 0.5rem 1rem
width 100% width 100%

View File

@ -122,6 +122,7 @@ var tests = map[string][]string{
"/auth/google/callback": nil, "/auth/google/callback": nil,
"/user": nil, "/user": nil,
"/settings": nil, "/settings": nil,
"/extension/embed": nil,
} }
func init() { func init() {