Merge pull request #2 from animenotifier/go

Catching up
This commit is contained in:
Allen Lydiard 2017-06-24 18:35:05 -03:00 committed by GitHub
commit facd70e094
53 changed files with 674 additions and 193 deletions

View File

@ -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)

View File

@ -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)
}

View File

@ -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("/")
})
}

14
auth/log.go Normal file
View File

@ -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"))
}

View File

@ -14,17 +14,15 @@
"layout",
"navigation",
"headers",
"forms",
"input",
"grid",
"colors",
"animelist",
"forum",
"settings",
"user",
"video",
"loading",
"fade",
"mobile"
"mobile",
"extension"
],
"scripts": {
"main": "main"

View File

@ -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

View File

@ -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]

View File

@ -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.")
}

View File

@ -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)
}

View File

@ -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

View File

@ -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"
}

43
middleware/IPToHost.go Normal file
View File

@ -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
}

View File

@ -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 := "<unknown host>"
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())
}
}

114
middleware/UserInfo.go Normal file
View File

@ -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
}
}

View File

@ -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) + ")")
img.anime-grid-image.lazy(data-src=anime.Image.Small, alt=anime.Title.Romaji, title=anime.Title.Romaji + " (" + toString(anime.Rating.Overall) + ")")

View File

@ -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

View File

@ -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")
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")

View File

@ -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)

View File

@ -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 + "."
//- //-

View File

@ -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

View File

@ -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 {

View File

@ -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()

View File

@ -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

31
pages/embed/embed.go Normal file
View File

@ -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)))
}

View File

@ -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)
if len(threads) == 0
p No threads found.
else
each thread in threads
ThreadLink(thread)

View File

@ -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)

View File

@ -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

View File

@ -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))

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -6,4 +6,8 @@
.profile-watching-list-item-image
width 55px !important
border-radius 2px
border-radius 2px
< 380px
.profile-watching-list
justify-content center

View File

@ -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))
}

View File

@ -1,4 +1,4 @@
component Search(users []*arn.User, animeResults []*arn.Anime)
component SearchResults(users []*arn.User, animeResults []*arn.Anime)
.widgets
.widget
h3 Users

View File

@ -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)))
}

View File

@ -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")
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")

View File

@ -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()
}
}

View File

@ -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)

View File

@ -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()

View File

@ -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)
}
}

View File

@ -48,6 +48,8 @@ export function save(arn: AnimeNotifier, input: HTMLInputElement | HTMLTextAreaE
.then(() => {
arn.loading(false)
input.disabled = false
return arn.reloadContent()
})
}

36
styles/extension.scarlet Normal file
View File

@ -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

View File

@ -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

View File

@ -32,6 +32,7 @@ mixin grid-image
height 100%
border-radius 3px
object-fit cover
default-transition
mixin grid-icon
font-size 2.5rem

11
styles/images.scarlet Normal file
View File

@ -0,0 +1,11 @@
.lazy
visibility hidden
opacity 0
.image-found
visibility visible
opacity 1 !important
.image-not-found
visibility hidden
opacity 0 !important

View File

@ -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

View File

@ -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
box-shadow outline-shadow-heavy

View File

@ -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",
},

10
utils/allowembed.go Normal file
View File

@ -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
}

15
utils/container.go Normal file
View File

@ -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 ""
}

View File

@ -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()
}

View File

@ -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)
}