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

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,26 +9,53 @@ 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)
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
request.Info(ctx.RealIP(), ctx.StatusCode, responseTimeString, ctx.URI())
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 {
@ -36,13 +63,12 @@ func Log() aero.Middleware {
// Ok.
default:
err.Error(http.StatusText(ctx.StatusCode), ctx.RealIP(), ctx.StatusCode, responseTimeString, ctx.URI())
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", ctx.RealIP(), ctx.StatusCode, responseTimeString, ctx.URI())
}
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)
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

@ -7,3 +7,7 @@
.profile-watching-list-item-image
width 55px !important
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,7 +10,7 @@ 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
@ -18,3 +18,11 @@ component Settings(user *arn.User)
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.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)

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

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