diff --git a/assets.go b/assets.go index fb745c3f..69cc8f80 100644 --- a/assets.go +++ b/assets.go @@ -19,6 +19,11 @@ func init() { return js }) + app.Get("/scripts.js", func(ctx *aero.Context) string { + ctx.SetResponseHeader("Content-Type", "application/javascript") + return js + }) + // Web manifest app.Get("/manifest.json", func(ctx *aero.Context) string { return ctx.JSON(app.Config.Manifest) diff --git a/auth/auth.go b/auth/auth.go index 83f1259e..2a855fd2 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -1,6 +1,7 @@ package auth import "github.com/aerogo/aero" +import "github.com/animenotifier/notify.moe/utils" // Install ... func Install(app *aero.Application) { @@ -10,6 +11,12 @@ func Install(app *aero.Application) { // Logout app.Get("/logout", func(ctx *aero.Context) string { if ctx.HasSession() { + user := utils.GetUser(ctx) + + if user != nil { + authLog.Info("User logged out", user.ID, ctx.RealIP(), user.Email, user.RealName()) + } + ctx.Session().Set("userId", nil) } diff --git a/auth/google.go b/auth/google.go index 8110020a..d23048b5 100644 --- a/auth/google.go +++ b/auth/google.go @@ -8,6 +8,7 @@ import ( "github.com/aerogo/aero" "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/utils" "golang.org/x/oauth2" "golang.org/x/oauth2/google" ) @@ -88,22 +89,86 @@ func InstallGoogleAuth(app *aero.Application) { return ctx.Error(http.StatusBadRequest, "Failed parsing user data (JSON)", err) } - // Try to find an existing user by the Google user ID - user, getErr := arn.GetUserFromTable("GoogleToUser", googleUser.Sub) + // Is this an existing user connecting another social account? + user := utils.GetUser(ctx) + + if user != nil { + println("Connected") + + // Add GoogleToUser reference + err = user.ConnectGoogle(googleUser.Sub) + + if err != nil { + ctx.Error(http.StatusInternalServerError, "Could not connect account to Google account", err) + } + + return ctx.Redirect("/") + } + + var getErr error + + // Try to find an existing user via the Google user ID + user, getErr = arn.GetUserFromTable("GoogleToUser", googleUser.Sub) if getErr == nil && user != nil { + authLog.Info("User logged in via Google ID", user.ID, user.Nick, ctx.RealIP(), user.Email, user.RealName()) + + user.LastLogin = arn.DateTimeUTC() + user.Save() + session.Set("userId", user.ID) return ctx.Redirect("/") } - // Try to find an existing user by the associated e-mail address + // Try to find an existing user via the associated e-mail address user, getErr = arn.GetUserByEmail(googleUser.Email) if getErr == nil && user != nil { + authLog.Info("User logged in via Email", user.ID, user.Nick, ctx.RealIP(), user.Email, user.RealName()) + + user.LastLogin = arn.DateTimeUTC() + user.Save() + session.Set("userId", user.ID) return ctx.Redirect("/") } - return ctx.Error(http.StatusForbidden, "Account does not exist", getErr) + // Register new user + user = arn.NewUser() + user.Nick = "g" + googleUser.Sub + user.Email = googleUser.Email + user.FirstName = googleUser.GivenName + user.LastName = googleUser.FamilyName + user.Gender = googleUser.Gender + user.LastLogin = arn.DateTimeUTC() + + // Save basic user info already to avoid data inconsistency problems + user.Save() + + // Register user + err = arn.RegisterUser(user) + + if err != nil { + ctx.Error(http.StatusInternalServerError, "Could not register a new user", err) + } + + // Connect account to a Google account + err = user.ConnectGoogle(googleUser.Sub) + + if err != nil { + ctx.Error(http.StatusInternalServerError, "Could not connect account to Google account", err) + } + + // Save user object again with updated data + user.Save() + + // Login + session.Set("userId", user.ID) + + // Log + authLog.Info("Registered new user", user.ID, user.Nick, ctx.RealIP(), user.Email, user.RealName()) + + // Redirect to frontpage + return ctx.Redirect("/") }) } diff --git a/auth/log.go b/auth/log.go new file mode 100644 index 00000000..d758c4e6 --- /dev/null +++ b/auth/log.go @@ -0,0 +1,14 @@ +package auth + +import ( + "os" + + "github.com/aerogo/log" +) + +var authLog = log.New() + +func init() { + authLog.AddOutput(os.Stdout) + authLog.AddOutput(log.File("logs/auth.log")) +} diff --git a/config.json b/config.json index ab0a1d90..c4244c39 100644 --- a/config.json +++ b/config.json @@ -14,17 +14,15 @@ "layout", "navigation", "headers", - "forms", + "input", "grid", - "colors", - "animelist", "forum", - "settings", "user", "video", "loading", "fade", - "mobile" + "mobile", + "extension" ], "scripts": { "main": "main" diff --git a/jobs/active-users/main.go b/jobs/active-users/main.go index 9e55d7c3..8c520285 100644 --- a/jobs/active-users/main.go +++ b/jobs/active-users/main.go @@ -24,7 +24,15 @@ func main() { // Sort sort.Slice(users, func(i, j int) bool { - return users[i].Registered < users[j].Registered + if users[i].LastSeen < users[j].LastSeen { + return false + } + + if users[i].LastSeen > users[j].LastSeen { + return true + } + + return users[i].Registered > users[j].Registered }) // Add users to list diff --git a/jobs/popular-anime/main.go b/jobs/popular-anime/main.go index 05eb5f55..c90015f2 100644 --- a/jobs/popular-anime/main.go +++ b/jobs/popular-anime/main.go @@ -7,7 +7,6 @@ import ( "github.com/fatih/color" ) - const maxPopularAnime = 10 // Note this is using the airing-anime as a template with modfications @@ -32,7 +31,7 @@ func main() { sort.Slice(animeList, func(i, j int) bool { return animeList[i].Rating.Overall > animeList[j].Rating.Overall }) - + // Change size of anime list to 10 animeList = animeList[:maxPopularAnime] diff --git a/jobs/search-index/main.go b/jobs/search-index/main.go index 214d4a2e..aa98d388 100644 --- a/jobs/search-index/main.go +++ b/jobs/search-index/main.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/aerogo/aero" + "github.com/aerogo/flow" "github.com/animenotifier/arn" "github.com/fatih/color" ) @@ -12,7 +12,7 @@ import ( func main() { color.Yellow("Updating search index") - aero.Parallel(updateAnimeIndex, updateUserIndex) + flow.Parallel(updateAnimeIndex, updateUserIndex) color.Green("Finished.") } diff --git a/layout/layout.go b/layout/layout.go index 9e617c67..b963046f 100644 --- a/layout/layout.go +++ b/layout/layout.go @@ -9,5 +9,5 @@ import ( // Render layout. func Render(ctx *aero.Context, content string) string { user := utils.GetUser(ctx) - return components.Layout(ctx.App, user, content) + return components.Layout(ctx.App, ctx, user, content) } diff --git a/layout/layout.pixy b/layout/layout.pixy index 00629b93..86007cdb 100644 --- a/layout/layout.pixy +++ b/layout/layout.pixy @@ -1,4 +1,4 @@ -component Layout(app *aero.Application, user *arn.User, content string) +component Layout(app *aero.Application, ctx *aero.Context, user *arn.User, content string) html(lang="en") head title= app.Config.Title @@ -6,7 +6,7 @@ component Layout(app *aero.Application, user *arn.User, content string) meta(name="theme-color", content=app.Config.Manifest.ThemeColor) link(rel="manifest", href="/manifest.json") body - #container + #container(class=utils.GetContainerClass(ctx)) #header Navigation(user) #content-container diff --git a/main.go b/main.go index 4e277623..16c31cf2 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,7 @@ import ( "github.com/animenotifier/notify.moe/pages/animelist" "github.com/animenotifier/notify.moe/pages/animelistitem" "github.com/animenotifier/notify.moe/pages/dashboard" + "github.com/animenotifier/notify.moe/pages/embed" "github.com/animenotifier/notify.moe/pages/forum" "github.com/animenotifier/notify.moe/pages/forums" "github.com/animenotifier/notify.moe/pages/login" @@ -26,7 +27,6 @@ import ( "github.com/animenotifier/notify.moe/pages/user" "github.com/animenotifier/notify.moe/pages/users" "github.com/animenotifier/notify.moe/pages/webdev" - "github.com/animenotifier/notify.moe/utils" ) var app = aero.New() @@ -71,19 +71,21 @@ func configure(app *aero.Application) *aero.Application { app.Ajax("/login", login.Get) app.Ajax("/airing", airing.Get) app.Ajax("/webdev", webdev.Get) + app.Ajax("/extension/embed", embed.Get) // app.Ajax("/genres", genres.Get) // app.Ajax("/genres/:name", genre.Get) // Middleware app.Use(middleware.Log()) app.Use(middleware.Session()) + app.Use(middleware.UserInfo()) // API api := api.New("/api/", arn.DB) api.Install(app) // Domain - if utils.IsDevelopment() { + if arn.IsDevelopment() { app.Config.Domain = "beta.notify.moe" } diff --git a/middleware/IPToHost.go b/middleware/IPToHost.go new file mode 100644 index 00000000..e4273465 --- /dev/null +++ b/middleware/IPToHost.go @@ -0,0 +1,43 @@ +package middleware + +import ( + "net" + "time" + + cache "github.com/patrickmn/go-cache" +) + +var ipToHosts = cache.New(60*time.Minute, 30*time.Minute) + +// GetHostsForIP returns all host names for the given IP (if cached). +func GetHostsForIP(ip string) []string { + hosts, found := ipToHosts.Get(ip) + + if !found { + hosts = findHostsForIP(ip) + } + + if hosts == nil { + return nil + } + + return hosts.([]string) +} + +// Finds all host names for the given IP +func findHostsForIP(ip string) []string { + hosts, err := net.LookupAddr(ip) + + if err != nil { + return nil + } + + if len(hosts) == 0 { + return nil + } + + // Cache host names + ipToHosts.Set(ip, hosts, cache.DefaultExpiration) + + return hosts +} diff --git a/middleware/Log.go b/middleware/Log.go index 8be4d67c..1a9f9472 100644 --- a/middleware/Log.go +++ b/middleware/Log.go @@ -9,40 +9,66 @@ import ( "github.com/aerogo/aero" "github.com/aerogo/log" + "github.com/animenotifier/notify.moe/utils" ) +var request = log.New() +var err = log.New() + +// Initialize log files +func init() { + request.AddOutput(log.File("logs/request.log")) + + err.AddOutput(log.File("logs/error.log")) + err.AddOutput(os.Stderr) +} + // Log middleware logs every request into logs/request.log and errors into logs/error.log. func Log() aero.Middleware { - request := log.New() - request.AddOutput(log.File("logs/request.log")) - - err := log.New() - err.AddOutput(log.File("logs/error.log")) - err.AddOutput(os.Stderr) - return func(ctx *aero.Context, next func()) { start := time.Now() next() responseTime := time.Since(start) - responseTimeString := strconv.Itoa(int(responseTime.Nanoseconds()/1000000)) + " ms" - responseTimeString = strings.Repeat(" ", 8-len(responseTimeString)) + responseTimeString - // Log every request - request.Info(ctx.RealIP(), ctx.StatusCode, responseTimeString, ctx.URI()) - - // Log all requests that failed - switch ctx.StatusCode { - case http.StatusOK, http.StatusFound, http.StatusMovedPermanently, http.StatusPermanentRedirect, http.StatusTemporaryRedirect: - // Ok. - - default: - err.Error(http.StatusText(ctx.StatusCode), ctx.RealIP(), ctx.StatusCode, responseTimeString, ctx.URI()) - } - - // Notify us about long requests. - // However ignore requests under /auth/ because those depend on 3rd party servers. - if responseTime >= 200*time.Millisecond && !strings.HasPrefix(ctx.URI(), "/auth/") { - err.Error("Long response time", ctx.RealIP(), ctx.StatusCode, responseTimeString, ctx.URI()) - } + go logRequest(ctx, responseTime) + } +} + +// Logs a single request +func logRequest(ctx *aero.Context, responseTime time.Duration) { + responseTimeString := strconv.Itoa(int(responseTime.Nanoseconds()/1000000)) + " ms" + responseTimeString = strings.Repeat(" ", 8-len(responseTimeString)) + responseTimeString + + user := utils.GetUser(ctx) + ip := ctx.RealIP() + + hostName := "" + hostNames := GetHostsForIP(ip) + + if len(hostNames) != 0 { + hostName = hostNames[0] + hostName = strings.TrimSuffix(hostName, ".") + } + + // Log every request + if user != nil { + request.Info(user.Nick, ip, hostName, responseTimeString, ctx.StatusCode, ctx.URI()) + } else { + request.Info("[guest]", ip, hostName, responseTimeString, ctx.StatusCode, ctx.URI()) + } + + // Log all requests that failed + switch ctx.StatusCode { + case http.StatusOK, http.StatusFound, http.StatusMovedPermanently, http.StatusPermanentRedirect, http.StatusTemporaryRedirect: + // Ok. + + default: + err.Error(http.StatusText(ctx.StatusCode), ip, hostName, responseTimeString, ctx.StatusCode, ctx.URI()) + } + + // Notify us about long requests. + // However ignore requests under /auth/ because those depend on 3rd party servers. + if responseTime >= 200*time.Millisecond && !strings.HasPrefix(ctx.URI(), "/auth/") { + err.Error("Long response time", ip, hostName, responseTimeString, ctx.StatusCode, ctx.URI()) } } diff --git a/middleware/UserInfo.go b/middleware/UserInfo.go new file mode 100644 index 00000000..96aecab1 --- /dev/null +++ b/middleware/UserInfo.go @@ -0,0 +1,114 @@ +package middleware + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "strconv" + "strings" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/utils" + "github.com/fatih/color" + "github.com/mssola/user_agent" + "github.com/parnurzeal/gorequest" +) + +var apiKeys arn.APIKeys + +func init() { + data, _ := ioutil.ReadFile("security/api-keys.json") + err := json.Unmarshal(data, &apiKeys) + + if err != nil { + panic(err) + } +} + +// UserInfo updates user related information after each request. +func UserInfo() aero.Middleware { + return func(ctx *aero.Context, next func()) { + next() + + // Ignore non-HTML requests + if ctx.IsMediaResponse() { + return + } + + // Ignore API requests + if strings.HasPrefix(ctx.URI(), "/api/") { + return + } + + user := utils.GetUser(ctx) + + // When there's no user logged in, nothing to update + if user == nil { + return + } + + // This works asynchronously so it doesn't block the response + go updateUserInfo(ctx, user) + } +} + +// Update browser and OS data +func updateUserInfo(ctx *aero.Context, user *arn.User) { + newIP := ctx.RealIP() + newUserAgent := ctx.UserAgent() + + if user.UserAgent != newUserAgent { + user.UserAgent = newUserAgent + + // Parse user agent + parsed := user_agent.New(user.UserAgent) + + // Browser + user.Browser.Name, user.Browser.Version = parsed.Browser() + + // OS + os := parsed.OSInfo() + user.OS.Name = os.Name + user.OS.Version = os.Version + } + + if user.IP != newIP { + updateUserLocation(user, newIP) + } + + user.LastSeen = arn.DateTimeUTC() + user.Save() +} + +// Updates the location of the user. +func updateUserLocation(user *arn.User, newIP string) { + user.IP = newIP + locationAPI := "https://api.ipinfodb.com/v3/ip-city/?key=" + apiKeys.IPInfoDB.ID + "&ip=" + user.IP + "&format=json" + + response, data, err := gorequest.New().Get(locationAPI).EndBytes() + + if len(err) > 0 && err[0] != nil { + color.Red("Couldn't fetch location data | Error: %s | IP: %s", err[0].Error(), user.IP) + return + } + + if response.StatusCode != http.StatusOK { + color.Red("Couldn't fetch location data | Status: %d | IP: %s", response.StatusCode, user.IP) + return + } + + newLocation := arn.IPInfoDBLocation{} + json.Unmarshal(data, &newLocation) + + if newLocation.CountryName != "-" { + user.Location.CountryName = newLocation.CountryName + user.Location.CountryCode = newLocation.CountryCode + user.Location.Latitude, _ = strconv.ParseFloat(newLocation.Latitude, 64) + user.Location.Longitude, _ = strconv.ParseFloat(newLocation.Longitude, 64) + user.Location.CityName = newLocation.CityName + user.Location.RegionName = newLocation.RegionName + user.Location.TimeZone = newLocation.TimeZone + user.Location.ZipCode = newLocation.ZipCode + } +} diff --git a/mixins/AnimeGrid.pixy b/mixins/AnimeGrid.pixy index 1581023c..43537328 100644 --- a/mixins/AnimeGrid.pixy +++ b/mixins/AnimeGrid.pixy @@ -2,4 +2,4 @@ component AnimeGrid(animeList []*arn.Anime) .anime-grid each anime in animeList a.anime-grid-cell.ajax(href="/anime/" + toString(anime.ID)) - img.anime-grid-image(src=anime.Image.Small, alt=anime.Title.Romaji, title=anime.Title.Romaji + " (" + toString(anime.Rating.Overall) + ")") \ No newline at end of file + img.anime-grid-image.lazy(data-src=anime.Image.Small, alt=anime.Title.Romaji, title=anime.Title.Romaji + " (" + toString(anime.Rating.Overall) + ")") \ No newline at end of file diff --git a/mixins/Avatar.pixy b/mixins/Avatar.pixy index c95f96f4..b3f9a103 100644 --- a/mixins/Avatar.pixy +++ b/mixins/Avatar.pixy @@ -4,7 +4,7 @@ component Avatar(user *arn.User) component AvatarNoLink(user *arn.User) if user.HasAvatar() - img.user-image(src=user.SmallAvatar(), alt=user.Nick) + img.user-image.lazy(data-src=user.SmallAvatar(), alt=user.Nick) else SVGAvatar diff --git a/mixins/Input.pixy b/mixins/Input.pixy index 7da14926..b5aa3884 100644 --- a/mixins/Input.pixy +++ b/mixins/Input.pixy @@ -1,14 +1,19 @@ component InputText(id string, value string, label string, placeholder string) .widget-input label(for=id)= label + ":" - input.widget-element.action(id=id, type="text", value=value, placeholder=placeholder, data-action="save", data-trigger="change") + input.widget-element.action(id=id, type="text", value=value, placeholder=placeholder, title=placeholder, data-action="save", data-trigger="change") component InputTextArea(id string, value string, label string, placeholder string) .widget-input label(for=id)= label + ":" - textarea.widget-element.action(id=id, placeholder=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) .widget-input label(for=id)= label + ":" - input.widget-element.action(id=id, type="number", value=value, min=min, max=max, placeholder=placeholder, data-action="save", data-trigger="change") \ No newline at end of file + input.widget-element.action(id=id, type="number", value=value, min=min, max=max, placeholder=placeholder, title=placeholder, data-action="save", data-trigger="change") + +component InputSelection(id string, value string, label string, placeholder string) + .widget-input + label(for=id)= label + ":" + select.widget-element.action(id=id, value=value, title=placeholder, data-action="save", data-trigger="change") diff --git a/mixins/Navigation.pixy b/mixins/Navigation.pixy index ab394798..0a6a55df 100644 --- a/mixins/Navigation.pixy +++ b/mixins/Navigation.pixy @@ -39,7 +39,7 @@ component LoggedInMenu(user *arn.User) NavigationButtonNoAJAX("Logout", "/logout", "sign-out") component FuzzySearch - input#search.action(data-action="search", data-trigger="input", type="text", placeholder="Search...", title="Shortcut: Ctrl + Q") + input#search.action(data-action="search", data-trigger="input", type="text", placeholder="Search...", title="Shortcut: F") component NavigationButton(name string, target string, icon string) a.navigation-link.ajax(href=target, aria-label=name, title=name) diff --git a/pages/anime/anime.pixy b/pages/anime/anime.pixy index d97034f1..2b770f46 100644 --- a/pages/anime/anime.pixy +++ b/pages/anime/anime.pixy @@ -159,8 +159,11 @@ component Anime(anime *arn.Anime, user *arn.User) //- if providers.AnimePlanet //- a.light-button(href="http://www.anime-planet.com/anime/" + providers.AnimePlanet.providerId, target="_blank") AnimePlanet - .sources - p Powered by Kitsu. + .footer + //- if user != nil && user.Role == "admin" + //- a(href="/api/anime/" + anime.ID) Anime API + //- span | + span Powered by Kitsu. //- if descriptionSource //- span= " Summary by " + summarySource + "." //- //- diff --git a/pages/anime/anime.scarlet b/pages/anime/anime.scarlet index 15721b7f..b1eb03fb 100644 --- a/pages/anime/anime.scarlet +++ b/pages/anime/anime.scarlet @@ -87,9 +87,9 @@ .anime-rating-categories vertical -.sources +.footer font-size 0.8rem - opacity 0.5 + opacity 0.7 margin-top 0.5rem .relations diff --git a/pages/animelist/animelist.go b/pages/animelist/animelist.go index 0982543f..d06f7227 100644 --- a/pages/animelist/animelist.go +++ b/pages/animelist/animelist.go @@ -21,7 +21,7 @@ func Get(ctx *aero.Context) string { animeList := viewUser.AnimeList() if animeList == nil { - return ctx.Error(http.StatusNotFound, "Anime list not found", err) + return ctx.Error(http.StatusNotFound, "Anime list not found", nil) } sort.Slice(animeList.Items, func(i, j int) bool { diff --git a/pages/animelist/animelist.pixy b/pages/animelist/animelist.pixy index da17b5c8..89092062 100644 --- a/pages/animelist/animelist.pixy +++ b/pages/animelist/animelist.pixy @@ -10,5 +10,8 @@ component AnimeList(animeList *arn.AnimeList) tr.anime-list-item.mountable(title=item.Notes) td.anime-list-item-name a.ajax(href=item.Anime().Link())= item.Anime().Title.Canonical - td.anime-list-item-episodes= toString(item.Episodes) + " / " + item.Anime().EpisodeCountString() + td.anime-list-item-episodes + span.anime-list-item-episodes-watched= item.Episodes + span.anime-list-item-episodes-separator / + span.anime-list-item-episodes-max= item.Anime().EpisodeCountString() td.anime-list-item-rating= item.FinalRating() \ No newline at end of file diff --git a/pages/animelist/animelist.scarlet b/pages/animelist/animelist.scarlet index c67e96dc..e943c080 100644 --- a/pages/animelist/animelist.scarlet +++ b/pages/animelist/animelist.scarlet @@ -7,6 +7,9 @@ .anime-list-item-name flex 0.8 + white-space nowrap + text-overflow ellipsis + overflow hidden .anime-list-item-episodes flex 0.1 diff --git a/pages/embed/embed.go b/pages/embed/embed.go new file mode 100644 index 00000000..8c486ce2 --- /dev/null +++ b/pages/embed/embed.go @@ -0,0 +1,31 @@ +package embed + +import ( + "net/http" + "sort" + + "github.com/aerogo/aero" + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" +) + +// Get anime list in the browser extension. +func Get(ctx *aero.Context) string { + user := utils.GetUser(ctx) + + if user == nil { + return ctx.Error(http.StatusUnauthorized, "Not logged in", nil) + } + + animeList := user.AnimeList() + + if animeList == nil { + return ctx.Error(http.StatusNotFound, "Anime list not found", nil) + } + + sort.Slice(animeList.Items, func(i, j int) bool { + return animeList.Items[i].FinalRating() < animeList.Items[j].FinalRating() + }) + + return utils.AllowEmbed(ctx, ctx.HTML(components.AnimeList(animeList))) +} diff --git a/pages/forum/forum.pixy b/pages/forum/forum.pixy index 43800d10..dd4f9ee1 100644 --- a/pages/forum/forum.pixy +++ b/pages/forum/forum.pixy @@ -11,5 +11,8 @@ component Forum(tag string, threads []*arn.Thread, threadsPerPage int) span Load more component ThreadList(threads []*arn.Thread) - each thread in threads - ThreadLink(thread) \ No newline at end of file + if len(threads) == 0 + p No threads found. + else + each thread in threads + ThreadLink(thread) \ No newline at end of file diff --git a/pages/popularanime/old/popular.pixy b/pages/popularanime/old/popular.pixy new file mode 100644 index 00000000..4d650c83 --- /dev/null +++ b/pages/popularanime/old/popular.pixy @@ -0,0 +1,16 @@ +component OldPopularAnime(popularAnime []*arn.Anime, titleCount int, animeCount int) + //- h2 Anime + + //- #search-container + //- input#search(type="text", placeholder="Search...", onkeyup="$.searchAnime();", onfocus="this.select();", disabled="disabled", data-count=titleCount, data-anime-count=animeCount) + + //- #search-results-container + //- #search-results + + //- if popularAnime != nil + //- h3.popular-title Popular + + //- .popular-anime-list + //- each anime in popularAnime + //- a.popular-anime.ajax(href="/anime/" + toString(anime.ID), title=anime.Title.Romaji + " (" + arn.Plural(anime.Watching(), "user") + " watching)") + //- img.anime-image.popular-anime-image(src=anime.Image, alt=anime.Title.Romaji) \ No newline at end of file diff --git a/pages/popularanime/old/popular.scarlet b/pages/popularanime/old/popular.scarlet new file mode 100644 index 00000000..6ee9f2dd --- /dev/null +++ b/pages/popularanime/old/popular.scarlet @@ -0,0 +1,19 @@ +// .popular-title +// text-align center + +// .popular-anime-list +// display flex +// flex-flow row wrap +// justify-content center + +// .popular-anime +// padding 0.5em +// display block + +// .popular-anime-image +// width 100px !important +// height 141px !important +// border-radius 3px +// object-fit cover +// default-transition +// shadow-up \ No newline at end of file diff --git a/pages/popularanime/popular.go b/pages/popularanime/popular.go index 0d35d971..c7cd7e22 100644 --- a/pages/popularanime/popular.go +++ b/pages/popularanime/popular.go @@ -1,6 +1,8 @@ package popularanime import ( + "net/http" + "github.com/aerogo/aero" "github.com/animenotifier/arn" "github.com/animenotifier/notify.moe/components" @@ -8,29 +10,10 @@ import ( // Get search page. func Get(ctx *aero.Context) string { - // titleCount := 0 - // animeCount := 0 - - // // let info: any = await bluebird.props({ - // // popular: arn.db.get('Cache', 'popularAnime'), - // // stats: arn.db.get('Cache', 'animeStats') - // // }) - - // // return response.render({ - // // user, - // // popularAnime: info.popular.anime, - // // animeCount: info.stats.animeCount, - // // titleCount: info.stats.titleCount, - // // anime: null - // // }) - - // popular, _ := arn.GetPopularCache() - - // return ctx.HTML(components.Search(popular.Anime, titleCount, animeCount)) animeList, err := arn.GetPopularAnimeCached() if err != nil { - return ctx.HTML("There was a problem listing anime!") + return ctx.Error(http.StatusInternalServerError, "Error fetching popular anime", err) } return ctx.HTML(components.AnimeGrid(animeList)) diff --git a/pages/popularanime/popular.pixy b/pages/popularanime/popular.pixy deleted file mode 100644 index dc922971..00000000 --- a/pages/popularanime/popular.pixy +++ /dev/null @@ -1,16 +0,0 @@ -component Search(popularAnime []*arn.Anime, titleCount int, animeCount int) - h2 Anime - - #search-container - input#search(type="text", placeholder="Search...", onkeyup="$.searchAnime();", onfocus="this.select();", disabled="disabled", data-count=titleCount, data-anime-count=animeCount) - - #search-results-container - #search-results - - if popularAnime != nil - h3.popular-title Popular - - .popular-anime-list - each anime in popularAnime - a.popular-anime.ajax(href="/anime/" + toString(anime.ID), title=anime.Title.Romaji + " (" + arn.Plural(anime.Watching(), "user") + " watching)") - img.anime-image.popular-anime-image(src=anime.Image, alt=anime.Title.Romaji) \ No newline at end of file diff --git a/pages/popularanime/popular.scarlet b/pages/popularanime/popular.scarlet deleted file mode 100644 index fa3aa153..00000000 --- a/pages/popularanime/popular.scarlet +++ /dev/null @@ -1,19 +0,0 @@ -.popular-title - text-align center - -.popular-anime-list - display flex - flex-flow row wrap - justify-content center - -.popular-anime - padding 0.5em - display block - -.popular-anime-image - width 100px !important - height 141px !important - border-radius 3px - object-fit cover - default-transition - shadow-up \ No newline at end of file diff --git a/pages/profile/profile.go b/pages/profile/profile.go index 9e660b42..c146f2ac 100644 --- a/pages/profile/profile.go +++ b/pages/profile/profile.go @@ -2,6 +2,7 @@ package profile import ( "github.com/aerogo/aero" + "github.com/aerogo/flow" "github.com/animenotifier/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" @@ -28,7 +29,7 @@ func Profile(ctx *aero.Context, viewUser *arn.User) string { var animeList *arn.AnimeList var posts []*arn.Post - aero.Parallel(func() { + flow.Parallel(func() { user = utils.GetUser(ctx) }, func() { animeList = viewUser.AnimeList() diff --git a/pages/profile/profile.pixy b/pages/profile/profile.pixy index aa87fc5a..a7abc4fe 100644 --- a/pages/profile/profile.pixy +++ b/pages/profile/profile.pixy @@ -57,7 +57,7 @@ component Profile(viewUser *arn.User, user *arn.User, animeList *arn.AnimeList, else each item in animeList.Items a.profile-watching-list-item.ajax(href=item.Anime().Link(), title=item.Anime().Title.Canonical + " (" + toString(item.Episodes) + " / " + arn.EpisodesToString(item.Anime().EpisodeCount) + ")") - img.anime-cover-image.profile-watching-list-item-image(src=item.Anime().Image.Tiny, alt=item.Anime().Title.Canonical) + img.anime-cover-image.profile-watching-list-item-image.lazy(data-src=item.Anime().Image.Tiny, alt=item.Anime().Title.Canonical) .profile-category.mountable h3 @@ -79,7 +79,11 @@ component Profile(viewUser *arn.User, user *arn.User, animeList *arn.AnimeList, .post-author Avatar(post.Author()) .post-content - .mountable!= aero.Markdown(post.Text) + div!= aero.Markdown(post.Text) .post-toolbar.active .spacer .post-likes= len(post.Likes) + + //- if user != nil && user.Role == "admin" + //- .footer + //- a(href="/api/user/" + viewUser.ID) User API \ No newline at end of file diff --git a/pages/profile/watching.scarlet b/pages/profile/watching.scarlet index 29699c9d..6b3b9b13 100644 --- a/pages/profile/watching.scarlet +++ b/pages/profile/watching.scarlet @@ -6,4 +6,8 @@ .profile-watching-list-item-image width 55px !important - border-radius 2px \ No newline at end of file + border-radius 2px + +< 380px + .profile-watching-list + justify-content center \ No newline at end of file diff --git a/pages/search/search.go b/pages/search/search.go index 34e6a19f..a7dd0516 100644 --- a/pages/search/search.go +++ b/pages/search/search.go @@ -14,5 +14,5 @@ func Get(ctx *aero.Context) string { term := ctx.Get("term") userResults, animeResults := arn.Search(term, maxUsers, maxAnime) - return ctx.HTML(components.Search(userResults, animeResults)) + return ctx.HTML(components.SearchResults(userResults, animeResults)) } diff --git a/pages/search/search.pixy b/pages/search/search.pixy index 312ded75..6e12b652 100644 --- a/pages/search/search.pixy +++ b/pages/search/search.pixy @@ -1,4 +1,4 @@ -component Search(users []*arn.User, animeResults []*arn.Anime) +component SearchResults(users []*arn.User, animeResults []*arn.Anime) .widgets .widget h3 Users diff --git a/pages/settings/settings.go b/pages/settings/settings.go index 978866bb..761cc9a5 100644 --- a/pages/settings/settings.go +++ b/pages/settings/settings.go @@ -16,5 +16,5 @@ func Get(ctx *aero.Context) string { return ctx.Error(http.StatusForbidden, "Not logged in", nil) } - return ctx.HTML(components.Settings(user)) + return utils.AllowEmbed(ctx, ctx.HTML(components.Settings(user))) } diff --git a/pages/settings/settings.pixy b/pages/settings/settings.pixy index 3cd68590..d00e2e1c 100644 --- a/pages/settings/settings.pixy +++ b/pages/settings/settings.pixy @@ -1,7 +1,7 @@ component Settings(user *arn.User) h2.page-title Settings - .widgets(data-api="/api/user/" + user.ID) - .widget.mountable + .widgets + .widget.mountable(data-api="/api/user/" + user.ID) h3.widget-title Icon("user") span Personal @@ -10,11 +10,19 @@ component Settings(user *arn.User) InputText("Tagline", user.Tagline, "Tagline", "Text that appears below your username") InputText("Website", user.Website, "Website", "Your homepage") - .widget.mountable + .widget.mountable(data-api="/api/user/" + user.ID) h3.widget-title Icon("cubes") span Accounts InputText("Accounts.AniList.Nick", user.Accounts.AniList.Nick, "AniList", "Your username on anilist.co") InputText("Accounts.MyAnimeList.Nick", user.Accounts.MyAnimeList.Nick, "MyAnimeList", "Your username on myanimelist.net") - InputText("Accounts.Kitsu.Nick", user.Accounts.Kitsu.Nick, "Kitsu", "Your username on kitsu.io") \ No newline at end of file + InputText("Accounts.Kitsu.Nick", user.Accounts.Kitsu.Nick, "Kitsu", "Your username on kitsu.io") + InputText("Accounts.AnimePlanet.Nick", user.Accounts.AnimePlanet.Nick, "AnimePlanet", "Your username on anime-planet.com") + + //- .widget.mountable(data-api="/api/settings/" + user.ID) + //- h3.widget-title + //- Icon("cogs") + //- span Settings + + //- InputText("TitleLanguage", user.Settings().TitleLanguage, "Title language", "Language of anime titles") \ No newline at end of file diff --git a/patches/add-last-seen/main.go b/patches/add-last-seen/main.go new file mode 100644 index 00000000..ca8fcc3c --- /dev/null +++ b/patches/add-last-seen/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "github.com/animenotifier/arn" +) + +func main() { + // Get a stream of all users + allUsers, err := arn.AllUsers() + + if err != nil { + panic(err) + } + + // Iterate over the stream + for user := range allUsers { + if user.LastSeen != "" { + continue + } + + user.LastSeen = user.LastLogin + + if user.LastSeen == "" { + user.LastSeen = user.Registered + } + + user.Save() + } +} diff --git a/patches/user-references/main.go b/patches/user-references/main.go index 0dbc4a51..46ad2777 100644 --- a/patches/user-references/main.go +++ b/patches/user-references/main.go @@ -25,7 +25,7 @@ func main() { count++ println(count, user.Nick) - user.SetNick(user.Nick) + user.ForceSetNick(user.Nick) if user.Email != "" { user.SetEmail(user.Email) diff --git a/scripts/AnimeNotifier.ts b/scripts/AnimeNotifier.ts index 61622040..d705e594 100644 --- a/scripts/AnimeNotifier.ts +++ b/scripts/AnimeNotifier.ts @@ -5,9 +5,34 @@ import * as actions from "./actions" export class AnimeNotifier { app: Application + visibilityObserver: IntersectionObserver constructor(app: Application) { this.app = app + + if("IntersectionObserver" in window) { + // Enable lazy load + this.visibilityObserver = new IntersectionObserver( + entries => { + for(let entry of entries) { + if(entry.intersectionRatio > 0) { + entry.target["became visible"]() + this.visibilityObserver.unobserve(entry.target) + } + } + }, + {} + ) + } else { + // Disable lazy load feature + this.visibilityObserver = { + disconnect: () => {}, + observe: (elem: HTMLElement) => { + elem["became visible"]() + }, + unobserve: (elem: HTMLElement) => {} + } as IntersectionObserver + } } onReadyStateChange() { @@ -24,12 +49,22 @@ export class AnimeNotifier { this.app.run() } + onContentLoaded() { + this.visibilityObserver.disconnect() + + // Update each of these asynchronously + Promise.resolve().then(() => this.updateMountables()) + Promise.resolve().then(() => this.updateActions()) + Promise.resolve().then(() => this.lazyLoadImages()) + } + reloadContent() { return fetch("/_" + this.app.currentPath, { credentials: "same-origin" }) .then(response => response.text()) .then(html => Diff.innerHTML(this.app.content, html)) + .then(() => this.app.emit("DOMContentLoaded")) } loading(isLoading: boolean) { @@ -42,32 +77,48 @@ export class AnimeNotifier { updateActions() { for(let element of findAll("action")) { + if(element["action assigned"]) { + continue + } + let actionName = element.dataset.action element.addEventListener(element.dataset.trigger, e => { actions[actionName](this, element, e) }) - element.classList.remove("action") + // Use "action assigned" flag instead of removing the class. + // This will make sure that DOM diffs which restore the class name + // will not assign the action multiple times to the same element. + element["action assigned"] = true } } - updateAvatars() { - for(let element of findAll("user-image")) { - let img = element as HTMLImageElement + lazyLoadImages() { + for(let element of findAll("lazy")) { + this.lazyLoadImage(element as HTMLImageElement) + } + } + + lazyLoadImage(img: HTMLImageElement) { + // Once the image becomes visible, load it + img["became visible"] = () => { + img.src = img.dataset.src if(img.naturalWidth === 0) { img.onload = function() { - this.classList.add("user-image-found") + this.classList.add("image-found") } img.onerror = function() { - this.classList.add("user-image-not-found") + this.classList.add("image-not-found") } } else { - img.classList.add("user-image-found") + img.classList.add("image-found") } } + + this.visibilityObserver.observe(img) } updateMountables() { @@ -89,13 +140,6 @@ export class AnimeNotifier { } } - onContentLoaded() { - // Update each of these asynchronously - Promise.resolve().then(() => this.updateMountables()) - Promise.resolve().then(() => this.updateAvatars()) - Promise.resolve().then(() => this.updateActions()) - } - onPopState(e: PopStateEvent) { if(e.state) { this.app.load(e.state, { @@ -109,8 +153,15 @@ export class AnimeNotifier { } onKeyDown(e: KeyboardEvent) { - // Ctrl + Q = Search - if(e.ctrlKey && e.keyCode == 81) { + // Ignore hotkeys on input elements + switch(document.activeElement.tagName) { + case "INPUT": + case "TEXTAREA": + return + } + + // F = Search + if(e.keyCode == 70) { let search = this.app.find("search") as HTMLInputElement search.focus() diff --git a/scripts/Diff.ts b/scripts/Diff.ts index 394d06dd..aa4ab348 100644 --- a/scripts/Diff.ts +++ b/scripts/Diff.ts @@ -25,6 +25,10 @@ export class Diff { } if(a.nodeType === Node.ELEMENT_NODE) { + if(a.tagName === "IFRAME") { + continue + } + let removeAttributes: Attr[] = [] for(let x = 0; x < a.attributes.length; x++) { @@ -48,6 +52,11 @@ export class Diff { a.setAttribute(attrib.name, b.getAttribute(attrib.name)) } } + + // Special case: Apply state of input elements + if(a !== document.activeElement && a instanceof HTMLInputElement && b instanceof HTMLInputElement) { + a.value = b.value + } } Diff.childNodes(a, b) @@ -57,7 +66,7 @@ export class Diff { static innerHTML(aRoot: HTMLElement, html: string) { let bRoot = document.createElement("main") bRoot.innerHTML = html - + Diff.childNodes(aRoot, bRoot) } } \ No newline at end of file diff --git a/scripts/actions.ts b/scripts/actions.ts index c6a8f702..a6e7cb5e 100644 --- a/scripts/actions.ts +++ b/scripts/actions.ts @@ -48,6 +48,8 @@ export function save(arn: AnimeNotifier, input: HTMLInputElement | HTMLTextAreaE .then(() => { arn.loading(false) input.disabled = false + + return arn.reloadContent() }) } diff --git a/styles/extension.scarlet b/styles/extension.scarlet new file mode 100644 index 00000000..50eb9d4e --- /dev/null +++ b/styles/extension.scarlet @@ -0,0 +1,36 @@ +.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 \ No newline at end of file diff --git a/styles/forum.scarlet b/styles/forum.scarlet index e9f64f82..75c239fa 100644 --- a/styles/forum.scarlet +++ b/styles/forum.scarlet @@ -3,9 +3,19 @@ // margin-bottom 1rem .thread-link - horizontal + vertical margin 0.25rem 0 + .post-author + margin-bottom 0.25rem + +> 330px + .thread-link + horizontal + + .post-author + margin-bottom 0 + .post-content ui-element flex-grow 1 diff --git a/styles/grid.scarlet b/styles/grid.scarlet index 6d304d72..caa8ae2b 100644 --- a/styles/grid.scarlet +++ b/styles/grid.scarlet @@ -32,6 +32,7 @@ mixin grid-image height 100% border-radius 3px object-fit cover + default-transition mixin grid-icon font-size 2.5rem diff --git a/styles/images.scarlet b/styles/images.scarlet new file mode 100644 index 00000000..bf3ea806 --- /dev/null +++ b/styles/images.scarlet @@ -0,0 +1,11 @@ +.lazy + visibility hidden + opacity 0 + +.image-found + visibility visible + opacity 1 !important + +.image-not-found + visibility hidden + opacity 0 !important \ No newline at end of file diff --git a/styles/forms.scarlet b/styles/input.scarlet similarity index 88% rename from styles/forms.scarlet rename to styles/input.scarlet index a151dde3..8c272583 100644 --- a/styles/forms.scarlet +++ b/styles/input.scarlet @@ -1,3 +1,10 @@ +mixin input-focus + :focus + color black + border 1px solid main-color + // TODO: Replace with alpha(main-color, 20%) function + box-shadow 0 0 6px rgba(248, 165, 130, 0.2) + input, textarea, button, select font-family inherit font-size 1em @@ -9,16 +16,15 @@ input, textarea border ui-border background white box-shadow none - - :focus - color black - border 1px solid main-color - // TODO: Replace with alpha(main-color, 20%) function - box-shadow 0 0 6px rgba(248, 165, 130, 0.2) + input-focus :disabled ui-disabled +// We need this to have a selector with a higher priority than .widget-element:focus +input.widget-element + input-focus + button, .button ui-element horizontal diff --git a/styles/user.scarlet b/styles/user.scarlet index 21a16f54..5a39867f 100644 --- a/styles/user.scarlet +++ b/styles/user.scarlet @@ -4,13 +4,6 @@ height avatar-size border-radius 100% object-fit cover - opacity 0 default-transition :hover - box-shadow outline-shadow-heavy - -.user-image-found - opacity 1 !important - -.user-image-not-found - opacity 0 !important \ No newline at end of file + box-shadow outline-shadow-heavy \ No newline at end of file diff --git a/tests.go b/tests.go index a63318eb..53b1352e 100644 --- a/tests.go +++ b/tests.go @@ -10,6 +10,10 @@ var tests = map[string][]string{ "/+Akyoto/threads", }, + "/user/:nick/posts": []string{ + "/+Akyoto/posts", + }, + "/user/:nick/animelist": []string{ "/+Akyoto/animelist", }, @@ -56,6 +60,14 @@ var tests = map[string][]string{ "/api/animelist/4J6qpK1ve", }, + "/api/animelist/:id/get/:item": []string{ + "/api/animelist/4J6qpK1ve/get/7929", + }, + + "/api/animelist/:id/get/:item/:property": []string{ + "/api/animelist/4J6qpK1ve/get/7929/Episodes", + }, + "/api/settings/:id": []string{ "/api/settings/4J6qpK1ve", }, diff --git a/utils/allowembed.go b/utils/allowembed.go new file mode 100644 index 00000000..16761b8c --- /dev/null +++ b/utils/allowembed.go @@ -0,0 +1,10 @@ +package utils + +import "github.com/aerogo/aero" + +// AllowEmbed allows the page to be called by the browser extension. +func AllowEmbed(ctx *aero.Context, response string) string { + // This is a bit of a hack. + ctx.SetResponseHeader("X-Frame-Options", "ALLOW-FROM chrome-extension://hjfcooigdelogjmniiahfiilcefdlpha/options.html") + return response +} diff --git a/utils/container.go b/utils/container.go new file mode 100644 index 00000000..33343873 --- /dev/null +++ b/utils/container.go @@ -0,0 +1,15 @@ +package utils + +import ( + "github.com/aerogo/aero" +) + +// GetContainerClass returns the class for the "container" element. +// In the browser extension it will get the "embedded" class. +func GetContainerClass(ctx *aero.Context) string { + if ctx.URI() == "/extension/embed" { + return "embedded" + } + + return "" +} diff --git a/utils/production.go b/utils/production.go deleted file mode 100644 index 334a0991..00000000 --- a/utils/production.go +++ /dev/null @@ -1,17 +0,0 @@ -package utils - -import ( - "os" - "strings" -) - -// IsProduction returns true if the hostname contains "arn". -func IsProduction() bool { - host, _ := os.Hostname() - return strings.Contains(host, "arn") -} - -// IsDevelopment returns true if the hostname does not contain "arn". -func IsDevelopment() bool { - return !IsProduction() -} diff --git a/utils/user.go b/utils/user.go index ff52a7df..3e665f7e 100644 --- a/utils/user.go +++ b/utils/user.go @@ -5,23 +5,7 @@ import ( "github.com/animenotifier/arn" ) -// GetUser ... +// GetUser returns the logged in user for the given context. func GetUser(ctx *aero.Context) *arn.User { - if !ctx.HasSession() { - return nil - } - - userID := ctx.Session().GetString("userId") - - if userID == "" { - return nil - } - - user, err := arn.GetUser(userID) - - if err != nil { - return nil - } - - return user + return arn.GetUserFromContext(ctx) }