diff --git a/.gitignore b/.gitignore index 901b7b49..4aeada51 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ _testmain.go *.exe *.test *.prof +*.pprof # Output of the go coverage tool, specifically when used with LiteIDE *.out diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..c1bd8cb3 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at e.urbach@gmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..52eaf6bd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,7 @@ +# Contributing + +Please get in contact with the team on the [Anime Notifier Discord](https://discord.gg/0kimAmMCeXGXuzNF). +We're willing to help with installations and how to get started with contributions. +There are no stupid questions so feel free to ask anything if you encounter any troubles. + +If you'd like to install this project locally, take a look at the [Installation](INSTALLATION.md) guide. \ No newline at end of file diff --git a/INSTALLATION.md b/INSTALLATION.md new file mode 100644 index 00000000..07d33897 --- /dev/null +++ b/INSTALLATION.md @@ -0,0 +1,50 @@ +# Installation + +## Prerequisites + +* Install [Ubuntu](https://www.ubuntu.com/) or any of its derivates +* Install [Go](https://golang.org/dl/) (1.9 or higher) +* Install [TypeScript](https://www.typescriptlang.org/) (2.5 or higher) + +## Download the repository and its dependencies + +* `go get github.com/animenotifier/notify.moe` + +## Build all + +* Navigate to the project directory `notify.moe` +* Run `make tools` to install [pack](https://github.com/aerogo/pack) & [run](https://github.com/aerogo/run) +* Run `make ports` to set up local port forwarding *(80 to 4000, 443 to 4001)* +* Run `make all` + +## Hosts + +* Add `127.0.0.1 beta.notify.moe` to `/etc/hosts` + +## HTTPS + +* [Create the certificate](https://stackoverflow.com/questions/10175812/how-to-create-a-self-signed-certificate-with-openssl) `notify.moe/security/fullchain.pem` (domain: `beta.notify.moe`) +* Create the private key `notify.moe/security/privkey.pem` + +## Browser + +* Start Chrome via `google-chrome --ignore-certificate-errors` + +## API keys + +* Get a Google OAuth 2.0 client key & secret from [console.developers.google.com](https://console.developers.google.com) +* Create the file `notify.moe/security/api-keys.json`: + +```json +{ + "google": { + "id": "YOUR_KEY", + "secret": "YOUR_SECRET" + } +} +``` + +## Run + +* Start the web server in notify.moe directory: `run` +* Open `https://beta.notify.moe` which should now resolve to localhost \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..0a9295b9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Eduard Urbach + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e9c91f65..9ae1eb37 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,225 @@ # Anime Notifier -## Info +## What kind of website is this? -notify.moe is powered by the [Aero framework](https://github.com/aerogo/aero) from the same author. The project also uses Go and Aerospike. +An anime tracker where you can add anime to your list and edit your episode progress using either the website, the chrome extension or the mobile app. -## Installation +## Why is it called notify.moe? -### Prerequisites +Because we made a notifier that takes your watching list, checks it against external websites and notifies you when there is a new episode on that external site. It's also a terrible wordplay combining "notify me!" and [moe](https://en.wikipedia.org/wiki/Moe_(slang)). -* Install a Debian based operating system -* Install [Go](https://golang.org/dl/) (1.9 or higher) -* Install [Aerospike](http://www.aerospike.com/download) (3.14.0 or higher) +## So it's just a notifier? -### Download the repository and its dependencies +In the past it was, but not anymore. We're growing bigger by establishing a database that combines information from multiple sources and also growing as a community. Many of us are hanging out on Discord and you are welcome to join us. We also have our own anime lists now due to popular request of adding episode progress changes to our browser extension. -* `go get github.com/animenotifier/notify.moe` +## What does the current feature set look like? -### Install pack & run +* [Chrome extension](https://chrome.google.com/webstore/detail/anime-notifier/hajchfikckiofgilinkpifobdbiajfch) for quick watching list access and episode updates +* Edit episode progress and rating by clicking on the number +* Airing dates +* Offline browsing +* Push notifications +* Soundtracks +* Anime & user search +* Anime rating system +* [twist.moe](https://twist.moe) integration +* [anilist.co](https://anilist.co/), [myanimelist.net](https://myanimelist.net/) and [kitsu.io](https://kitsu.io/) import +* [osu](https://osu.ppy.sh/) ranking view +* [Gravatar](https://gravatar.com) support +* User profiles +* Dashboard +* Forums +* Responsive layout (looks good on 1080p and on mobile devices) -* `go get github.com/aerogo/pack` -* `go get github.com/aerogo/run` -* `go install github.com/aerogo/pack` -* `go install github.com/aerogo/run` +## Can I follow the project on social media? -### Build all +* [Facebook](https://www.facebook.com/animenotifier) +* [Twitter](https://twitter.com/animenotifier) +* [Google+](https://plus.google.com/+AnimeReleaseNotifierOfficial) +* [GitHub](https://github.com/animenotifier/notify.moe) +* [Discord](https://discord.gg/0kimAmMCeXGXuzNF) -* Run `make all` -* Run `make ports` to set up local port forwarding *(80 to 4000, 443 to 4001)* -* You should be able to start the server by executing `run` now +## How do I enable notifications? -### Database +Use a browser that supports push notifications (Chrome or Firefox). Then go to your [settings](https://notify.moe/settings) and click "Enable notifications". This might take a while, especially on mobile devices. After that you can press "Send test notification". If you get a notification saying "Yay, it works!" then everything's fine. The real thing looks like this: -* Remove all namespaces in `/etc/aerospike/aerospike.conf` -* Add a namespace called `arn`: +![Anime Notifications](https://puu.sh/wKpcm/304a4441a0.png) -``` -namespace arn { - storage-engine device { - file /home/YOUR_NAME/YOUR_PATH/notify.moe/db/arn-dev.dat - filesize 64M - data-in-memory true +## How do I use the search? - # Maximum object size. 128K is ideal for SSDs but we need 1M for search indices. - write-block-size 1M +Press the "F" key and start searching for an anime title. - # Write block size x Post write queue = Cache memory usage (for write block buffers) - post-write-queue 1 - } -} -``` +![Anime search](https://puu.sh/wM45s/ffe5025c63.png) -* Download the [database for developers](https://mega.nz/#!iN4WTRxb!R_cRjBbnUUvGeXdtRGiqbZRrnvy0CHc2MjlyiGBxdP4) to notify.moe/db/arn-dev.dat -* Start the database using `sudo service aerospike start` -* Confirm that the status is "green": `sudo service aerospike status` +## How do I add anime to my list? -### Hosts +Once you open the anime page you should see a button called "Add to my collection". Clicking that will add the anime to your "Plan to watch" list. To move it to your current "Watching" list, you need to click "Edit in collection" and change the status to "Watching". -* Add `127.0.0.1 arn-db` to `/etc/hosts` -* Add `127.0.0.1 beta.notify.moe` to `/etc/hosts` +## How do I edit my episode progress? -### HTTPS +There are 2 ways of editing your progress: -* Create the certificate `notify.moe/security/fullchain.pem` (domain: `beta.notify.moe`) -* Create the private key `notify.moe/security/privkey.pem` +1. Click on the "+" button that shows up when you hover over the episode number. This will increase your progress by one episode on each click. +1. Click on the episode number so that a text input cursor shows up. Use backspace/delete keys and enter your new number directly. Press Enter or click somewhere else to confirm. -### API keys +## How do I edit my rating? -* Get a Google OAuth 2.0 client key & secret from [console.developers.google.com](https://console.developers.google.com) -* Create the file `notify.moe/security/api-keys.json`: +Your "Overall" rating can be edited with the same method as episodes by clicking on the number directly so that the text input cursor shows up, then entering a new number and confirming with Enter. The other 3 rating numbers for Story, Visuals and Soundtrack can only be edited by going into edit mode (click on the anime title in your list). -```json -{ - "google": { - "id": "YOUR_KEY", - "secret": "YOUR_SECRET" - } -} -``` +## How does the rating system work? -### Fetch data +You can rate each entry in your anime list in 4 different categories: -* Run `jobs/sync-anime/sync-anime` from this repository to fetch anime data +* Overall (this will determine the sorting order) +* Story (how interesting was the story/plot?) +* Visuals (art & effect & animation quality) +* Soundtrack (music rating) -### Run +Each rating is a number on a scale of 0 to 10. A rating of 0 counts as "not rated" and will be ignored in average rating calculations for that anime. Thus the lowest possible rating you can assign to an anime is 0.1. The highest possible rating is 10. The average is close to the number 5. -* Start the web server in notify.moe directory: `run` -* Open `https://beta.notify.moe` which should now resolve to localhost \ No newline at end of file +## What does the Chrome extension offer me? + +A quick access to your watching list: + +![Anime Notifier Chrome extension](https://puu.sh/wM47V/af25b23755.png) + +## How can I format text and include images in the forum? + +You need to use [Markdown](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet). + +## What is offline mode? + +This website / app is accessible even when you go offline. You can keep browsing the pages you visited earlier which is especially useful for mobile phones or when you're traveling with an unstable connection. Feel free to try it by disabling your WiFi and opening the site while offline. + +## Do I need to keep the site open to receive notifications? + +No, you can close the site and still receive notifications after you enabled them. + +## What are the community rules for conversations on the forum? + +* Be respectful to each other. +* Realize that every person has his or her own opinion and that you should treat that opinion with respect. You do not have to agree with strangers on the internet but it's worth thinking about their viewpoint. +* Do not spam. +* Do not advertise unrelated products. If anything it needs to be related to anime or the site itself. +* We do not mind links to competitors or similar websites. Feel free to post them. + +## How do I import my former anime list? + +We added importers for what we consider to be the 3 most popular list providers: + +* anilist.co +* kitsu.io +* myanimelist.net + +To use an importer, enter your nickname for the site you want to import from and click the "Import" button with the list provider name that just appeared. + +![Anime list import](https://puu.sh/wM4dP/11d43e5f71.png) + +## What does following a person do? + +You will be able to see their progress and ratings on anime pages: + +![Anime pages friends](https://puu.sh/wPfE2/d65ef4f771.png) + +## How do I install the site as an Android app? + +This website uses a modern technology that allows you to install websites as local apps. To install notify.moe as a mobile app, do the following: + +1. Go to https://notify.moe on your Android device. +2. Open the menu by tapping the top right part of your browser. +3. Choose "Add to Home screen" and confirm it. +4. Now you can access your anime list easily from your home screen and get notified about new episodes. + +You need to enable notifications on each device separately. To receive notifications on both desktop and mobile phone you need to click "Enable notifications" on both. + +## How do I install the site as a PC/desktop app? + +In Chrome, open the top right menu and go to **More tools > Add to desktop**. Make sure that "Open as window" is checked. + +![Anime Notifier desktop app](https://puu.sh/wM4pB/542add3113.png) + +## What do I get notified about? + +At the time of writing this, you get notified when: + +* A new episode from your watching list is released on twist.moe +* Somebody replies in a thread you have participated in +* Somebody likes your post +* You get a new follower + +## How do notifications work from a technical perspective? + +There are many, many ways how notifications can be implemented from a technical standpoint. There is e.g. "polling" which means that an app periodically checks external sites and tells you when something new is available. We are not using polling because these periodic checks can quickly drain your battery on a mobile phone. We are using so-called "push notifications" instead. The advantage of push notifications is that your mobile phone or desktop PC doesn't have to do periodic checks anymore - instead the website will send new episode releases to all of your registered devices. This consumes less CPU/network resources and is much more battery friendly for mobile devices. + +## How can I confirm I'm a PRO user now? + +Go to your [settings](https://notify.moe/settings), it should show you the remaining duration for your [PRO](https://notify.moe/shop) account. + +## Is this website well-optimized? + +![Anime Notifier - Lighthouse](https://pbs.twimg.com/media/DEplUsNXgAEF-UT.jpg:large) + +![Anime Notifier - PageSpeed](https://pbs.twimg.com/media/DEplXmpWsAAPzb6.jpg:large) + +## Is this website secure? + +* The site is not storing passwords which means there is no password that could be stolen +* The site uses HTTPS, CSP and CSS hashing to improve overall security +* The site functionality is 99.9% server-sided which is a requirement for any security related app +* The site is using only the most modern and secure SSL ciphers + +## Is this website mobile-friendly? + +Yes, we have a dynamic layout that works on everything from 320p to full HD (1080p). Larger sizes should work well due to automatic layout. On smartphones you can use the sidebar by sliding with your finger to the right side. + +## Which platforms and browsers do you officially support? + +OS: + +* Windows +* Linux +* Mac + +Browsers: + +* Chrome +* Firefox +* Safari + +The most modern browser is [without question](https://html5test.com/compare/browser/chrome-58/firefox-53/safari-10.2.html) Chrome and I highly recommend everyone to switch to Chrome if you're not using it already. Chrome has WebP support which *drastically* reduces page loading times and also lazy loading support which loads images only when they appear in your current viewport, reducing both your bandwidth and your initial loading times. + +Firefox and Safari are supported but I do not recommend using them. See these for more information: + +* [WebP support](http://caniuse.com/#feat=webp) +* [Push notifications](http://caniuse.com/#feat=push-api) +* [Intersection Observer support](http://caniuse.com/#feat=intersectionobserver) (lazy loading) +* [RequestIdleCallback](http://caniuse.com/#feat=requestidlecallback) (defer unimportant requests to idle times) + +## Can you tell me more about the history of this software? + +From a technological standpoint we went through quite a few different approaches: + +* Version 1.0: This version was just a browser extension with **client-side JS**. +* Version 2.0: To decrease the number of requests/pressure on external sites we made a central website. It was written in **PHP**. +* Version 3.0: A complete remake of the website in **node.js** supporting 4 different list providers and 2 anime providers. Episode changes were not possible. +* Version 4.0: We switched to our own hosted anime lists to make episode updates in the extension as smooth as possible. The website is now written in **Go** and uses 3 separate servers/machines (web server, database and the scheduler). + +## How many developers are working on this? + +Since 2014 it's been just me, though I do plan to start a company and hire talented people to help me out with this project once the stars align. + +## Is there an API for this site? + +Yes, the [API](https://notify.moe/api) is an on-going effort and subject to change. + +## Can I show my support for this site? Do you accept donations? + +I recently added [PRO](https://notify.moe/shop) accounts for an extended feature set. You do not have to donate without getting something back, instead I'd rather be happy to see you profit from the donation as well. It would be my dream to work on this full-time. + +## Can I help with coding or change stuff as this is Open Source? + +Sure, the setup to start contributing is not that hard. Try to get in contact with me on Discord. + +## Can I apply to be a data mod / editor? + +Sure, just contact me on Discord if you want to help out with the database. \ No newline at end of file diff --git a/assets.go b/assets.go index 1c312697..f613eb03 100644 --- a/assets.go +++ b/assets.go @@ -1,27 +1,36 @@ package main import ( - "errors" - "net/http" + "io/ioutil" "strings" "github.com/aerogo/aero" - "github.com/animenotifier/arn" "github.com/animenotifier/notify.moe/components/js" ) -func init() { - // Scripts - scripts := js.Bundle() +// configureAssets adds all the routes used for media assets. +func configureAssets(app *aero.Application) { + // Script bundle + scriptBundle := js.Bundle() + + // Service worker + serviceWorkerBytes, err := ioutil.ReadFile("sw/service-worker.js") + serviceWorker := string(serviceWorkerBytes) + + if err != nil { + panic("Couldn't load service worker") + } app.Get("/scripts", func(ctx *aero.Context) string { - ctx.SetResponseHeader("Content-Type", "application/javascript") - return scripts + return ctx.JavaScript(scriptBundle) }) app.Get("/scripts.js", func(ctx *aero.Context) string { - ctx.SetResponseHeader("Content-Type", "application/javascript") - return scripts + return ctx.JavaScript(scriptBundle) + }) + + app.Get("/service-worker", func(ctx *aero.Context) string { + return ctx.JavaScript(serviceWorker) }) // Web manifest @@ -36,8 +45,7 @@ func init() { // Brand icons app.Get("/images/brand/:file", func(ctx *aero.Context) string { - file := strings.TrimSuffix(ctx.Get("file"), ".webp") - return ctx.TryWebP("images/brand/"+file, ".png") + return ctx.File("images/brand/" + ctx.Get("file")) }) // Cover image @@ -53,44 +61,12 @@ func init() { // Avatars app.Get("/images/avatars/large/:file", func(ctx *aero.Context) string { - file := strings.TrimSuffix(ctx.Get("file"), ".webp") - - if ctx.CanUseWebP() { - return ctx.File("images/avatars/large/webp/" + file + ".webp") - } - - original := arn.FindFileWithExtension( - file, - "images/avatars/large/original/", - arn.OriginalImageExtensions, - ) - - if original == "" { - return ctx.Error(http.StatusNotFound, "Avatar not found", errors.New("Image not found: "+file)) - } - - return ctx.File(original) + return ctx.File("images/avatars/large/" + ctx.Get("file")) }) // Avatars app.Get("/images/avatars/small/:file", func(ctx *aero.Context) string { - file := strings.TrimSuffix(ctx.Get("file"), ".webp") - - if ctx.CanUseWebP() { - return ctx.File("images/avatars/small/webp/" + file + ".webp") - } - - original := arn.FindFileWithExtension( - file, - "images/avatars/small/original/", - arn.OriginalImageExtensions, - ) - - if original == "" { - return ctx.Error(http.StatusNotFound, "Avatar not found", errors.New("Image not found: "+file)) - } - - return ctx.File(original) + return ctx.File("images/avatars/small/" + ctx.Get("file")) }) // Elements diff --git a/auth/api-keys.go b/auth/api-keys.go deleted file mode 100644 index fdc7e7fa..00000000 --- a/auth/api-keys.go +++ /dev/null @@ -1,19 +0,0 @@ -package auth - -import ( - "encoding/json" - "io/ioutil" - - "github.com/animenotifier/arn" -) - -var apiKeys arn.APIKeys - -func init() { - data, _ := ioutil.ReadFile("security/api-keys.json") - err := json.Unmarshal(data, &apiKeys) - - if err != nil { - panic(err) - } -} diff --git a/auth/auth.go b/auth/auth.go index 2a855fd2..aed84e2b 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -3,11 +3,16 @@ package auth import "github.com/aerogo/aero" import "github.com/animenotifier/notify.moe/utils" +const newUserStartRoute = "/settings" + // Install ... func Install(app *aero.Application) { // Google InstallGoogleAuth(app) + // Facebook + InstallFacebookAuth(app) + // Logout app.Get("/logout", func(ctx *aero.Context) string { if ctx.HasSession() { diff --git a/auth/facebook.go b/auth/facebook.go new file mode 100644 index 00000000..75fa4b5a --- /dev/null +++ b/auth/facebook.go @@ -0,0 +1,164 @@ +package auth + +import ( + "encoding/json" + "errors" + "io/ioutil" + "net/http" + "strings" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/utils" + "golang.org/x/oauth2" + "golang.org/x/oauth2/facebook" +) + +// FacebookUser is the user data we receive from Facebook +type FacebookUser struct { + ID string `json:"id"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Gender string `json:"gender"` +} + +// InstallFacebookAuth enables Facebook login for the app. +func InstallFacebookAuth(app *aero.Application) { + config := &oauth2.Config{ + ClientID: arn.APIKeys.Facebook.ID, + ClientSecret: arn.APIKeys.Facebook.Secret, + RedirectURL: "https://" + app.Config.Domain + "/auth/facebook/callback", + Scopes: []string{ + "public_profile", + "email", + }, + Endpoint: facebook.Endpoint, + } + + // Auth + app.Get("/auth/facebook", func(ctx *aero.Context) string { + state := ctx.Session().ID() + url := config.AuthCodeURL(state) + ctx.Redirect(url) + return "" + }) + + // Auth Callback + app.Get("/auth/facebook/callback", func(ctx *aero.Context) string { + if !ctx.HasSession() { + return ctx.Error(http.StatusUnauthorized, "Facebook login failed", errors.New("Session does not exist")) + } + + session := ctx.Session() + + if session.ID() != ctx.Query("state") { + return ctx.Error(http.StatusUnauthorized, "Facebook login failed", errors.New("Incorrect state")) + } + + // Handle the exchange code to initiate a transport + token, err := config.Exchange(oauth2.NoContext, ctx.Query("code")) + + if err != nil { + return ctx.Error(http.StatusBadRequest, "Could not obtain OAuth token", err) + } + + // Construct the OAuth client + client := config.Client(oauth2.NoContext, token) + + // Fetch user data from Facebook + resp, err := client.Get("https://graph.facebook.com/me?fields=email,first_name,last_name,gender") + + if err != nil { + return ctx.Error(http.StatusBadRequest, "Failed requesting user data from Facebook", err) + } + + defer resp.Body.Close() + body, _ := ioutil.ReadAll(resp.Body) + + // Construct a FacebookUser object + fbUser := FacebookUser{} + err = json.Unmarshal(body, &fbUser) + + if err != nil { + return ctx.Error(http.StatusBadRequest, "Failed parsing user data (JSON)", err) + } + + // Change googlemail.com to gmail.com + fbUser.Email = strings.Replace(fbUser.Email, "googlemail.com", "gmail.com", 1) + + // Is this an existing user connecting another social account? + user := utils.GetUser(ctx) + + if user != nil { + // Add FacebookToUser reference + user.ConnectFacebook(fbUser.ID) + + // Save in DB + user.Save() + + // Log + authLog.Info("Added Facebook ID to existing account", user.ID, user.Nick, ctx.RealIP(), user.Email, user.RealName()) + + return ctx.Redirect("/") + } + + var getErr error + + // Try to find an existing user via the Facebook user ID + user, getErr = arn.GetUserByFacebookID(fbUser.ID) + + if getErr == nil && user != nil { + authLog.Info("User logged in via Facebook 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 via the associated e-mail address + user, getErr = arn.GetUserByEmail(fbUser.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("/") + } + + // Register new user + user = arn.NewUser() + user.Nick = "fb" + fbUser.ID + user.Email = fbUser.Email + user.FirstName = fbUser.FirstName + user.LastName = fbUser.LastName + user.Gender = fbUser.Gender + user.LastLogin = arn.DateTimeUTC() + + // Save basic user info already to avoid data inconsistency problems + user.Save() + + // Register user + arn.RegisterUser(user) + + // Connect account to a Facebook account + user.ConnectFacebook(fbUser.ID) + + // Save user object again with updated data + user.Save() + + // Login + session.Set("userId", user.ID) + + // Log + authLog.Info("Registered new user via Facebook", user.ID, user.Nick, ctx.RealIP(), user.Email, user.RealName()) + + // Redirect to settings + return ctx.Redirect(newUserStartRoute) + }) +} diff --git a/auth/google.go b/auth/google.go index d23048b5..77321831 100644 --- a/auth/google.go +++ b/auth/google.go @@ -5,6 +5,7 @@ import ( "errors" "io/ioutil" "net/http" + "strings" "github.com/aerogo/aero" "github.com/animenotifier/arn" @@ -29,8 +30,8 @@ type GoogleUser struct { // InstallGoogleAuth enables Google login for the app. func InstallGoogleAuth(app *aero.Application) { config := &oauth2.Config{ - ClientID: apiKeys.Google.ID, - ClientSecret: apiKeys.Google.Secret, + ClientID: arn.APIKeys.Google.ID, + ClientSecret: arn.APIKeys.Google.Secret, RedirectURL: "https://" + app.Config.Domain + "/auth/google/callback", Scopes: []string{ "https://www.googleapis.com/auth/userinfo.email", @@ -43,8 +44,8 @@ func InstallGoogleAuth(app *aero.Application) { // Auth app.Get("/auth/google", func(ctx *aero.Context) string { - sessionID := ctx.Session().ID() - url := config.AuthCodeURL(sessionID) + state := ctx.Session().ID() + url := config.AuthCodeURL(state) ctx.Redirect(url) return "" }) @@ -89,18 +90,25 @@ func InstallGoogleAuth(app *aero.Application) { return ctx.Error(http.StatusBadRequest, "Failed parsing user data (JSON)", err) } + if googleUser.Sub == "" { + return ctx.Error(http.StatusBadRequest, "Failed retrieving Google data", errors.New("Empty ID")) + } + + // Change googlemail.com to gmail.com + googleUser.Email = strings.Replace(googleUser.Email, "googlemail.com", "gmail.com", 1) + // 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) + user.ConnectGoogle(googleUser.Sub) - if err != nil { - ctx.Error(http.StatusInternalServerError, "Could not connect account to Google account", err) - } + // Save in DB + user.Save() + + // Log + authLog.Info("Added Google ID to existing account", user.ID, user.Nick, ctx.RealIP(), user.Email, user.RealName()) return ctx.Redirect("/") } @@ -108,7 +116,7 @@ func InstallGoogleAuth(app *aero.Application) { var getErr error // Try to find an existing user via the Google user ID - user, getErr = arn.GetUserFromTable("GoogleToUser", googleUser.Sub) + user, getErr = arn.GetUserByGoogleID(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()) @@ -146,18 +154,10 @@ func InstallGoogleAuth(app *aero.Application) { user.Save() // Register user - err = arn.RegisterUser(user) - - if err != nil { - ctx.Error(http.StatusInternalServerError, "Could not register a new user", err) - } + arn.RegisterUser(user) // 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) - } + user.ConnectGoogle(googleUser.Sub) // Save user object again with updated data user.Save() @@ -166,9 +166,9 @@ func InstallGoogleAuth(app *aero.Application) { session.Set("userId", user.ID) // Log - authLog.Info("Registered new user", user.ID, user.Nick, ctx.RealIP(), user.Email, user.RealName()) + authLog.Info("Registered new user via Google", user.ID, user.Nick, ctx.RealIP(), user.Email, user.RealName()) // Redirect to frontpage - return ctx.Redirect("/") + return ctx.Redirect(newUserStartRoute) }) } diff --git a/benchmarks/Components_test.go b/benchmarks/Components_test.go index ef68455e..514a1ac7 100644 --- a/benchmarks/Components_test.go +++ b/benchmarks/Components_test.go @@ -7,7 +7,7 @@ import ( "github.com/animenotifier/notify.moe/components" ) -func BenchmarkThread(b *testing.B) { +func BenchmarkRenderThread(b *testing.B) { thread, _ := arn.GetThread("HJgS7c2K") thread.HTML() // Pre-render markdown @@ -25,3 +25,18 @@ func BenchmarkThread(b *testing.B) { } }) } + +func BenchmarkRenderAnimeList(b *testing.B) { + user, _ := arn.GetUser("4J6qpK1ve") + animeList := user.AnimeList() + animeList.PrefetchAnime() + + b.ReportAllocs() + b.ResetTimer() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + components.AnimeList(animeList, user, user) + } + }) +} diff --git a/benchmarks/DB_AnimeList_test.go b/benchmarks/DB_AnimeList_test.go new file mode 100644 index 00000000..d16c0da7 --- /dev/null +++ b/benchmarks/DB_AnimeList_test.go @@ -0,0 +1,36 @@ +package benchmarks + +import ( + "testing" + + "github.com/animenotifier/arn" +) + +func BenchmarkDBAnimeListGetMap(b *testing.B) { + user, _ := arn.GetUser("4J6qpK1ve") + + b.ReportAllocs() + b.ResetTimer() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + animeList, _ := arn.GetAnimeList(user.ID) + noop(animeList) + } + }) +} + +func BenchmarkDBAnimeListGet(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + list, _ := arn.DB.Get("AnimeList", "4J6qpK1ve") + animeList := list.(*arn.AnimeList) + noop(animeList) + } + }) +} + +func noop(list *arn.AnimeList) {} diff --git a/bots/avatars/avatars.go b/bots/avatars/avatars.go new file mode 100644 index 00000000..8f27146a --- /dev/null +++ b/bots/avatars/avatars.go @@ -0,0 +1,67 @@ +package main + +import ( + "encoding/json" + "flag" + "io" + "log" + "net/http" + "os" + "path" + "strings" + + "github.com/animenotifier/arn" + + _ "image/gif" + _ "image/jpeg" + _ "image/png" + + "github.com/animenotifier/avatar/lib" +) + +var port = "8001" + +func init() { + flag.StringVar(&port, "port", "", "Port the HTTP server should listen on") + flag.Parse() +} + +func main() { + // Switch to main directory + exe, err := os.Executable() + + if err != nil { + panic(err) + } + + root := path.Dir(exe) + os.Chdir(path.Join(root, "../../")) + + // Start server + http.HandleFunc("/", onRequest) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} + +// onRequest handles requests and refreshes the requested avatar +func onRequest(w http.ResponseWriter, req *http.Request) { + userID := strings.TrimPrefix(req.URL.Path, "/") + user, err := arn.GetUser(userID) + + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + io.WriteString(w, err.Error()) + return + } + + // Refresh + lib.RefreshAvatar(user) + + // Send JSON response + buffer, err := json.Marshal(user.Avatar) + + if err != nil { + io.WriteString(w, err.Error()) + } + + w.Write(buffer) +} diff --git a/bots/build.sh b/bots/build.sh new file mode 100755 index 00000000..bf4b428d --- /dev/null +++ b/bots/build.sh @@ -0,0 +1,4 @@ +#!/bin/sh +MYDIR="$(dirname "$(realpath "$0")")" +cd "$MYDIR" +for dir in ./*; do ([ -d "$dir" ] && cd "$dir" && echo "Building bots/$dir" && go build); done \ No newline at end of file diff --git a/jobs/discord/main.go b/bots/discord/discord.go similarity index 74% rename from jobs/discord/main.go rename to bots/discord/discord.go index b0885f08..4c9c974a 100644 --- a/jobs/discord/main.go +++ b/bots/discord/discord.go @@ -1,13 +1,9 @@ package main import ( - "encoding/json" - "io/ioutil" "log" "os" "os/signal" - "path" - "path/filepath" "strings" "syscall" @@ -21,30 +17,8 @@ var discord *discordgo.Session func main() { var err error - exe, err := os.Executable() - - if err != nil { - panic(err) - } - - dir := path.Dir(exe) - var apiKeysPath string - apiKeysPath, err = filepath.Abs(dir + "/../../security/api-keys.json") - - if err != nil { - panic(err) - } - - var apiKeys arn.APIKeys - data, _ := ioutil.ReadFile(apiKeysPath) - err = json.Unmarshal(data, &apiKeys) - - if err != nil { - panic(err) - } - discord, _ = discordgo.New() - discord.Token = "Bot " + apiKeys.Discord.Token + discord.Token = "Bot " + arn.APIKeys.Discord.Token // Verify a Token was provided if discord.Token == "" { @@ -93,6 +67,14 @@ func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) { **!tag** [forum tag]`) } + // Has the bot been mentioned? + for _, user := range m.Mentions { + if user.ID == discord.State.User.ID { + s.ChannelMessageSend(m.ChannelID, m.Author.Mention()+" :heart:") + return + } + } + if strings.HasPrefix(m.Content, "!user ") { s.ChannelMessageSend(m.ChannelID, "https://notify.moe/+"+strings.Split(m.Content, " ")[1]) return @@ -113,21 +95,34 @@ func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) { return } + if strings.HasPrefix(m.Content, "!play ") { + s.UpdateStatus(0, strings.Split(m.Content, " ")[1]) + return + } + if strings.HasPrefix(m.Content, "!s ") { term := m.Content[len("!s "):] - userResults, animeResults := arn.Search(term, 10, 10) + users, animes, posts, threads := arn.Search(term, 3, 3, 3, 3) message := "" - for _, user := range userResults { + for _, user := range users { message += "https://notify.moe" + user.Link() + "\n" } - for _, anime := range animeResults { + for _, anime := range animes { message += "https://notify.moe" + anime.Link() + "\n" } - if len(userResults) == 0 && len(animeResults) == 0 { - message = "Sorry, I couldn't find any anime or users with that term." + for _, post := range posts { + message += "https://notify.moe" + post.Link() + "\n" + } + + for _, thread := range threads { + message += "https://notify.moe" + thread.Link() + "\n" + } + + if len(users) == 0 && len(animes) == 0 && len(posts) == 0 && len(threads) == 0 { + message = "Sorry, I couldn't find anything using that term." } s.ChannelMessageSend(m.ChannelID, message) diff --git a/config.json b/config.json index b4ecbbf9..ff43a316 100644 --- a/config.json +++ b/config.json @@ -6,6 +6,7 @@ ], "styles": [ "include/config", + "include/dark", "include/mixins", "reset", "base", @@ -17,6 +18,7 @@ "input", "grid", "forum", + "tabs", "user", "video", "loading", @@ -30,20 +32,28 @@ "manifest": { "short_name": "notify.moe", "gcm_sender_id": "941298467524", - "theme_color": "#f8a582", + "theme_color": "#f0c7bb", "background_color": "#ffffff", "icons": [ { - "src": "images/brand/64", - "sizes": "64x64" + "src": "images/brand/64.png", + "sizes": "64x64", + "type": "image/png" }, { - "src": "images/brand/300", - "sizes": "300x300" + "src": "images/brand/128.png", + "sizes": "128x128", + "type": "image/png" }, { - "src": "images/brand/600", - "sizes": "600x600" + "src": "images/brand/144.png", + "sizes": "144x144", + "type": "image/png" + }, + { + "src": "images/brand/220.png", + "sizes": "220x220", + "type": "image/png" } ] }, diff --git a/images/brand/128.png b/images/brand/128.png new file mode 100644 index 00000000..7b42c8c6 Binary files /dev/null and b/images/brand/128.png differ diff --git a/images/brand/128.webp b/images/brand/128.webp new file mode 100644 index 00000000..c7874d0f Binary files /dev/null and b/images/brand/128.webp differ diff --git a/images/brand/144.png b/images/brand/144.png new file mode 100644 index 00000000..60fe4b0c Binary files /dev/null and b/images/brand/144.png differ diff --git a/images/brand/144.webp b/images/brand/144.webp new file mode 100644 index 00000000..707e2a05 Binary files /dev/null and b/images/brand/144.webp differ diff --git a/images/brand/220.png b/images/brand/220.png new file mode 100644 index 00000000..64889a90 Binary files /dev/null and b/images/brand/220.png differ diff --git a/images/brand/220.webp b/images/brand/220.webp new file mode 100644 index 00000000..abd85125 Binary files /dev/null and b/images/brand/220.webp differ diff --git a/images/brand/300.png b/images/brand/300.png deleted file mode 100644 index 13edc019..00000000 Binary files a/images/brand/300.png and /dev/null differ diff --git a/images/brand/300.webp b/images/brand/300.webp deleted file mode 100644 index 928a51a5..00000000 Binary files a/images/brand/300.webp and /dev/null differ diff --git a/images/brand/600.png b/images/brand/600.png deleted file mode 100644 index 007d01ee..00000000 Binary files a/images/brand/600.png and /dev/null differ diff --git a/images/brand/600.webp b/images/brand/600.webp deleted file mode 100644 index a53a1dd0..00000000 Binary files a/images/brand/600.webp and /dev/null differ diff --git a/images/brand/64.png b/images/brand/64.png index 0309ab76..4fc9b10d 100644 Binary files a/images/brand/64.png and b/images/brand/64.png differ diff --git a/images/brand/64.webp b/images/brand/64.webp index 6cac4dc6..e2b69b47 100644 Binary files a/images/brand/64.webp and b/images/brand/64.webp differ diff --git a/images/elements/extension-screenshot.png b/images/elements/extension-screenshot.png index 8c29bbe1..44a2451b 100644 Binary files a/images/elements/extension-screenshot.png and b/images/elements/extension-screenshot.png differ diff --git a/images/elements/noise-light.png b/images/elements/noise-light.png new file mode 100644 index 00000000..b99b55fc Binary files /dev/null and b/images/elements/noise-light.png differ diff --git a/images/elements/noise-strong.png b/images/elements/noise-strong.png new file mode 100644 index 00000000..836dbcea Binary files /dev/null and b/images/elements/noise-strong.png differ diff --git a/images/elements/thank-you.jpg b/images/elements/thank-you.jpg new file mode 100644 index 00000000..75ae85c1 Binary files /dev/null and b/images/elements/thank-you.jpg differ diff --git a/jobs/active-users/main.go b/jobs/active-users/main.go deleted file mode 100644 index 8c520285..00000000 --- a/jobs/active-users/main.go +++ /dev/null @@ -1,52 +0,0 @@ -package main - -import ( - "fmt" - "sort" - - "github.com/animenotifier/arn" - "github.com/fatih/color" -) - -func main() { - color.Yellow("Caching list of active users") - - cache := arn.ListOfIDs{} - - // Filter out active users with an avatar - users, err := arn.FilterUsers(func(user *arn.User) bool { - return user.IsActive() && user.Avatar != "" - }) - - if err != nil { - panic(err) - } - - // Sort - sort.Slice(users, func(i, j int) bool { - 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 - for _, user := range users { - cache.IDList = append(cache.IDList, user.ID) - } - - fmt.Println(len(cache.IDList), "users") - - err = arn.DB.Set("Cache", "active users", cache) - - if err != nil { - panic(err) - } - - color.Green("Finished.") -} diff --git a/jobs/airing-anime/main.go b/jobs/airing-anime/main.go deleted file mode 100644 index d1dec2ee..00000000 --- a/jobs/airing-anime/main.go +++ /dev/null @@ -1,43 +0,0 @@ -package main - -import ( - "sort" - - "github.com/animenotifier/arn" - "github.com/fatih/color" -) - -func main() { - color.Yellow("Caching airing anime") - - animeList, err := arn.GetAiringAnime() - - if err != nil { - color.Red("Failed fetching airing anime") - color.Red(err.Error()) - return - } - - sort.Slice(animeList, func(i, j int) bool { - return animeList[i].Rating.Overall > animeList[j].Rating.Overall - }) - - // Convert to small anime list - cache := &arn.ListOfIDs{} - - for _, anime := range animeList { - cache.IDList = append(cache.IDList, anime.ID) - } - - println(len(cache.IDList)) - - saveErr := arn.DB.Set("Cache", "airing anime", cache) - - if saveErr != nil { - color.Red("Error saving airing anime") - color.Red(saveErr.Error()) - return - } - - color.Green("Finished.") -} diff --git a/jobs/anime-characters/anime-characters.go b/jobs/anime-characters/anime-characters.go new file mode 100644 index 00000000..a99565b9 --- /dev/null +++ b/jobs/anime-characters/anime-characters.go @@ -0,0 +1,32 @@ +package main + +import ( + "fmt" + "time" + + "github.com/animenotifier/arn" + "github.com/fatih/color" +) + +func main() { + color.Yellow("Refreshing anime characters...") + defer arn.Node.Close() + + allAnime, _ := arn.AllAnime() + rateLimiter := time.NewTicker(500 * time.Millisecond) + + for _, anime := range allAnime { + <-rateLimiter.C + + chars, err := anime.RefreshAnimeCharacters() + + if err != nil { + color.Red(err.Error()) + continue + } + + fmt.Printf("%s %s (%d characters)\n", anime.ID, anime.Title.Canonical, len(chars.Items)) + } + + color.Green("Finished.") +} diff --git a/jobs/anime-images/anime-images.go b/jobs/anime-images/anime-images.go new file mode 100644 index 00000000..a7390908 --- /dev/null +++ b/jobs/anime-images/anime-images.go @@ -0,0 +1,59 @@ +package main + +import ( + "fmt" + "io/ioutil" + "net/http" + "strings" + "time" + + "github.com/aerogo/flow/jobqueue" + "github.com/animenotifier/arn" + "github.com/fatih/color" + "github.com/parnurzeal/gorequest" +) + +var ticker = time.NewTicker(50 * time.Millisecond) + +func main() { + color.Yellow("Downloading anime images") + defer arn.Node.Close() + + jobs := jobqueue.New(work) + allAnime, _ := arn.AllAnime() + + for _, anime := range allAnime { + jobs.Queue(anime) + } + + results := jobs.Wait() + color.Green("Finished downloading %d anime images.", len(results)) +} + +func work(job interface{}) interface{} { + anime := job.(*arn.Anime) + + if !strings.HasPrefix(anime.Image.Original, "//media.kitsu.io/anime/") { + return nil + } + + <-ticker.C + resp, body, errs := gorequest.New().Get(anime.Image.Original).End() + + if len(errs) > 0 { + color.Red(errs[0].Error()) + return errs[0] + } + + if resp.StatusCode != http.StatusOK { + color.Red("Status %d", resp.StatusCode) + } + + extension := anime.Image.Original[strings.LastIndex(anime.Image.Original, "."):] + fileName := "anime/" + anime.ID + extension + fmt.Println(fileName) + + ioutil.WriteFile(fileName, []byte(body), 0644) + + return nil +} diff --git a/jobs/anime-ratings/anime-ratings.go b/jobs/anime-ratings/anime-ratings.go new file mode 100644 index 00000000..875ed50a --- /dev/null +++ b/jobs/anime-ratings/anime-ratings.go @@ -0,0 +1,130 @@ +package main + +import ( + "github.com/animenotifier/arn" + "github.com/fatih/color" +) + +var ratings = map[string][]*arn.AnimeRating{} +var finalRating = map[string]*arn.AnimeRating{} +var popularity = map[string]*arn.AnimePopularity{} + +// Note this is using the airing-anime as a template with modfications +// made to it. +func main() { + color.Yellow("Updating anime ratings") + defer arn.Node.Close() + + allAnimeLists, err := arn.AllAnimeLists() + arn.PanicOnError(err) + + for _, animeList := range allAnimeLists { + extractRatings(animeList) + extractPopularity(animeList) + } + + // Calculate rating + for animeID := range finalRating { + overall := []float64{} + story := []float64{} + visuals := []float64{} + soundtrack := []float64{} + + for _, rating := range ratings[animeID] { + if rating.Overall != 0 { + overall = append(overall, rating.Overall) + } + + if rating.Story != 0 { + story = append(story, rating.Story) + } + + if rating.Visuals != 0 { + visuals = append(visuals, rating.Visuals) + } + + if rating.Soundtrack != 0 { + soundtrack = append(soundtrack, rating.Soundtrack) + } + } + + finalRating[animeID].Overall = average(overall) + finalRating[animeID].Story = average(story) + finalRating[animeID].Visuals = average(visuals) + finalRating[animeID].Soundtrack = average(soundtrack) + } + + // Save + for animeID := range finalRating { + anime, err := arn.GetAnime(animeID) + arn.PanicOnError(err) + anime.Rating = finalRating[animeID] + anime.Save() + } + + // Save popularity + for animeID := range popularity { + anime, err := arn.GetAnime(animeID) + arn.PanicOnError(err) + anime.Popularity = popularity[animeID] + anime.Save() + } + + color.Green("Finished.") +} + +func average(floatSlice []float64) float64 { + if len(floatSlice) == 0 { + return arn.DefaultAverageRating + } + + var sum float64 + + for _, value := range floatSlice { + sum += value + } + + return sum / float64(len(floatSlice)) +} + +func extractRatings(animeList *arn.AnimeList) { + for _, item := range animeList.Items { + if item.Rating.IsNotRated() { + continue + } + + _, found := ratings[item.AnimeID] + + if !found { + ratings[item.AnimeID] = []*arn.AnimeRating{} + finalRating[item.AnimeID] = &arn.AnimeRating{} + } + + ratings[item.AnimeID] = append(ratings[item.AnimeID], item.Rating) + } +} + +func extractPopularity(animeList *arn.AnimeList) { + for _, item := range animeList.Items { + _, found := popularity[item.AnimeID] + + if !found { + popularity[item.AnimeID] = &arn.AnimePopularity{} + } + + counter := popularity[item.AnimeID] + + switch item.Status { + case arn.AnimeListStatusWatching: + counter.Watching++ + case arn.AnimeListStatusCompleted: + counter.Completed++ + case arn.AnimeListStatusPlanned: + counter.Planned++ + case arn.AnimeListStatusHold: + counter.Hold++ + case arn.AnimeListStatusDropped: + counter.Dropped++ + } + } +} diff --git a/jobs/avatars/Avatar.go b/jobs/avatars/Avatar.go deleted file mode 100644 index 25c5b21a..00000000 --- a/jobs/avatars/Avatar.go +++ /dev/null @@ -1,66 +0,0 @@ -package main - -import ( - "bytes" - "fmt" - "image" - "net/http" - "time" - - "github.com/animenotifier/arn" - "github.com/parnurzeal/gorequest" -) - -var netLog = avatarLog.NewChannel("NET") - -// Avatar represents a single image and the name of the format. -type Avatar struct { - User *arn.User - Image image.Image - Data []byte - Format string -} - -// String returns a text representation of the format, width and height. -func (avatar *Avatar) String() string { - return fmt.Sprint(avatar.Format, " | ", avatar.Image.Bounds().Dx(), "x", avatar.Image.Bounds().Dy()) -} - -// AvatarFromURL downloads and decodes the image from an URL and creates an Avatar. -func AvatarFromURL(url string, user *arn.User) *Avatar { - // Download - response, data, networkErr := gorequest.New().Get(url).EndBytes() - - // Retry after 5 seconds if service unavailable - if response.StatusCode == http.StatusServiceUnavailable { - time.Sleep(5 * time.Second) - response, data, networkErr = gorequest.New().Get(url).EndBytes() - } - - // Network errors - if networkErr != nil { - netLog.Error(user.Nick, url, networkErr) - return nil - } - - // Bad status codes - if response.StatusCode != http.StatusOK { - netLog.Error(user.Nick, url, response.StatusCode) - return nil - } - - // Decode - img, format, decodeErr := image.Decode(bytes.NewReader(data)) - - if decodeErr != nil { - netLog.Error(user.Nick, url, decodeErr) - return nil - } - - return &Avatar{ - User: user, - Image: img, - Data: data, - Format: format, - } -} diff --git a/jobs/avatars/AvatarOriginalFileOutput.go b/jobs/avatars/AvatarOriginalFileOutput.go deleted file mode 100644 index 8bb6544c..00000000 --- a/jobs/avatars/AvatarOriginalFileOutput.go +++ /dev/null @@ -1,64 +0,0 @@ -package main - -import ( - "bytes" - "errors" - "image/gif" - "image/jpeg" - "image/png" - "io/ioutil" - - "github.com/nfnt/resize" -) - -// AvatarOriginalFileOutput ... -type AvatarOriginalFileOutput struct { - Directory string - Size int -} - -// SaveAvatar writes the original avatar to the file system. -func (output *AvatarOriginalFileOutput) SaveAvatar(avatar *Avatar) error { - // Determine file extension - extension := "" - - switch avatar.Format { - case "jpg", "jpeg": - extension = ".jpg" - case "png": - extension = ".png" - case "gif": - extension = ".gif" - default: - return errors.New("Unknown format: " + avatar.Format) - } - - // Resize if needed - data := avatar.Data - img := avatar.Image - - if img.Bounds().Dx() > output.Size { - img = resize.Resize(uint(output.Size), 0, img, resize.Lanczos3) - buffer := new(bytes.Buffer) - - var err error - switch extension { - case ".jpg": - err = jpeg.Encode(buffer, img, nil) - case ".png": - err = png.Encode(buffer, img) - case ".gif": - err = gif.Encode(buffer, img, nil) - } - - if err != nil { - return err - } - - data = buffer.Bytes() - } - - // Write to file - fileName := output.Directory + avatar.User.ID + extension - return ioutil.WriteFile(fileName, data, 0644) -} diff --git a/jobs/avatars/AvatarSource.go b/jobs/avatars/AvatarSource.go deleted file mode 100644 index 5d7c2253..00000000 --- a/jobs/avatars/AvatarSource.go +++ /dev/null @@ -1,10 +0,0 @@ -package main - -import ( - "github.com/animenotifier/arn" -) - -// AvatarSource describes a source where we can find avatar images for a user. -type AvatarSource interface { - GetAvatar(*arn.User) *Avatar -} diff --git a/jobs/avatars/AvatarWebPFileOutput.go b/jobs/avatars/AvatarWebPFileOutput.go deleted file mode 100644 index a3aaf150..00000000 --- a/jobs/avatars/AvatarWebPFileOutput.go +++ /dev/null @@ -1,27 +0,0 @@ -package main - -import ( - "github.com/animenotifier/arn" - "github.com/nfnt/resize" -) - -// AvatarWebPFileOutput ... -type AvatarWebPFileOutput struct { - Directory string - Size int - Quality float32 -} - -// SaveAvatar writes the avatar in WebP format to the file system. -func (output *AvatarWebPFileOutput) SaveAvatar(avatar *Avatar) error { - img := avatar.Image - - // Resize if needed - if img.Bounds().Dx() > output.Size { - img = resize.Resize(uint(output.Size), 0, img, resize.Lanczos3) - } - - // Write to file - fileName := output.Directory + avatar.User.ID + ".webp" - return arn.SaveWebP(img, fileName, output.Quality) -} diff --git a/jobs/avatars/AvatarWriter.go b/jobs/avatars/AvatarWriter.go deleted file mode 100644 index eef1f5fc..00000000 --- a/jobs/avatars/AvatarWriter.go +++ /dev/null @@ -1,6 +0,0 @@ -package main - -// AvatarOutput represents a system that saves an avatar locally (in database or as a file, e.g.) -type AvatarOutput interface { - SaveAvatar(*Avatar) error -} diff --git a/jobs/avatars/Gravatar.go b/jobs/avatars/Gravatar.go deleted file mode 100644 index 2ec685ce..00000000 --- a/jobs/avatars/Gravatar.go +++ /dev/null @@ -1,35 +0,0 @@ -package main - -import ( - "fmt" - "time" - - "github.com/animenotifier/arn" - gravatar "github.com/ungerik/go-gravatar" -) - -var gravatarLog = avatarLog.NewChannel("GRA") - -// Gravatar - https://gravatar.com/ -type Gravatar struct { - Rating string - RequestLimiter *time.Ticker -} - -// GetAvatar returns the Gravatar image for a user (if available). -func (source *Gravatar) GetAvatar(user *arn.User) *Avatar { - // If the user has no Email registered we can't get a Gravatar. - if user.Email == "" { - gravatarLog.Error(user.Nick, "No Email") - return nil - } - - // Build URL - gravatarURL := gravatar.Url(user.Email) + "?s=" + fmt.Sprint(arn.AvatarMaxSize) + "&d=404&r=" + source.Rating - - // Wait for request limiter to allow us to send a request - <-source.RequestLimiter.C - - // Download - return AvatarFromURL(gravatarURL, user) -} diff --git a/jobs/avatars/MyAnimeList.go b/jobs/avatars/MyAnimeList.go deleted file mode 100644 index e1a30b52..00000000 --- a/jobs/avatars/MyAnimeList.go +++ /dev/null @@ -1,60 +0,0 @@ -package main - -import ( - "net/http" - "regexp" - "time" - - "github.com/animenotifier/arn" - "github.com/parnurzeal/gorequest" -) - -var userIDRegex = regexp.MustCompile(`(\d+)<\/user_id>`) -var malLog = avatarLog.NewChannel("MAL") - -// MyAnimeList - https://myanimelist.net/ -type MyAnimeList struct { - RequestLimiter *time.Ticker -} - -// GetAvatar returns the Gravatar image for a user (if available). -func (source *MyAnimeList) GetAvatar(user *arn.User) *Avatar { - malNick := user.Accounts.MyAnimeList.Nick - - // If the user has no username we can't get an avatar. - if malNick == "" { - malLog.Error(user.Nick, "No MAL nick") - return nil - } - - // Download user info - userInfoURL := "https://myanimelist.net/malappinfo.php?u=" + malNick - response, xml, networkErr := gorequest.New().Get(userInfoURL).End() - - if networkErr != nil { - malLog.Error(user.Nick, userInfoURL, networkErr) - return nil - } - - if response.StatusCode != http.StatusOK { - malLog.Error(user.Nick, userInfoURL, response.StatusCode) - return nil - } - - // Build URL - matches := userIDRegex.FindStringSubmatch(xml) - - if matches == nil || len(matches) < 2 { - malLog.Error(user.Nick, "Could not find user ID") - return nil - } - - malID := matches[1] - malAvatarURL := "https://myanimelist.cdn-dena.com/images/userimages/" + malID + ".jpg" - - // Wait for request limiter to allow us to send a request - <-source.RequestLimiter.C - - // Download - return AvatarFromURL(malAvatarURL, user) -} diff --git a/jobs/avatars/avatars.go b/jobs/avatars/avatars.go new file mode 100644 index 00000000..f0fb8c7c --- /dev/null +++ b/jobs/avatars/avatars.go @@ -0,0 +1,69 @@ +package main + +import ( + "os" + "path" + "runtime" + "sync" + + _ "image/gif" + _ "image/jpeg" + _ "image/png" + + "github.com/aerogo/log" + "github.com/animenotifier/arn" + "github.com/animenotifier/avatar/lib" + "github.com/fatih/color" +) + +var wg sync.WaitGroup + +// Main +func main() { + color.Yellow("Generating user avatars") + defer arn.Node.Close() + + // Switch to main directory + exe, err := os.Executable() + + if err != nil { + panic(err) + } + + root := path.Dir(exe) + os.Chdir(path.Join(root, "../../")) + + // Log + lib.Log.AddOutput(log.File("logs/avatar.log")) + defer lib.Log.Flush() + + if InvokeShellArgs() { + return + } + + // Worker queue + usersQueue := make(chan *arn.User, runtime.NumCPU()) + StartWorkers(usersQueue, lib.RefreshAvatar) + + // We'll send each user to one of the worker threads + for user := range arn.StreamUsers() { + wg.Add(1) + usersQueue <- user + } + + wg.Wait() + + color.Green("Finished.") +} + +// StartWorkers creates multiple workers to handle a user each. +func StartWorkers(queue chan *arn.User, work func(*arn.User)) { + for w := 0; w < runtime.NumCPU(); w++ { + go func() { + for user := range queue { + work(user) + wg.Done() + } + }() + } +} diff --git a/jobs/avatars/main.go b/jobs/avatars/main.go deleted file mode 100644 index c6a76bbc..00000000 --- a/jobs/avatars/main.go +++ /dev/null @@ -1,148 +0,0 @@ -package main - -import ( - "fmt" - "os" - "path" - "reflect" - "runtime" - "time" - - _ "image/gif" - _ "image/jpeg" - _ "image/png" - - "github.com/aerogo/log" - "github.com/animenotifier/arn" - "github.com/fatih/color" -) - -const ( - webPQuality = 80 -) - -var avatarSources []AvatarSource -var avatarOutputs []AvatarOutput -var avatarLog = log.New() - -// Main -func main() { - color.Yellow("Generating user avatars") - - // Switch to main directory - exe, err := os.Executable() - - if err != nil { - panic(err) - } - - root := path.Dir(exe) - os.Chdir(path.Join(root, "../../")) - - // Log - avatarLog.AddOutput(log.File("logs/avatar.log")) - defer avatarLog.Flush() - - // Define the avatar sources - avatarSources = []AvatarSource{ - &Gravatar{ - Rating: "pg", - RequestLimiter: time.NewTicker(250 * time.Millisecond), - }, - &MyAnimeList{ - RequestLimiter: time.NewTicker(250 * time.Millisecond), - }, - } - - // Define the avatar outputs - avatarOutputs = []AvatarOutput{ - // Original - Large - &AvatarOriginalFileOutput{ - Directory: "images/avatars/large/original/", - Size: arn.AvatarMaxSize, - }, - - // Original - Small - &AvatarOriginalFileOutput{ - Directory: "images/avatars/small/original/", - Size: arn.AvatarSmallSize, - }, - - // WebP - Large - &AvatarWebPFileOutput{ - Directory: "images/avatars/large/webp/", - Size: arn.AvatarMaxSize, - Quality: webPQuality, - }, - - // WebP - Small - &AvatarWebPFileOutput{ - Directory: "images/avatars/small/webp/", - Size: arn.AvatarSmallSize, - Quality: webPQuality, - }, - } - - if InvokeShellArgs() { - return - } - - // Stream of all users - users, _ := arn.FilterUsers(func(user *arn.User) bool { - return true - }) - - // Log user count - println(len(users), "users") - - // Worker queue - usersQueue := make(chan *arn.User) - StartWorkers(usersQueue, Work) - - // We'll send each user to one of the worker threads - for _, user := range users { - usersQueue <- user - } - - color.Green("Finished.") -} - -// StartWorkers creates multiple workers to handle a user each. -func StartWorkers(queue chan *arn.User, work func(*arn.User)) { - for w := 0; w < runtime.NumCPU(); w++ { - go func() { - for user := range queue { - work(user) - } - }() - } -} - -// Work handles a single user. -func Work(user *arn.User) { - user.Avatar = "" - - for _, source := range avatarSources { - avatar := source.GetAvatar(user) - - if avatar == nil { - // fmt.Println(color.RedString("✘"), reflect.TypeOf(source).Elem().Name(), user.Nick) - continue - } - - for _, writer := range avatarOutputs { - err := writer.SaveAvatar(avatar) - - if err != nil { - color.Red(err.Error()) - } - } - - fmt.Println(color.GreenString("✔"), reflect.TypeOf(source).Elem().Name(), "|", user.Nick, "|", avatar) - user.Avatar = "/+" + user.Nick + "/avatar" - break - } - - // Save avatar data - user.Save() -} diff --git a/jobs/avatars/shell.go b/jobs/avatars/shell.go index ab957ac2..da798e98 100644 --- a/jobs/avatars/shell.go +++ b/jobs/avatars/shell.go @@ -4,6 +4,7 @@ import ( "flag" "github.com/animenotifier/arn" + "github.com/animenotifier/avatar/lib" ) // Shell parameters @@ -26,7 +27,7 @@ func InvokeShellArgs() bool { panic(err) } - Work(user) + lib.RefreshAvatar(user) return true } @@ -37,7 +38,7 @@ func InvokeShellArgs() bool { panic(err) } - Work(user) + lib.RefreshAvatar(user) return true } diff --git a/jobs/build.sh b/jobs/build.sh index 2839f06f..48a4e138 100755 --- a/jobs/build.sh +++ b/jobs/build.sh @@ -1,4 +1,5 @@ #!/bin/sh MYDIR="$(dirname "$(realpath "$0")")" cd "$MYDIR" +go build for dir in ./*; do ([ -d "$dir" ] && cd "$dir" && echo "Building jobs/$dir" && go build); done \ No newline at end of file diff --git a/jobs/main.go b/jobs/jobs.go similarity index 87% rename from jobs/main.go rename to jobs/jobs.go index 54db996b..2137c493 100644 --- a/jobs/main.go +++ b/jobs/jobs.go @@ -23,12 +23,15 @@ var colorPool = []*color.Color{ } var jobs = map[string]time.Duration{ - "active-users": 1 * time.Minute, - "avatars": 1 * time.Hour, - "sync-anime": 10 * time.Hour, - "popular-anime": 11 * time.Hour, - "airing-anime": 12 * time.Hour, - "search-index": 13 * time.Hour, + "anime-ratings": 10 * time.Minute, + "avatars": 1 * time.Hour, + "test": 1 * time.Hour, + "twist": 2 * time.Hour, + "search-index": 2 * time.Hour, + "refresh-episodes": 10 * time.Hour, + "refresh-osu": 12 * time.Hour, + "sync-anime": 12 * time.Hour, + "sync-shoboi": 24 * time.Hour, } func main() { diff --git a/jobs/popular-anime/main.go b/jobs/popular-anime/main.go deleted file mode 100644 index c90015f2..00000000 --- a/jobs/popular-anime/main.go +++ /dev/null @@ -1,56 +0,0 @@ -package main - -import ( - "sort" - - "github.com/animenotifier/arn" - "github.com/fatih/color" -) - -const maxPopularAnime = 10 - -// Note this is using the airing-anime as a template with modfications -// made to it. -func main() { - color.Yellow("Caching popular anime") - - animeChan, err := arn.AllAnime() - - if err != nil { - color.Red("Failed fetching anime channel") - color.Red(err.Error()) - return - } - - var animeList []*arn.Anime - - for anime := range animeChan { - animeList = append(animeList, anime) - } - - 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] - - // Convert to small anime list - cache := &arn.ListOfIDs{} - - for _, anime := range animeList { - cache.IDList = append(cache.IDList, anime.ID) - } - - println(len(cache.IDList)) - - saveErr := arn.DB.Set("Cache", "popular anime", cache) - - if saveErr != nil { - color.Red("Error saving popular anime") - color.Red(saveErr.Error()) - return - } - - color.Green("Finished.") -} diff --git a/jobs/refresh-episodes/refresh-episodes.go b/jobs/refresh-episodes/refresh-episodes.go new file mode 100644 index 00000000..5f7e9f00 --- /dev/null +++ b/jobs/refresh-episodes/refresh-episodes.go @@ -0,0 +1,79 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/animenotifier/arn" + "github.com/fatih/color" +) + +func main() { + color.Yellow("Refreshing episode information for each anime.") + defer arn.Node.Close() + + if InvokeShellArgs() { + return + } + + highPriority := []*arn.Anime{} + mediumPriority := []*arn.Anime{} + lowPriority := []*arn.Anime{} + + for anime := range arn.StreamAnime() { + if anime.GetMapping("shoboi/anime") == "" { + continue + } + + // The rest gets sorted by airing status + switch anime.Status { + case "current": + highPriority = append(highPriority, anime) + case "upcoming": + mediumPriority = append(mediumPriority, anime) + default: + lowPriority = append(lowPriority, anime) + } + } + + color.Cyan("High priority queue (%d):", len(highPriority)) + refreshQueue(highPriority) + + color.Cyan("Medium priority queue (%d):", len(mediumPriority)) + refreshQueue(mediumPriority) + + color.Cyan("Low priority queue (%d):", len(lowPriority)) + refreshQueue(lowPriority) + + color.Green("Finished.") +} + +func refreshQueue(queue []*arn.Anime) { + for _, anime := range queue { + refresh(anime) + } +} + +func refresh(anime *arn.Anime) { + fmt.Println(anime.ID, "|", anime.Title.Canonical, "|", anime.GetMapping("shoboi/anime")) + + episodeCount := len(anime.Episodes().Items) + availableEpisodeCount := anime.Episodes().AvailableCount() + + err := anime.RefreshEpisodes() + + if err != nil { + if strings.Contains(err.Error(), "missing a Shoboi ID") { + return + } + + color.Red(err.Error()) + } else { + faint := color.New(color.Faint).SprintFunc() + episodes := anime.Episodes() + + fmt.Println(faint(episodes)) + fmt.Printf("+%d episodes | +%d available (%d total)\n", len(episodes.Items)-episodeCount, episodes.AvailableCount()-availableEpisodeCount, len(episodes.Items)) + println() + } +} diff --git a/jobs/refresh-episodes/shell.go b/jobs/refresh-episodes/shell.go new file mode 100644 index 00000000..a804ad45 --- /dev/null +++ b/jobs/refresh-episodes/shell.go @@ -0,0 +1,32 @@ +package main + +import ( + "flag" + + "github.com/animenotifier/arn" +) + +// Shell parameters +var animeID string + +// Shell flags +func init() { + flag.StringVar(&animeID, "id", "", "ID of the anime you want to refresh") + flag.Parse() +} + +// InvokeShellArgs ... +func InvokeShellArgs() bool { + if animeID != "" { + anime, err := arn.GetAnime(animeID) + + if err != nil { + panic(err) + } + + refresh(anime) + return true + } + + return false +} diff --git a/jobs/refresh-osu/refresh-osu.go b/jobs/refresh-osu/refresh-osu.go new file mode 100644 index 00000000..04639099 --- /dev/null +++ b/jobs/refresh-osu/refresh-osu.go @@ -0,0 +1,32 @@ +package main + +import ( + "time" + + "github.com/animenotifier/arn" + "github.com/fatih/color" +) + +func main() { + color.Yellow("Refreshing osu information") + defer arn.Node.Close() + + ticker := time.NewTicker(500 * time.Millisecond) + + for user := range arn.StreamUsers() { + // Get osu info + if user.RefreshOsuInfo() == nil { + arn.PrettyPrint(user.Accounts.Osu) + + // Fetch user again to prevent writing old data + updatedUser, _ := arn.GetUser(user.ID) + updatedUser.Accounts.Osu = user.Accounts.Osu + updatedUser.Save() + } + + // Wait for rate limiter + <-ticker.C + } + + color.Green("Finished.") +} diff --git a/jobs/search-index/main.go b/jobs/search-index/main.go deleted file mode 100644 index aa98d388..00000000 --- a/jobs/search-index/main.go +++ /dev/null @@ -1,74 +0,0 @@ -package main - -import ( - "fmt" - "strings" - - "github.com/aerogo/flow" - "github.com/animenotifier/arn" - "github.com/fatih/color" -) - -func main() { - color.Yellow("Updating search index") - - flow.Parallel(updateAnimeIndex, updateUserIndex) - - color.Green("Finished.") -} - -func updateAnimeIndex() { - animeSearchIndex := arn.NewSearchIndex() - - // Anime - animeStream, err := arn.AllAnime() - - if err != nil { - panic(err) - } - - for anime := range animeStream { - if anime.Title.Canonical != "" { - animeSearchIndex.TextToID[strings.ToLower(anime.Title.Canonical)] = anime.ID - } - - if anime.Title.Japanese != "" { - animeSearchIndex.TextToID[anime.Title.Japanese] = anime.ID - } - } - - fmt.Println(len(animeSearchIndex.TextToID), "anime titles") - - // Save in database - err = arn.DB.Set("SearchIndex", "Anime", animeSearchIndex) - - if err != nil { - panic(err) - } -} - -func updateUserIndex() { - userSearchIndex := arn.NewSearchIndex() - - // Users - userStream, err := arn.AllUsers() - - if err != nil { - panic(err) - } - - for user := range userStream { - if user.IsActive() && user.Nick != "" { - userSearchIndex.TextToID[strings.ToLower(user.Nick)] = user.ID - } - } - - fmt.Println(len(userSearchIndex.TextToID), "user names") - - // Save in database - err = arn.DB.Set("SearchIndex", "User", userSearchIndex) - - if err != nil { - panic(err) - } -} diff --git a/jobs/search-index/search-index.go b/jobs/search-index/search-index.go new file mode 100644 index 00000000..e4b597d2 --- /dev/null +++ b/jobs/search-index/search-index.go @@ -0,0 +1,108 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/aerogo/flow" + "github.com/animenotifier/arn" + "github.com/fatih/color" +) + +func main() { + color.Yellow("Updating search index") + defer arn.Node.Close() + + flow.Parallel( + updateAnimeIndex, + updateUserIndex, + updatePostIndex, + updateThreadIndex, + ) + + color.Green("Finished.") +} + +func updateAnimeIndex() { + animeSearchIndex := arn.NewSearchIndex() + + // Anime + for anime := range arn.StreamAnime() { + if anime.Title.Canonical != "" { + animeSearchIndex.TextToID[strings.ToLower(anime.Title.Canonical)] = anime.ID + } + + if anime.Title.Romaji != "" { + animeSearchIndex.TextToID[strings.ToLower(anime.Title.Romaji)] = anime.ID + } + + // Make sure we only include Japanese titles that + // don't overlap with the English titles. + if anime.Title.Japanese != "" && animeSearchIndex.TextToID[strings.ToLower(anime.Title.Japanese)] == "" { + animeSearchIndex.TextToID[strings.ToLower(anime.Title.Japanese)] = anime.ID + } + + // Same with English titles, don't overwrite other stuff. + if anime.Title.English != "" && animeSearchIndex.TextToID[strings.ToLower(anime.Title.English)] == "" { + animeSearchIndex.TextToID[strings.ToLower(anime.Title.English)] = anime.ID + } + + for _, synonym := range anime.Title.Synonyms { + synonym = strings.ToLower(synonym) + + if synonym != "" && len(synonym) <= 10 { + animeSearchIndex.TextToID[synonym] = anime.ID + } + } + } + + fmt.Println(len(animeSearchIndex.TextToID), "anime titles") + + // Save in database + arn.DB.Set("SearchIndex", "Anime", animeSearchIndex) +} + +func updateUserIndex() { + userSearchIndex := arn.NewSearchIndex() + + // Users + for user := range arn.StreamUsers() { + if user.HasNick() { + userSearchIndex.TextToID[strings.ToLower(user.Nick)] = user.ID + } + } + + fmt.Println(len(userSearchIndex.TextToID), "user names") + + // Save in database + arn.DB.Set("SearchIndex", "User", userSearchIndex) +} + +func updatePostIndex() { + postSearchIndex := arn.NewSearchIndex() + + // Users + for post := range arn.StreamPosts() { + postSearchIndex.TextToID[strings.ToLower(post.Text)] = post.ID + } + + fmt.Println(len(postSearchIndex.TextToID), "posts") + + // Save in database + arn.DB.Set("SearchIndex", "Post", postSearchIndex) +} + +func updateThreadIndex() { + threadSearchIndex := arn.NewSearchIndex() + + // Users + for thread := range arn.StreamThreads() { + threadSearchIndex.TextToID[strings.ToLower(thread.Title)] = thread.ID + threadSearchIndex.TextToID[strings.ToLower(thread.Text)] = thread.ID + } + + fmt.Println(len(threadSearchIndex.TextToID)/2, "threads") + + // Save in database + arn.DB.Set("SearchIndex", "Thread", threadSearchIndex) +} diff --git a/jobs/sync-anime/main.go b/jobs/sync-anime/main.go deleted file mode 100644 index ea44093f..00000000 --- a/jobs/sync-anime/main.go +++ /dev/null @@ -1,88 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "strconv" - "strings" - - "github.com/animenotifier/arn" - "github.com/animenotifier/kitsu" - "github.com/fatih/color" -) - -func main() { - color.Yellow("Syncing Anime") - - // Get a stream of all anime - allAnime := kitsu.AllAnime() - - // Iterate over the stream - for anime := range allAnime { - sync(anime) - } - - println("Finished.") -} - -func sync(data *kitsu.Anime) { - anime := arn.Anime{} - attr := data.Attributes - - // General data - anime.ID = data.ID - anime.Type = strings.ToLower(attr.ShowType) - anime.Title.Canonical = attr.CanonicalTitle - anime.Title.English = attr.Titles.En - anime.Title.Japanese = attr.Titles.JaJp - anime.Title.Romaji = attr.Titles.EnJp - anime.Title.Synonyms = attr.AbbreviatedTitles - anime.Image.Tiny = kitsu.FixImageURL(attr.PosterImage.Tiny) - anime.Image.Small = kitsu.FixImageURL(attr.PosterImage.Small) - anime.Image.Large = kitsu.FixImageURL(attr.PosterImage.Large) - anime.Image.Original = kitsu.FixImageURL(attr.PosterImage.Original) - anime.StartDate = attr.StartDate - anime.EndDate = attr.EndDate - anime.EpisodeCount = attr.EpisodeCount - anime.EpisodeLength = attr.EpisodeLength - anime.Status = attr.Status - anime.NSFW = attr.Nsfw - anime.Summary = arn.FixAnimeDescription(attr.Synopsis) - - // Rating - overall, convertError := strconv.ParseFloat(attr.AverageRating, 64) - - if convertError != nil { - overall = 0 - } - - anime.Rating.Overall = overall - - // Trailers - anime.Trailers = []arn.AnimeTrailer{} - - if attr.YoutubeVideoID != "" { - anime.Trailers = append(anime.Trailers, arn.AnimeTrailer{ - Service: "Youtube", - VideoID: attr.YoutubeVideoID, - }) - } - - // Save in database - err := anime.Save() - status := "" - - if err == nil { - status = color.GreenString("✔") - } else { - color.Red(err.Error()) - - data, _ := json.MarshalIndent(anime, "", "\t") - fmt.Println(string(data)) - - status = color.RedString("✘") - } - - // Log - fmt.Println(status, anime.ID, anime.Title.Canonical) -} diff --git a/jobs/sync-anime/shell.go b/jobs/sync-anime/shell.go new file mode 100644 index 00000000..96a3802d --- /dev/null +++ b/jobs/sync-anime/shell.go @@ -0,0 +1,50 @@ +package main + +import ( + "errors" + "flag" + + "github.com/animenotifier/arn" + "github.com/animenotifier/kitsu" + "github.com/fatih/color" +) + +// Shell parameters +var animeID string +var verbose bool + +// Shell flags +func init() { + flag.StringVar(&animeID, "id", "", "ID of the anime that you want to refresh") + flag.BoolVar(&verbose, "v", false, "Verbose output") + flag.Parse() +} + +// InvokeShellArgs ... +func InvokeShellArgs() bool { + if animeID != "" { + kitsuAnime, err := kitsu.GetAnime(animeID) + + if err != nil { + panic(err) + } + + if kitsuAnime.ID != animeID { + panic(errors.New("Anime ID is not the same")) + } + + anime := sync(kitsuAnime) + + if verbose { + color.Cyan("Kitsu:") + arn.PrettyPrint(kitsuAnime) + + color.Cyan("ARN:") + arn.PrettyPrint(anime) + } + + return true + } + + return false +} diff --git a/jobs/sync-anime/sync-anime.go b/jobs/sync-anime/sync-anime.go new file mode 100644 index 00000000..7db928f0 --- /dev/null +++ b/jobs/sync-anime/sync-anime.go @@ -0,0 +1,143 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/animenotifier/arn" + "github.com/animenotifier/kitsu" + "github.com/fatih/color" +) + +func main() { + color.Yellow("Syncing Anime") + defer arn.Node.Close() + + // In case we refresh only one anime + if InvokeShellArgs() { + color.Green("Finished.") + return + } + + // Get a stream of all anime + allAnime := kitsu.StreamAnimeWithMappings() + + // Iterate over the stream + for anime := range allAnime { + sync(anime) + } + + color.Green("Finished.") +} + +func sync(data *kitsu.Anime) *arn.Anime { + anime, err := arn.GetAnime(data.ID) + + if err != nil { + if strings.Contains(err.Error(), "not found") { + anime = &arn.Anime{ + Title: &arn.AnimeTitle{}, + Image: &arn.AnimeImageTypes{}, + } + } else { + panic(err) + } + } + + attr := data.Attributes + + // General data + anime.ID = data.ID + anime.Type = strings.ToLower(attr.ShowType) + anime.Title.Canonical = attr.CanonicalTitle + anime.Title.English = attr.Titles.En + anime.Title.Romaji = attr.Titles.EnJp + anime.Title.Synonyms = attr.AbbreviatedTitles + anime.Image.Tiny = kitsu.FixImageURL(attr.PosterImage.Tiny) + anime.Image.Small = kitsu.FixImageURL(attr.PosterImage.Small) + anime.Image.Large = kitsu.FixImageURL(attr.PosterImage.Large) + anime.Image.Original = kitsu.FixImageURL(attr.PosterImage.Original) + anime.StartDate = attr.StartDate + anime.EndDate = attr.EndDate + anime.EpisodeCount = attr.EpisodeCount + anime.EpisodeLength = attr.EpisodeLength + anime.Status = attr.Status + anime.Summary = arn.FixAnimeDescription(attr.Synopsis) + + if anime.Mappings == nil { + anime.Mappings = []*arn.Mapping{} + } + + // Prefer Shoboi Japanese titles over Kitsu JP titles + if anime.GetMapping("shoboi/anime") != "" { + // Only take Kitsu title when our JP title is empty + if anime.Title.Japanese == "" { + anime.Title.Japanese = attr.Titles.JaJp + } + } else { + // Update JP title with Kitsu JP title + anime.Title.Japanese = attr.Titles.JaJp + } + + // Import mappings + for _, mapping := range data.Mappings { + switch mapping.Attributes.ExternalSite { + case "myanimelist/anime": + anime.AddMapping("myanimelist/anime", mapping.Attributes.ExternalID, "") + case "anidb": + anime.AddMapping("anidb/anime", mapping.Attributes.ExternalID, "") + case "thetvdb/series": + anime.AddMapping("thetvdb/anime", mapping.Attributes.ExternalID, "") + case "thetvdb/season": + // Ignore + default: + color.Yellow("Unknown mapping: %s %s", mapping.Attributes.ExternalSite, mapping.Attributes.ExternalID) + } + } + + // NSFW + if attr.Nsfw { + anime.NSFW = 1 + } else { + anime.NSFW = 0 + } + + // Rating + if anime.Rating == nil { + anime.Rating = &arn.AnimeRating{} + } + + if anime.Rating.IsNotRated() { + anime.Rating.Reset() + } + + // Popularity + if anime.Popularity == nil { + anime.Popularity = &arn.AnimePopularity{} + } + + // Trailers + anime.Trailers = []*arn.ExternalMedia{} + + if attr.YoutubeVideoID != "" { + anime.Trailers = append(anime.Trailers, &arn.ExternalMedia{ + Service: "Youtube", + ServiceID: attr.YoutubeVideoID, + }) + } + + // Save in database + anime.Save() + + // Episodes + episodes, err := arn.GetAnimeEpisodes(anime.ID) + + if err != nil || episodes == nil { + anime.RefreshEpisodes() + } + + // Log + fmt.Println(color.GreenString("✔"), anime.ID, anime.Title.Canonical) + + return anime +} diff --git a/jobs/sync-characters/sync-characters.go b/jobs/sync-characters/sync-characters.go new file mode 100644 index 00000000..76ad09a8 --- /dev/null +++ b/jobs/sync-characters/sync-characters.go @@ -0,0 +1,31 @@ +package main + +import ( + "fmt" + + "github.com/animenotifier/arn" + "github.com/animenotifier/kitsu" + "github.com/fatih/color" +) + +func main() { + color.Yellow("Syncing characters with Kitsu DB") + defer arn.Node.Close() + + kitsuCharacters := kitsu.StreamCharacters() + + for kitsuCharacter := range kitsuCharacters { + character := &arn.Character{ + ID: kitsuCharacter.ID, + Name: kitsuCharacter.Attributes.Name, + Image: kitsu.FixImageURL(kitsuCharacter.Attributes.Image.Original), + Description: arn.FixAnimeDescription(kitsuCharacter.Attributes.Description), + } + + fmt.Printf("%s %s\n", character.ID, character.Name) + + character.Save() + } + + color.Green("Finished.") +} diff --git a/jobs/sync-media-relations/sync-media-relations.go b/jobs/sync-media-relations/sync-media-relations.go new file mode 100644 index 00000000..2a9fca9d --- /dev/null +++ b/jobs/sync-media-relations/sync-media-relations.go @@ -0,0 +1,75 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/animenotifier/arn" + + "github.com/animenotifier/kitsu" + "github.com/fatih/color" +) + +func main() { + color.Yellow("Syncing media relations with Kitsu DB") + defer arn.Node.Close() + + kitsuMediaRelations := kitsu.StreamMediaRelations() + relations := map[string]*arn.AnimeRelations{} + + for mediaRelation := range kitsuMediaRelations { + // We only care about anime for now + if mediaRelation.Relationships.Source.Data.Type != "anime" || mediaRelation.Relationships.Destination.Data.Type != "anime" { + continue + } + + relationType := strings.Replace(mediaRelation.Attributes.Role, "_", " ", -1) + animeID := mediaRelation.Relationships.Source.Data.ID + destinationAnimeID := mediaRelation.Relationships.Destination.Data.ID + + // Confirm that the anime IDs are valid + if !arn.DB.Exists("Anime", animeID) { + continue + } + + if !arn.DB.Exists("Anime", destinationAnimeID) { + continue + } + + fmt.Printf( + "%s %s has %s which is %s %s\n", + mediaRelation.Relationships.Source.Data.Type, + animeID, + color.GreenString(relationType), + mediaRelation.Relationships.Destination.Data.Type, + destinationAnimeID, + ) + + // Add anime to the global map + relationsList, found := relations[animeID] + + if !found { + relationsList = &arn.AnimeRelations{ + AnimeID: animeID, + Items: []*arn.AnimeRelation{}, + } + relations[animeID] = relationsList + } + + relationsList.Items = append(relationsList.Items, &arn.AnimeRelation{ + AnimeID: destinationAnimeID, + Type: relationType, + }) + + // for _, item := range relationsList.Items { + // fmt.Println("*", item.Type, item.AnimeID) + // } + } + + // Save relations map + for _, animeRelations := range relations { + animeRelations.Save() + } + + color.Green("Finished.") +} diff --git a/jobs/sync-shoboi/sync-shoboi.go b/jobs/sync-shoboi/sync-shoboi.go new file mode 100644 index 00000000..ab3eb549 --- /dev/null +++ b/jobs/sync-shoboi/sync-shoboi.go @@ -0,0 +1,120 @@ +package main + +import ( + "time" + + "github.com/animenotifier/arn" + "github.com/animenotifier/shoboi" + "github.com/fatih/color" +) + +func main() { + color.Yellow("Syncing Shoboi Anime") + defer arn.Node.Close() + + // Priority queues + highPriority := []*arn.Anime{} + mediumPriority := []*arn.Anime{} + lowPriority := []*arn.Anime{} + + for anime := range arn.StreamAnime() { + if anime.GetMapping("shoboi/anime") != "" { + continue + } + + switch anime.Status { + case "current": + highPriority = append(highPriority, anime) + case "upcoming": + mediumPriority = append(mediumPriority, anime) + default: + lowPriority = append(lowPriority, anime) + } + } + + color.Cyan("High priority queue (%d):", len(highPriority)) + refreshQueue(highPriority) + + color.Cyan("Medium priority queue (%d):", len(mediumPriority)) + refreshQueue(mediumPriority) + + color.Cyan("Low priority queue (%d):", len(lowPriority)) + refreshQueue(lowPriority) + + // This is a lazy hack: Wait 5 minutes for goroutines to finish their remaining work. + time.Sleep(5 * time.Minute) + + color.Green("Finished.") +} + +func refreshQueue(queue []*arn.Anime) { + count := 0 + + for _, anime := range queue { + if sync(anime) { + anime.Save() + count++ + } + } + + color.Green("Added Shoboi IDs for %d anime", count) +} + +func sync(anime *arn.Anime) bool { + // If we already have the ID, nothing to do here + if anime.GetMapping("shoboi/anime") != "" { + return false + } + + // Log ID and title + print(anime.ID + " | [JP] " + anime.Title.Japanese + " | [EN] " + anime.Title.Canonical) + + // Search Japanese title + if anime.GetMapping("shoboi/anime") == "" && anime.Title.Japanese != "" { + search(anime, anime.Title.Japanese) + } + + // Search English title + if anime.GetMapping("shoboi/anime") == "" && anime.Title.English != "" { + search(anime, anime.Title.English) + } + + // Did we get the ID? + if anime.GetMapping("shoboi/anime") != "" { + println(color.GreenString("✔")) + return true + } + + println(color.RedString("✘")) + return false +} + +// Search for a specific title +func search(anime *arn.Anime, title string) { + shoboi, err := shoboi.SearchAnime(title) + + if err != nil { + color.Red(err.Error()) + return + } + + if shoboi == nil { + return + } + + // Copy titles + if shoboi.TitleJapanese != "" { + anime.Title.Japanese = shoboi.TitleJapanese + } + + if shoboi.TitleHiragana != "" { + anime.Title.Hiragana = shoboi.TitleHiragana + } + + if shoboi.FirstChannel != "" { + anime.FirstChannel = shoboi.FirstChannel + } + + // This will start a goroutine that saves the anime + anime.AddMapping("shoboi/anime", shoboi.TID, "") +} diff --git a/jobs/test/test.go b/jobs/test/test.go new file mode 100644 index 00000000..ace5bfac --- /dev/null +++ b/jobs/test/test.go @@ -0,0 +1,68 @@ +package main + +import ( + "os/exec" + "sync" + + "github.com/animenotifier/arn" + + "github.com/fatih/color" +) + +var packages = []string{ + "github.com/animenotifier/notify.moe", + "github.com/animenotifier/arn", + "github.com/animenotifier/kitsu", + "github.com/animenotifier/anilist", + "github.com/animenotifier/mal", + "github.com/animenotifier/shoboi", + "github.com/animenotifier/twist", + "github.com/animenotifier/avatar", + // "github.com/animenotifier/japanese", + // "github.com/animenotifier/osu", +} + +func main() { + wg := sync.WaitGroup{} + + for _, pkg := range packages { + wg.Add(1) + + go func(pkgLocal string) { + testPackage(pkgLocal) + wg.Done() + }(pkg) + } + + wg.Wait() +} + +func testPackage(pkg string) { + cmd := exec.Command("go", "test", pkg+"/...") + // cmd.Stdout = os.Stdout + // cmd.Stderr = os.Stderr + + err := cmd.Start() + + if err != nil { + panic(err) + } + + err = cmd.Wait() + + if err != nil { + color.Red("%s", pkg) + + // Send notification to the admin + admin, _ := arn.GetUser("4J6qpK1ve") + admin.SendNotification(&arn.Notification{ + Title: pkg, + Message: "Test failed", + Link: "https://" + pkg, + Icon: "https://notify.moe/images/brand/220.png", + }) + return + } + + color.Green("%s", pkg) +} diff --git a/jobs/twist/twist.go b/jobs/twist/twist.go new file mode 100644 index 00000000..c0c1bf11 --- /dev/null +++ b/jobs/twist/twist.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + "os" + "time" + + "github.com/animenotifier/arn" + "github.com/animenotifier/twist" + "github.com/fatih/color" +) + +var rateLimiter = time.NewTicker(500 * time.Millisecond) + +func main() { + defer arn.Node.Close() + + // Replace this with ID list from twist.moe later + twistAnime, err := twist.GetAnimeIndex() + arn.PanicOnError(err) + idList := arn.IDList(twistAnime.KitsuIDs()) + + // Save index in cache + arn.DB.Set("IDList", "animetwist index", &idList) + + color.Yellow("Refreshing twist.moe links for %d anime", len(idList)) + + for count, animeID := range idList { + anime, animeErr := arn.GetAnime(animeID) + + if animeErr != nil { + color.Red("Error fetching anime from the database with ID %s: %v", animeID, animeErr) + continue + } + + // Log + fmt.Fprintf(os.Stdout, "[%d / %d] ", count+1, len(idList)) + + // Refresh + anime.RefreshEpisodes() + + // Ok + color.Green("Found %d episodes for anime %s", len(anime.Episodes().Items), animeID) + + // Wait for rate limiter + <-rateLimiter.C + } +} diff --git a/layout/layout.go b/layout/layout.go index b963046f..384528a8 100644 --- a/layout/layout.go +++ b/layout/layout.go @@ -2,6 +2,7 @@ package layout import ( "github.com/aerogo/aero" + "github.com/animenotifier/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" ) @@ -9,5 +10,6 @@ import ( // Render layout. func Render(ctx *aero.Context, content string) string { user := utils.GetUser(ctx) - return components.Layout(ctx.App, ctx, user, content) + openGraph, _ := ctx.Data.(*arn.OpenGraph) + return components.Layout(ctx.App, ctx, user, openGraph, content) } diff --git a/layout/layout.pixy b/layout/layout.pixy index 65523533..d63fa6d9 100644 --- a/layout/layout.pixy +++ b/layout/layout.pixy @@ -1,29 +1,36 @@ -component Layout(app *aero.Application, ctx *aero.Context, user *arn.User, content string) +component Layout(app *aero.Application, ctx *aero.Context, user *arn.User, openGraph *arn.OpenGraph, content string) html(lang="en") head - title= app.Config.Title + if openGraph != nil + title= openGraph.Tags["og:title"] + else + title= app.Config.Title + meta(name="viewport", content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes") meta(name="theme-color", content=app.Config.Manifest.ThemeColor) + + if openGraph != nil + for name, value := range openGraph.Meta + meta(name=name, content=value) + + for property, content := range openGraph.Tags + meta(property=property, content=content) + link(rel="chrome-webstore-item", href="https://chrome.google.com/webstore/detail/hajchfikckiofgilinkpifobdbiajfch") link(rel="manifest", href="/manifest.json") body #container(class=utils.GetContainerClass(ctx)) - #header - Navigation(user) - #content-container - main#content.fade!= content - - LoadingAnimation + //- #header + //- Navigation(user) + #columns + Sidebar(user) + Content(content) + LoadingAnimation + StatusMessage + if user != nil + #user(data-id=user.ID) script(src="/scripts") -component LoadingAnimation - #loading.sk-cube-grid.fade - .sk-cube.hide - .sk-cube - .sk-cube.hide - .sk-cube - .sk-cube.sk-cube-center - .sk-cube - .sk-cube.hide - .sk-cube - .sk-cube.hide \ No newline at end of file +component Content(content string) + #content-container + main#content.fade!= content \ No newline at end of file diff --git a/layout/sidebar/sidebar.pixy b/layout/sidebar/sidebar.pixy new file mode 100644 index 00000000..df34916a --- /dev/null +++ b/layout/sidebar/sidebar.pixy @@ -0,0 +1,66 @@ +component Sidebar(user *arn.User) + aside#sidebar + .user-image-container + if user != nil + Avatar(user) + else + img.user-image.lazy(src=utils.EmptyImage(), data-src="/images/brand/64.png", data-webp="true", alt="Anime Notifier") + + if user != nil + SidebarButton("Home", "/animelist/watching", "home") + //- SidebarButton("Dash", "/dashboard", "tachometer") + else + SidebarButton("Home", "/", "home") + + SidebarButton("Forum", "/forum", "comment") + SidebarButton("Explore", "/explore", "th") + //- SidebarButton("Artworks", "/artworks", "paint-brush") + SidebarButton("Soundtracks", "/soundtracks", "headphones") + //- SidebarButton("AMVs", "/amvs", "video-camera") + //- SidebarButton("Games", "/games", "gamepad") + SidebarButton("Users", "/users", "globe") + //- SidebarButton("Search", "/search", "search") + + if user != nil + //- if user.Role == "admin" + //- SidebarButton("Groups", "/groups", "users") + + SidebarButton("Shop", "/shop", "shopping-cart") + + //- if user.Role == "admin" || user.Role == "editor" + //- SidebarButton("Statistics", "/statistics", "pie-chart") + + SidebarButton("Settings", "/settings", "cog") + + .spacer + + .sidebar-link(aria-label="Search") + .sidebar-button + Icon("search") + FuzzySearch + + if user != nil + if user.Role == "admin" + SidebarButton("Admin", "/admin", "wrench") + + if user.Role == "editor" + SidebarButton("Editor", "/editor", "pencil") + + SidebarButton("Help", "/thread/I3MMiOtzR", "question-circle") + + if user != nil + SidebarButtonNoAJAX("Logout", "/logout", "sign-out") + else + SidebarButton("Login", "/login", "sign-in") + +component SidebarButton(name string, target string, icon string) + a.sidebar-link.ajax(href=target, aria-label=name, data-bubble="true") + .sidebar-button + Icon(icon) + span.sidebar-text= name + +component SidebarButtonNoAJAX(name string, target string, icon string) + a.sidebar-link(href=target, aria-label=name, data-bubble="true") + .sidebar-button + Icon(icon) + span.sidebar-text= name \ No newline at end of file diff --git a/layout/sidebar/sidebar.scarlet b/layout/sidebar/sidebar.scarlet new file mode 100644 index 00000000..0edd7907 --- /dev/null +++ b/layout/sidebar/sidebar.scarlet @@ -0,0 +1,60 @@ +sidebar-spacing-y = 0.7rem + +#sidebar + vertical + position fixed + left 0 + top 0 + z-index 10 + min-width 200px + height 100% + background sidebar-opaque-background + transform translateX(-100%) + overflow-x hidden + overflow-y auto + opacity 0 + pointer-events none + box-shadow shadow-medium + transition opacity transition-speed ease, transform transition-speed ease + will-change opacity, transition + + .user-image-container + horizontal + justify-content center + margin 0.8rem 0 + flex-shrink 0 + +> 800px + #sidebar + opacity 1 + transform none + position static + pointer-events auto + box-shadow none + border-right ui-border + background sidebar-background + +.sidebar-visible + transform translateX(0) !important + pointer-events auto !important + opacity 1 !important + +.sidebar-link + color text-color + + &.active + .sidebar-button + color tab-active-color + background tab-active-background + text-shadow tab-active-text-shadow + background tab-active-background + +.sidebar-button + horizontal + align-items center + padding sidebar-spacing-y 1rem + // background ui-background + + .icon + font-size 1rem + margin-right 0.75rem \ No newline at end of file diff --git a/main.go b/main.go index c2993c4e..84301e33 100644 --- a/main.go +++ b/main.go @@ -2,31 +2,56 @@ package main import ( "github.com/aerogo/aero" - "github.com/aerogo/api" + "github.com/aerogo/session-store-nano" "github.com/animenotifier/arn" "github.com/animenotifier/notify.moe/auth" + "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/components/css" "github.com/animenotifier/notify.moe/layout" "github.com/animenotifier/notify.moe/middleware" "github.com/animenotifier/notify.moe/pages/admin" - "github.com/animenotifier/notify.moe/pages/airing" + "github.com/animenotifier/notify.moe/pages/amvs" "github.com/animenotifier/notify.moe/pages/anime" "github.com/animenotifier/notify.moe/pages/animelist" "github.com/animenotifier/notify.moe/pages/animelistitem" + "github.com/animenotifier/notify.moe/pages/apiview" + "github.com/animenotifier/notify.moe/pages/artworks" + "github.com/animenotifier/notify.moe/pages/best" + "github.com/animenotifier/notify.moe/pages/character" + "github.com/animenotifier/notify.moe/pages/charge" + "github.com/animenotifier/notify.moe/pages/compare" "github.com/animenotifier/notify.moe/pages/dashboard" + "github.com/animenotifier/notify.moe/pages/database" + "github.com/animenotifier/notify.moe/pages/editanime" + "github.com/animenotifier/notify.moe/pages/editor" "github.com/animenotifier/notify.moe/pages/embed" + "github.com/animenotifier/notify.moe/pages/explore" "github.com/animenotifier/notify.moe/pages/forum" "github.com/animenotifier/notify.moe/pages/forums" + "github.com/animenotifier/notify.moe/pages/group" + "github.com/animenotifier/notify.moe/pages/groups" + "github.com/animenotifier/notify.moe/pages/home" + "github.com/animenotifier/notify.moe/pages/inventory" + "github.com/animenotifier/notify.moe/pages/listimport" + "github.com/animenotifier/notify.moe/pages/listimport/listimportanilist" + "github.com/animenotifier/notify.moe/pages/listimport/listimportkitsu" + "github.com/animenotifier/notify.moe/pages/listimport/listimportmyanimelist" "github.com/animenotifier/notify.moe/pages/login" - "github.com/animenotifier/notify.moe/pages/popularanime" + "github.com/animenotifier/notify.moe/pages/me" + "github.com/animenotifier/notify.moe/pages/newthread" + "github.com/animenotifier/notify.moe/pages/notifications" + "github.com/animenotifier/notify.moe/pages/paypal" "github.com/animenotifier/notify.moe/pages/posts" "github.com/animenotifier/notify.moe/pages/profile" "github.com/animenotifier/notify.moe/pages/search" "github.com/animenotifier/notify.moe/pages/settings" + "github.com/animenotifier/notify.moe/pages/shop" + "github.com/animenotifier/notify.moe/pages/soundtrack" + "github.com/animenotifier/notify.moe/pages/soundtracks" + "github.com/animenotifier/notify.moe/pages/statistics" "github.com/animenotifier/notify.moe/pages/threads" "github.com/animenotifier/notify.moe/pages/user" "github.com/animenotifier/notify.moe/pages/users" - "github.com/animenotifier/notify.moe/pages/webdev" ) var app = aero.New() @@ -44,45 +69,154 @@ func configure(app *aero.Application) *aero.Application { app.SetStyle(css.Bundle()) // Sessions - app.Sessions.Duration = 3600 * 24 - app.Sessions.Store = arn.NewAerospikeStore("Session", app.Sessions.Duration) + app.Sessions.Duration = 3600 * 24 * 30 * 6 + + // TODO: ... + // app.Sessions.Store = aerospikestore.New(arn.DB, "Session", app.Sessions.Duration) + app.Sessions.Store = nanostore.New(arn.DB.Collection("Session")) // Layout app.Layout = layout.Render // Ajax routes - app.Ajax("/", dashboard.Get) - app.Ajax("/anime", popularanime.Get) + app.Ajax("/", home.Get) + app.Ajax("/dashboard", dashboard.Get) app.Ajax("/anime/:id", anime.Get) + app.Ajax("/anime/:id/episodes", anime.Episodes) + app.Ajax("/anime/:id/characters", anime.Characters) + app.Ajax("/anime/:id/tracks", anime.Tracks) + app.Ajax("/anime/:id/edit", editanime.Get) + app.Ajax("/api", apiview.Get) + app.Ajax("/best/anime", best.Get) + app.Ajax("/explore", explore.Get) app.Ajax("/forum", forums.Get) app.Ajax("/forum/:tag", forum.Get) - app.Ajax("/threads/:id", threads.Get) - app.Ajax("/posts/:id", posts.Get) + app.Ajax("/thread/:id", threads.Get) + app.Ajax("/post/:id", posts.Get) + app.Ajax("/character/:id", character.Get) + app.Ajax("/new/thread", newthread.Get) + app.Ajax("/artworks", artworks.Get) + app.Ajax("/amvs", amvs.Get) + app.Ajax("/users", users.Active) + app.Ajax("/users/osu", users.Osu) + app.Ajax("/users/staff", users.Staff) + app.Ajax("/statistics", statistics.Get) + app.Ajax("/statistics/anime", statistics.Anime) + app.Ajax("/login", login.Get) + + // Settings + app.Ajax("/settings", settings.Get(components.SettingsPersonal)) + app.Ajax("/settings/accounts", settings.Get(components.SettingsAccounts)) + app.Ajax("/settings/notifications", settings.Get(components.SettingsNotifications)) + app.Ajax("/settings/apps", settings.Get(components.SettingsApps)) + app.Ajax("/settings/avatar", settings.Get(components.SettingsAvatar)) + app.Ajax("/settings/formatting", settings.Get(components.SettingsFormatting)) + app.Ajax("/settings/pro", settings.Get(components.SettingsPro)) + + // Soundtracks + app.Ajax("/soundtracks", soundtracks.Get) + app.Ajax("/soundtracks/from/:index", soundtracks.From) + app.Ajax("/soundtrack/:id", soundtrack.Get) + app.Ajax("/soundtrack/:id/edit", soundtrack.Edit) + + // Groups + app.Ajax("/groups", groups.Get) + app.Ajax("/group/:id", group.Get) + app.Ajax("/group/:id/edit", group.Edit) + app.Ajax("/group/:id/forum", group.Forum) + + // User profiles app.Ajax("/user", user.Get) app.Ajax("/user/:nick", profile.Get) app.Ajax("/user/:nick/threads", profile.GetThreadsByUser) app.Ajax("/user/:nick/posts", profile.GetPostsByUser) + app.Ajax("/user/:nick/soundtracks", profile.GetSoundTracksByUser) + app.Ajax("/user/:nick/stats", profile.GetStatsByUser) + app.Ajax("/user/:nick/followers", profile.GetFollowers) app.Ajax("/user/:nick/animelist", animelist.Get) - app.Ajax("/user/:nick/animelist/:id", animelistitem.Get) - app.Ajax("/settings", settings.Get) - app.Ajax("/admin", admin.Get) + app.Ajax("/user/:nick/animelist/watching", animelist.FilterByStatus(arn.AnimeListStatusWatching)) + app.Ajax("/user/:nick/animelist/completed", animelist.FilterByStatus(arn.AnimeListStatusCompleted)) + app.Ajax("/user/:nick/animelist/planned", animelist.FilterByStatus(arn.AnimeListStatusPlanned)) + app.Ajax("/user/:nick/animelist/hold", animelist.FilterByStatus(arn.AnimeListStatusHold)) + app.Ajax("/user/:nick/animelist/dropped", animelist.FilterByStatus(arn.AnimeListStatusDropped)) + app.Ajax("/user/:nick/animelist/anime/:id", animelistitem.Get) + + // Anime list + app.Ajax("/animelist/watching", home.FilterByStatus(arn.AnimeListStatusWatching)) + app.Ajax("/animelist/completed", home.FilterByStatus(arn.AnimeListStatusCompleted)) + app.Ajax("/animelist/planned", home.FilterByStatus(arn.AnimeListStatusPlanned)) + app.Ajax("/animelist/hold", home.FilterByStatus(arn.AnimeListStatusHold)) + app.Ajax("/animelist/dropped", home.FilterByStatus(arn.AnimeListStatusDropped)) + + // Compare + app.Ajax("/compare/animelist/:nick-1/:nick-2", compare.AnimeList) + + // Search + app.Ajax("/search", search.Get) app.Ajax("/search/:term", search.Get) - app.Ajax("/users", users.Get) - app.Ajax("/login", login.Get) - app.Ajax("/airing", airing.Get) - app.Ajax("/webdev", webdev.Get) - app.Ajax("/extension/embed", embed.Get) + + // Shop + app.Ajax("/shop", shop.Get) + app.Ajax("/inventory", inventory.Get) + app.Ajax("/charge", charge.Get) + app.Ajax("/shop/history", shop.PurchaseHistory) + app.Post("/api/shop/buy/:item/:quantity", shop.BuyItem) + + // Admin + app.Ajax("/admin", admin.Get) + app.Ajax("/admin/webdev", admin.WebDev) + app.Ajax("/admin/purchases", admin.PurchaseHistory) + + // Editor + app.Ajax("/editor", editor.Get) + app.Ajax("/editor/anilist", editor.AniList) + app.Ajax("/editor/shoboi", editor.Shoboi) + + // Mixed + app.Ajax("/database", database.Get) + app.Get("/api/select/:data-type/where/:field/is/:field-value", database.Select) + + // Import + app.Ajax("/import", listimport.Get) + app.Ajax("/import/anilist/animelist", listimportanilist.Preview) + app.Ajax("/import/anilist/animelist/finish", listimportanilist.Finish) + app.Ajax("/import/myanimelist/animelist", listimportmyanimelist.Preview) + app.Ajax("/import/myanimelist/animelist/finish", listimportmyanimelist.Finish) + app.Ajax("/import/kitsu/animelist", listimportkitsu.Preview) + app.Ajax("/import/kitsu/animelist/finish", listimportkitsu.Finish) + + // Genres // app.Ajax("/genres", genres.Get) // app.Ajax("/genres/:name", genre.Get) - // Middleware - app.Use(middleware.Log()) - app.Use(middleware.Session()) - app.Use(middleware.UserInfo()) + // Browser extension + app.Ajax("/extension/embed", embed.Get) // API - api := api.New("/api/", arn.DB) - api.Install(app) + app.Get("/api/me", me.Get) + app.Get("/api/test/notification", notifications.Test) + + // PayPal + app.Ajax("/paypal/success", paypal.Success) + app.Ajax("/paypal/cancel", paypal.Cancel) + app.Post("/api/paypal/payment/create", paypal.CreatePayment) + + // Assets + configureAssets(app) + + // Rewrite + app.Rewrite(rewrite) + + // Middleware + app.Use( + middleware.Firewall(), + middleware.Log(), + middleware.Session(), + middleware.UserInfo(), + ) + + // API + arn.API.Install(app) // Domain if arn.IsDevelopment() { @@ -92,5 +226,16 @@ func configure(app *aero.Application) *aero.Application { // Authentication auth.Install(app) + // Close the database node on shutdown + app.OnShutdown(arn.Node.Close) + + // Prefetch data from all collections + arn.DB.PrefetchData() + + // Specify test routes + for route, examples := range routeTests { + app.Test(route, examples) + } + return app } diff --git a/main_test.go b/main_test.go index a766bc9f..57037505 100644 --- a/main_test.go +++ b/main_test.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "net/http" "net/http/httptest" "testing" @@ -11,7 +12,7 @@ import ( func TestRoutes(t *testing.T) { app := configure(aero.New()) - for _, examples := range tests { + for _, examples := range routeTests { for _, example := range examples { request, err := http.NewRequest("GET", example, nil) @@ -23,7 +24,7 @@ func TestRoutes(t *testing.T) { app.Handler().ServeHTTP(responseRecorder, request) if status := responseRecorder.Code; status != http.StatusOK { - t.Errorf("%s | Wrong status code | %v instead of %v", example, status, http.StatusOK) + panic(fmt.Errorf("%s | Wrong status code | %v instead of %v", example, status, http.StatusOK)) } } } diff --git a/makefile b/makefile index b1e3d012..cb0dda73 100644 --- a/makefile +++ b/makefile @@ -3,27 +3,40 @@ GOCMD=@go GOBUILD=$(GOCMD) build GOINSTALL=$(GOCMD) install -GOTEST=$(GOCMD) test +GOTEST=@./go-test-color.sh BUILDJOBS=@./jobs/build.sh BUILDPATCHES=@./patches/build.sh +BUILDBOTS=@./bots/build.sh +TSCMD=@tsc IPTABLES=@sudo iptables server: $(GOBUILD) jobs: $(BUILDJOBS) +bots: + $(BUILDBOTS) patches: $(BUILDPATCHES) +js: + $(TSCMD) install: $(GOINSTALL) test: - $(GOTEST) + $(GOTEST) github.com/animenotifier/... -v -cover bench: $(GOTEST) -bench . +tools: + go get -u golang.org/x/tools/cmd/goimports + go get -u github.com/aerogo/pack + go get -u github.com/aerogo/run + go install github.com/aerogo/pack + go install github.com/aerogo/run versions: @go version @asd --version assets: + $(TSCMD) @pack depslist: $(GOCMD) list -f {{.Deps}} | sed -e 's/\[//g' -e 's/\]//g' | tr " " "\n" @@ -32,6 +45,6 @@ clean: ports: $(IPTABLES) -t nat -A OUTPUT -o lo -p tcp --dport 80 -j REDIRECT --to-port 4000 $(IPTABLES) -t nat -A OUTPUT -o lo -p tcp --dport 443 -j REDIRECT --to-port 4001 -all: assets server jobs patches +all: assets server bots jobs patches -.PHONY: jobs patches ports +.PHONY: bots jobs patches ports diff --git a/middleware/Firewall.go b/middleware/Firewall.go new file mode 100644 index 00000000..0594e57c --- /dev/null +++ b/middleware/Firewall.go @@ -0,0 +1,79 @@ +package middleware + +import ( + "strings" + "time" + + "github.com/aerogo/aero" + "github.com/animenotifier/notify.moe/utils" + cache "github.com/patrickmn/go-cache" +) + +const requestThreshold = 10 + +var ipToStats = cache.New(15*time.Minute, 15*time.Minute) + +// IPStats captures the statistics for a single IP. +type IPStats struct { + Requests []string +} + +// Firewall middleware detects malicious requests. +func Firewall() aero.Middleware { + return func(ctx *aero.Context, next func()) { + var stats *IPStats + + ip := ctx.RealIP() + + // Allow localhost + if ip == "127.0.0.1" { + next() + return + } + + statsObj, found := ipToStats.Get(ip) + + if found { + stats = statsObj.(*IPStats) + } else { + stats = &IPStats{ + Requests: []string{}, + } + + ipToStats.Set(ip, stats, cache.DefaultExpiration) + } + + // Add requested URI to the list of requests + stats.Requests = append(stats.Requests, ctx.URI()) + + if len(stats.Requests) > requestThreshold { + stats.Requests = stats.Requests[len(stats.Requests)-requestThreshold:] + + for _, uri := range stats.Requests { + // Allow request + if strings.Contains(uri, "/_/") || strings.Contains(uri, "/api/") || strings.Contains(uri, "/scripts") || strings.Contains(uri, "/service-worker") || strings.Contains(uri, "/favicon.ico") || strings.Contains(uri, "/extension/embed") { + next() + return + } + } + + // Allow logged in users + if ctx.HasSession() { + user := utils.GetUser(ctx) + + if user != nil { + // Allow request + next() + return + } + } + + // Disallow request + request.Error("[guest]", ip, "BLOCKED BY FIREWALL", ctx.URI()) + return + } + + // Allow the request if the number of requests done by the IP is below the threshold + next() + } +} diff --git a/middleware/UserInfo.go b/middleware/UserInfo.go index 96aecab1..2a3e825f 100644 --- a/middleware/UserInfo.go +++ b/middleware/UserInfo.go @@ -1,31 +1,18 @@ package middleware import ( - "encoding/json" - "io/ioutil" "net/http" "strconv" "strings" "github.com/aerogo/aero" + "github.com/aerogo/http/client" "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()) { @@ -84,22 +71,21 @@ func updateUserInfo(ctx *aero.Context, user *arn.User) { // 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" + locationAPI := "https://api.ipinfodb.com/v3/ip-city/?key=" + arn.APIKeys.IPInfoDB.ID + "&ip=" + user.IP + "&format=json" + response, err := client.Get(locationAPI).End() - 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) + if err != nil { + color.Red("Couldn't fetch location data | Error: %s | IP: %s", err.Error(), user.IP) return } - if response.StatusCode != http.StatusOK { + 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) + response.Unmarshal(&newLocation) if newLocation.CountryName != "-" { user.Location.CountryName = newLocation.CountryName diff --git a/mixins/AnimeGrid.pixy b/mixins/AnimeGrid.pixy index 43537328..b95e06ef 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.lazy(data-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) \ No newline at end of file diff --git a/mixins/Avatar.pixy b/mixins/Avatar.pixy index b3f9a103..990191cf 100644 --- a/mixins/Avatar.pixy +++ b/mixins/Avatar.pixy @@ -1,10 +1,13 @@ component Avatar(user *arn.User) - a.user.ajax(href="/+" + user.Nick, title=user.Nick) + CustomAvatar(user, user.Link(), user.Nick) + +component CustomAvatar(user *arn.User, link string, title string) + a.user.ajax(href=link, title=title) AvatarNoLink(user) component AvatarNoLink(user *arn.User) if user.HasAvatar() - img.user-image.lazy(data-src=user.SmallAvatar(), alt=user.Nick) + img.user-image.lazy(data-src=user.SmallAvatar(), data-webp="true", alt=user.Nick) else SVGAvatar diff --git a/mixins/Character.pixy b/mixins/Character.pixy new file mode 100644 index 00000000..42b274e7 --- /dev/null +++ b/mixins/Character.pixy @@ -0,0 +1,4 @@ +component Character(character *arn.Character) + a.character.ajax(href="/character/" + character.ID) + img.character-image.lazy(data-src=character.Image, alt=character.Name, title=character.Name) + //- span.character-name= character.Name \ No newline at end of file diff --git a/mixins/ForumTags.pixy b/mixins/ForumTags.pixy index ff367246..d89a59be 100644 --- a/mixins/ForumTags.pixy +++ b/mixins/ForumTags.pixy @@ -1,5 +1,5 @@ component ForumTags - .buttons.forum-tags + .tabs ForumTag("All", "", "list") ForumTag("General", "general", "list") ForumTag("News", "news", "list") @@ -9,6 +9,6 @@ component ForumTags ForumTag("Bugs", "bug", "list") component ForumTag(title string, category string, icon string) - a.button.forum-tag.action(href=strings.TrimSuffix("/forum/" + category, "/"), data-action="diff", data-trigger="click") + a.tab.action(href=strings.TrimSuffix("/forum/" + category, "/"), data-action="diff", data-trigger="click") Icon(arn.GetForumIcon(category)) - span.forum-tag-text= title \ No newline at end of file + span.tab-text= title \ No newline at end of file diff --git a/mixins/FuzzySearch.pixy b/mixins/FuzzySearch.pixy new file mode 100644 index 00000000..cbf95fba --- /dev/null +++ b/mixins/FuzzySearch.pixy @@ -0,0 +1,2 @@ +component FuzzySearch + input#search.action(data-action="search", data-trigger="input", type="text", placeholder="Search...", title="Shortcut: F") \ No newline at end of file diff --git a/mixins/Input.pixy b/mixins/Input.pixy index 352bd112..c6984dfa 100644 --- a/mixins/Input.pixy +++ b/mixins/Input.pixy @@ -1,19 +1,37 @@ component InputText(id string, value string, label string, placeholder string) - .widget-input + .widget-section label(for=id)= label + ":" - input.widget-element.action(id=id, type="text", value=value, placeholder=placeholder, title=placeholder, data-action="save", data-trigger="change") + input.widget-ui-element.action(id=id, data-field=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 + .widget-section label(for=id)= label + ":" - textarea.widget-element.action(id=id, placeholder=placeholder, title=placeholder, data-action="save", data-trigger="change")= value + textarea.widget-ui-element.action(id=id, data-field=id, placeholder=placeholder, title=placeholder, data-action="save", data-trigger="change")= value component InputNumber(id string, value float64, label string, placeholder string, min string, max string, step string) - .widget-input + .widget-section label(for=id)= label + ":" - input.widget-element.action(id=id, type="number", value=value, min=min, max=max, step=step, placeholder=placeholder, title=placeholder, data-action="save", data-trigger="change") + input.widget-ui-element.action(id=id, data-field=id, type="number", value=value, min=min, max=max, step=step, placeholder=placeholder, title=placeholder, data-action="save", data-trigger="change") -component InputSelection(id string, value string, label string, placeholder string) - .widget-input +component InputSelection(id string, value string, label string, placeholder string, options []*arn.Option) + .widget-section label(for=id)= label + ":" - select.widget-element.action(id=id, value=value, title=placeholder, data-action="save", data-trigger="change") + select.widget-ui-element.action(id=id, data-field=id, value=value, title=placeholder, data-action="save", data-trigger="change") + each option in options + option(value=option.Value)= option.Label + +component InputTags(id string, value []string, label string, tooltip string) + .widget-section + label(for=id)= label + ":" + .tags(id=id) + for index, tag := range value + .tag.tag-edit + span.tag-title.action(contenteditable="true", data-trigger="focusout", data-action="save", data-field=id + "[" + strconv.Itoa(index) + "]")= tag + button.tag-remove.action(data-action="arrayRemove", data-trigger="click", data-field=id, data-index=index) + RawIcon("trash") + + button.tag-add.action(data-action="arrayAppend", data-trigger="click", data-field=id) + RawIcon("plus") + + p!= tooltip + \ No newline at end of file diff --git a/mixins/Japanese.pixy b/mixins/Japanese.pixy new file mode 100644 index 00000000..9ebe2bbd --- /dev/null +++ b/mixins/Japanese.pixy @@ -0,0 +1,12 @@ +component Japanese(text string) + if arn.ContainsUnicodeLetters(text) + for _, token := range arn.JapaneseTokenizer.Tokenize(text) + if token.Furigana + a.japanese(href="http://jisho.org/search/" + token.Original, target="_blank", rel="noopener") + ruby(title=token.Romaji)= token.Original + rt.furigana= token.Hiragana + else + ruby.japanese(title=token.Romaji)= token.Original + rt.furigana + else + span.japanese= text \ No newline at end of file diff --git a/mixins/LoadMore.pixy b/mixins/LoadMore.pixy new file mode 100644 index 00000000..94337ed8 --- /dev/null +++ b/mixins/LoadMore.pixy @@ -0,0 +1,4 @@ +component LoadMore(index int) + button#load-more-button.action(data-action="loadMore", data-trigger="click", data-index=index) + Icon("refresh") + span Load more \ No newline at end of file diff --git a/mixins/LoadingAnimation.pixy b/mixins/LoadingAnimation.pixy new file mode 100644 index 00000000..2489c239 --- /dev/null +++ b/mixins/LoadingAnimation.pixy @@ -0,0 +1,11 @@ +component LoadingAnimation + #loading.sk-cube-grid.fade + .sk-cube.hide + .sk-cube + .sk-cube.hide + .sk-cube + .sk-cube.sk-cube-center + .sk-cube + .sk-cube.hide + .sk-cube + .sk-cube.hide \ No newline at end of file diff --git a/mixins/Navigation.pixy b/mixins/Navigation.pixy deleted file mode 100644 index a8c01021..00000000 --- a/mixins/Navigation.pixy +++ /dev/null @@ -1,57 +0,0 @@ -component Navigation(user *arn.User) - if user == nil - LoggedOutMenu - else - LoggedInMenu(user) - -component LoggedOutMenu - nav#navigation.logged-out - NavigationButton("About", "/", "question-circle") - NavigationButton("Anime", "/anime", "television") - NavigationButton("Forum", "/forum", "comment") - - FuzzySearch - - .extra-navigation - NavigationButton("Users", "/users", "globe") - - NavigationButton("Airing", "/airing", "th") - NavigationButton("Login", "/login", "sign-in") - -component LoggedInMenu(user *arn.User) - nav#navigation.logged-in - .extension-navigation - NavigationButton("Watching list", "/extension/embed", "home") - - NavigationButton("Dash", "/", "dashboard") - NavigationButton("Profile", "/+", "user") - NavigationButton("Forum", "/forum", "comment") - NavigationButton("Anime", "/anime", "television") - - FuzzySearch - - .extra-navigation - NavigationButton("Users", "/users", "globe") - - .extra-navigation - NavigationButton("Airing", "/airing", "th") - - NavigationButton("Settings", "/settings", "cog") - - .extra-navigation - NavigationButtonNoAJAX("Logout", "/logout", "sign-out") - -component FuzzySearch - 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) - .navigation-button - Icon(icon) - span.navigation-text= name - -component NavigationButtonNoAJAX(name string, target string, icon string) - a.navigation-link(href=target, aria-label=name) - .navigation-button - Icon(icon) - span.navigation-text= name \ No newline at end of file diff --git a/mixins/Postable.pixy b/mixins/Postable.pixy index 8c165a6d..9fd02261 100644 --- a/mixins/Postable.pixy +++ b/mixins/Postable.pixy @@ -1,5 +1,5 @@ -component Postable(post arn.Postable, highlightAuthorID string) - .post.mountable(data-highlight=post.Author().ID == highlightAuthorID) +component Postable(post arn.Postable, user *arn.User, highlightAuthorID string) + .post.mountable(id=strings.ToLower(post.Type()) + "-" + toString(post.ID()), data-highlight=post.Author().ID == highlightAuthorID, data-pro=post.Author().IsPro(), data-api="/api/" + strings.ToLower(post.Type()) + "/" + post.ID()) .post-author Avatar(post.Author()) @@ -9,30 +9,38 @@ component Postable(post arn.Postable, highlightAuthorID string) .post-content div(id="render-" + post.ID())!= post.HTML() - //- if user && user.ID === post.authorId - //- textarea.post-input.hidden(id="source-" + post.ID)= post.text - //- a.post-save.hidden(id="save-" + post.ID, onclick=`$.saveEdit("${type.toLowerCase()}", "${post.ID}")`) - //- i.fa.fa-save - //- span Save + if user != nil && user.ID == post.Author().ID + .post-edit-interface + if post.Type() == "Thread" + input.post-title-input.hidden(id="title-" + post.ID(), value=post.Title(), type="text", placeholder="Thread title") + textarea.post-text-input.hidden(id="source-" + post.ID())= post.Text() + .buttons.hidden(id="edit-toolbar-" + post.ID()) + a.button.post-save.action(data-action="savePost", data-trigger="click", data-id=post.ID()) + Icon("save") + span Save + + a.button.post-cancel-edit.action(data-action="editPost", data-trigger="click", data-id=post.ID()) + Icon("close") + span Cancel + + .post-date.utc-date(data-date=post.Created()) .post-toolbar(id="toolbar-" + post.ID()) .spacer .post-likes(id="likes-" + post.ID(), title="Likes")= len(post.Likes()) - //- if user != nil - //- if user.ID !== post.authorId - //- - var liked = post.likes && post.likes.indexOf(user.ID) !== -1 + if user != nil + if user.ID != post.Author().ID + if post.LikedBy(user.ID) + a.post-tool.post-unlike.action(id="unlike-" + post.ID(), title="Unlike", data-action="unlike", data-trigger="click") + Icon("thumbs-down") + else + a.post-tool.post-like.action(id="like-" + post.ID(), title="Like", data-action="like", data-trigger="click") + Icon("thumbs-up") - //- a.post-tool.post-like(id="like-" + post.ID, onclick=`$.like("${type.toLowerCase()}", "${post.ID}")`, title="Like", class=liked ? "hidden" : ") - //- i.fa.fa-thumbs-up.fa-fw - - //- a.post-tool.post-unlike(id="unlike-" + post.ID, onclick=`$.unlike("${type.toLowerCase()}", "${post.ID}")`, title="Unlike", class=!liked ? "hidden" : ") - //- i.fa.fa-thumbs-down.fa-fw - - //- if type === "Posts" || type === "Threads" - //- if user.ID === post.authorId - //- a.post-tool.post-edit(onclick=`$.edit("${post.ID}")`, title="Edit") - //- i.fa.fa-pencil.fa-fw + if user.ID == post.Author().ID + a.post-tool.post-edit.action(data-action="editPost", data-trigger="click", data-id=post.ID(), title="Edit") + Icon("pencil") if post.Type() != "Thread" a.post-tool.post-permalink.ajax(href=post.Link(), title="Permalink") diff --git a/mixins/PostableList.pixy b/mixins/PostableList.pixy index c2f956a9..bd31d475 100644 --- a/mixins/PostableList.pixy +++ b/mixins/PostableList.pixy @@ -1,5 +1,5 @@ -component PostableList(postables []arn.Postable) +component PostableList(postables []arn.Postable, user *arn.User) .thread .posts each post in postables - Postable(post, "") + Postable(post, user, "") diff --git a/mixins/ProfileImage.pixy b/mixins/ProfileImage.pixy index dc06f415..9b7a7735 100644 --- a/mixins/ProfileImage.pixy +++ b/mixins/ProfileImage.pixy @@ -1,6 +1,6 @@ component ProfileImage(user *arn.User) if user.HasAvatar() - img.profile-image(src=user.LargeAvatar(), alt="Profile image") + img.profile-image.lazy(data-src=user.LargeAvatar(), data-webp="true", alt="Profile image") else svg.profile-image(viewBox="0 0 50 50", alt="Profile image") circle.head(cx="25", cy="19", r="10") diff --git a/mixins/Rating.pixy b/mixins/Rating.pixy index de63ee71..775f6a82 100644 --- a/mixins/Rating.pixy +++ b/mixins/Rating.pixy @@ -1,2 +1,5 @@ -component Rating(value float64) - .anime-rating= int(value / 10 + 0.5) \ No newline at end of file +component Rating(value float64, user *arn.User) + if user == nil + .anime-rating= fmt.Sprintf("%.1f", value) + else + .anime-rating= fmt.Sprintf("%." + strconv.Itoa(user.Settings().Format.RatingsPrecision) + "f", value) \ No newline at end of file diff --git a/mixins/SoundTrack.pixy b/mixins/SoundTrack.pixy new file mode 100644 index 00000000..865c648d --- /dev/null +++ b/mixins/SoundTrack.pixy @@ -0,0 +1,29 @@ +component SoundTrack(track *arn.SoundTrack) + SoundTrackMedia(track, track.Media[0]) + +component SoundTrackMedia(track *arn.SoundTrack, media *arn.ExternalMedia) + .sound-track.mountable(id=track.ID) + SoundTrackContent(track, media) + SoundTrackFooter(track) + +component SoundTrackContent(track *arn.SoundTrack, media *arn.ExternalMedia) + .sound-track-content + if track.MainAnime() != nil + a.sound-track-anime-link.ajax(href="/anime/" + track.MainAnime().ID) + img.sound-track-anime-image.lazy(data-src=track.MainAnime().Image.Small, alt=track.MainAnime().Title.Canonical, title=track.MainAnime().Title.Canonical) + + ExternalMedia(media) + +component SoundTrackFooter(track *arn.SoundTrack) + .sound-track-footer + if track.Title == "" + a.ajax(href=track.Link() + "/edit") untitled + else + a.ajax(href=track.Link())= track.Title + span posted + span.utc-date(data-date=track.Created) + span by + a.ajax(href=track.Creator().Link())= track.Creator().Nick + " " + +component ExternalMedia(media *arn.ExternalMedia) + iframe.lazy(data-src=media.EmbedLink(), allowfullscreen="allowfullscreen") \ No newline at end of file diff --git a/mixins/StatusMessage.pixy b/mixins/StatusMessage.pixy new file mode 100644 index 00000000..d5397a27 --- /dev/null +++ b/mixins/StatusMessage.pixy @@ -0,0 +1,5 @@ +component StatusMessage + #status-message.fade.fade-out + #status-message-text + a.status-message-action.action(href="#", data-trigger="click", data-action="closeStatusMessage", aria-label="Close status message") + RawIcon("close") \ No newline at end of file diff --git a/mixins/StatusTabs.pixy b/mixins/StatusTabs.pixy new file mode 100644 index 00000000..7732ab3b --- /dev/null +++ b/mixins/StatusTabs.pixy @@ -0,0 +1,7 @@ +component StatusTabs(urlPrefix string) + .tabs + Tab("Watching", "play", urlPrefix + "/watching") + Tab("Completed", "check", urlPrefix + "/completed") + Tab("Planned", "forward", urlPrefix + "/planned") + Tab("On Hold", "pause", urlPrefix + "/hold") + Tab("Dropped", "stop", urlPrefix + "/dropped") \ No newline at end of file diff --git a/mixins/Tab.pixy b/mixins/Tab.pixy new file mode 100644 index 00000000..c4582768 --- /dev/null +++ b/mixins/Tab.pixy @@ -0,0 +1,4 @@ +component Tab(label string, icon string, url string) + a.tab.action(href=url, data-action="diff", data-trigger="click", aria-label=label) + Icon(icon) + span.tab-text= label \ No newline at end of file diff --git a/mixins/ThreadLink.pixy b/mixins/ThreadLink.pixy index 2c0a7952..564f7787 100644 --- a/mixins/ThreadLink.pixy +++ b/mixins/ThreadLink.pixy @@ -4,10 +4,10 @@ component ThreadLink(thread *arn.Thread) Avatar(thread.Author()) .thread-content-container .thread-content - if thread.Sticky + if thread.Sticky != 0 Icon("thumb-tack") - a.thread-link-title.ajax(href="/threads/" + thread.ID)= thread.Title + a.thread-link-title.ajax(href="/thread/" + thread.ID)= thread.Title .spacer - .thread-reply-count= thread.Replies + .thread-reply-count= len(thread.Posts) .thread-icons Icon(arn.GetForumIcon(thread.Tags[0])) \ No newline at end of file diff --git a/pages/admin/admin.go b/pages/admin/admin.go index df83aa5b..122bbd44 100644 --- a/pages/admin/admin.go +++ b/pages/admin/admin.go @@ -1,29 +1,61 @@ package admin import ( - "sort" - "github.com/aerogo/aero" - "github.com/animenotifier/arn" "github.com/animenotifier/notify.moe/components" "github.com/animenotifier/notify.moe/utils" + "github.com/shirou/gopsutil/host" ) // Get admin page. func Get(ctx *aero.Context) string { user := utils.GetUser(ctx) - if user == nil || user.Role != "admin" { + if user == nil || (user.Role != "admin" && user.Role != "editor") { return ctx.Redirect("/") } - types := []string{} + // // CPU + // cpuUsage := 0.0 + // cpuUsages, err := cpu.Percent(1*time.Second, false) - for typeName := range arn.DB.Types() { - types = append(types, typeName) + // if err == nil { + // cpuUsage = cpuUsages[0] + // } + + // // Memory + // memUsage := 0.0 + // memInfo, _ := mem.VirtualMemory() + + // if err == nil { + // memUsage = memInfo.UsedPercent + // } + + // // Disk + // diskUsage := 0.0 + // diskInfo, err := disk.Usage("/") + + // if err == nil { + // diskUsage = diskInfo.UsedPercent + // } + + // Host + platform, family, platformVersion, _ := host.PlatformInformation() + kernelVersion, _ := host.KernelVersion() + + return ctx.HTML(components.Admin(user, platform, family, platformVersion, kernelVersion)) +} + +func average(floatSlice []float64) float64 { + if len(floatSlice) == 0 { + return 0 } - sort.Strings(types) + var sum float64 - return ctx.HTML(components.Admin(user, types)) + for _, value := range floatSlice { + sum += value + } + + return sum / float64(len(floatSlice)) } diff --git a/pages/admin/admin.pixy b/pages/admin/admin.pixy index 2fd92953..d8fcb7c3 100644 --- a/pages/admin/admin.pixy +++ b/pages/admin/admin.pixy @@ -1,31 +1,75 @@ -component Admin(user *arn.User, types []string) - h2.page-title Admin Panel +component AdminTabs + .tabs + Tab("Server", "server", "/admin") + Tab("WebDev", "html5", "/admin/webdev") + Tab("Purchases", "shopping-cart", "/admin/purchases") - h3 Server - table - //- thead - //- tr - //- th Metric - //- th Value - tbody - tr - td CPU count: - td= runtime.NumCPU() - tr - td Goroutines: - td= runtime.NumGoroutine() - tr - td Go version: - td= runtime.Version() - - h3 Types - table - //- thead - //- tr - //- th Table - tbody - each typeName in types - tr - td= typeName - td - a(href="/api/" + strings.ToLower(typeName) + "/")= "/api/" + strings.ToLower(typeName) + "/" \ No newline at end of file + a.tab.ajax(href="/editor", aria-label="Editor") + Icon("pencil") + span.tab-text Editor + +component Admin(user *arn.User, platform, family, platformVersion, kernelVersion string) + h1.page-title Admin Panel + + AdminTabs + + .admin + //- .widget.mountable + //- h3.widget-title Usage + + //- table + //- tbody + //- tr + //- td CPU usage: + //- td + //- span= int(cpuUsage + 0.5) + //- span % + //- tr + //- td Memory usage: + //- td + //- span= int(memUsage + 0.5) + //- span % + //- tr + //- td Disk usage: + //- td + //- span= int(diskUsage + 0.5) + //- span % + + .widget.mountable + h3.widget-title OS + + table + tbody + tr + td Platform: + td= platform + tr + td Family: + td= family + tr + td Version: + td= platformVersion + tr + td Kernel: + td= kernelVersion + + .widget.mountable + h3.widget-title Hardware + + table + tbody + tr + td CPUs: + td= runtime.NumCPU() + + .widget.mountable + h3.widget-title Go + + table + tbody + tr + td Version: + td= runtime.Version() + tr + td Goroutines: + td= runtime.NumGoroutine() \ No newline at end of file diff --git a/pages/admin/purchases.go b/pages/admin/purchases.go new file mode 100644 index 00000000..4cf1b70b --- /dev/null +++ b/pages/admin/purchases.go @@ -0,0 +1,36 @@ +package admin + +import ( + "net/http" + "sort" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" +) + +// PurchaseHistory ... +func PurchaseHistory(ctx *aero.Context) string { + user := utils.GetUser(ctx) + + if user == nil { + return ctx.Error(http.StatusUnauthorized, "Not logged in", nil) + } + + if user.Role != "admin" { + return ctx.Error(http.StatusUnauthorized, "Not authorized", nil) + } + + purchases, err := arn.AllPurchases() + + if err != nil { + return ctx.Error(http.StatusInternalServerError, "Error fetching shop item data", err) + } + + sort.Slice(purchases, func(i, j int) bool { + return purchases[i].Date > purchases[j].Date + }) + + return ctx.HTML(components.GlobalPurchaseHistory(purchases)) +} diff --git a/pages/admin/purchases.pixy b/pages/admin/purchases.pixy new file mode 100644 index 00000000..e66e02b6 --- /dev/null +++ b/pages/admin/purchases.pixy @@ -0,0 +1,20 @@ +component GlobalPurchaseHistory(purchases []*arn.Purchase) + AdminTabs + + h1.page-title All Purchases + + table + thead + tr.mountable + th User + th Icon + th Item + th.history-quantity Quantity + th.history-price Price + th.history-date Date + tbody + each purchase in purchases + tr.shop-history-item.mountable(data-item-id=purchase.ItemID) + td + a.ajax(href=purchase.User().Link())= purchase.User().Nick + PurchaseInfo(purchase) \ No newline at end of file diff --git a/pages/webdev/webdev.go b/pages/admin/webdev.go similarity index 65% rename from pages/webdev/webdev.go rename to pages/admin/webdev.go index e25bcd67..15d7c97c 100644 --- a/pages/webdev/webdev.go +++ b/pages/admin/webdev.go @@ -1,9 +1,9 @@ -package webdev +package admin import "github.com/aerogo/aero" import "github.com/animenotifier/notify.moe/components" -// Get ... -func Get(ctx *aero.Context) string { +// WebDev ... +func WebDev(ctx *aero.Context) string { return ctx.HTML(components.WebDev()) } diff --git a/pages/admin/webdev.pixy b/pages/admin/webdev.pixy new file mode 100644 index 00000000..f7795b0a --- /dev/null +++ b/pages/admin/webdev.pixy @@ -0,0 +1,48 @@ +component WebDev + AdminTabs + + h1.page-title WebDev + + .webdev + .widget.mountable + h3.widget-title Tests + + .buttons + a.button.mountable(href="https://developers.google.com/speed/pagespeed/insights/?url=https://notify.moe/&tab=desktop", target="_blank", rel="noopener") + Icon("external-link") + span Google PageSpeed + a.button.mountable(href="https://observatory.mozilla.org/analyze.html?host=notify.moe", target="_blank", rel="noopener") + Icon("external-link") + span Mozilla Observatory + a.button.mountable(href="https://html5.validator.nu/?doc=https://notify.moe", target="_blank", rel="noopener") + Icon("external-link") + span HTML5 Validator + a.button.mountable(href="https://testmysite.withgoogle.com/", target="_blank", rel="noopener") + Icon("external-link") + span Mobile Speed + a.button.mountable(href="https://www.webpagetest.org/", target="_blank", rel="noopener") + Icon("external-link") + span Web Page Test + + .widget.mountable + h3.widget-title Browser Support + + .buttons + a.button.mountable(href="http://caniuse.com/#feat=webp", target="_blank", rel="noopener") + Icon("external-link") + span WebP + a.button.mountable(href="http://caniuse.com/#feat=push-api", target="_blank", rel="noopener") + Icon("external-link") + span Push API + a.button.mountable(href="http://caniuse.com/#feat=serviceworkers", target="_blank", rel="noopener") + Icon("external-link") + span Service Worker + a.button.mountable(href="http://caniuse.com/#feat=intersectionobserver", target="_blank", rel="noopener") + Icon("external-link") + span Intersection Observer + a.button.mountable(href="http://caniuse.com/#feat=requestidlecallback", target="_blank", rel="noopener") + Icon("external-link") + span Request Idle Callback + a.button.mountable(href="http://caniuse.com/#feat=css-variables", target="_blank", rel="noopener") + Icon("external-link") + span CSS Variables \ No newline at end of file diff --git a/pages/airing/airing.go b/pages/airing/airing.go deleted file mode 100644 index 05f68638..00000000 --- a/pages/airing/airing.go +++ /dev/null @@ -1,21 +0,0 @@ -package airing - -import ( - "github.com/aerogo/aero" - "github.com/animenotifier/arn" - "github.com/animenotifier/notify.moe/components" -) - -// Get ... -func Get(ctx *aero.Context) string { - var cache arn.ListOfIDs - err := arn.DB.GetObject("Cache", "airing anime", &cache) - - airing, err := arn.GetAiringAnimeCached() - - if err != nil { - return ctx.Error(500, "Couldn't fetch airing anime", err) - } - - return ctx.HTML(components.Airing(airing)) -} diff --git a/pages/airing/airing.pixy b/pages/airing/airing.pixy deleted file mode 100644 index 81f5d4c6..00000000 --- a/pages/airing/airing.pixy +++ /dev/null @@ -1,3 +0,0 @@ -component Airing(animeList []*arn.Anime) - h2.page-title(title=toString(len(animeList)) + " anime") Airing - AnimeGrid(animeList) \ No newline at end of file diff --git a/pages/amvs/amvs.go b/pages/amvs/amvs.go new file mode 100644 index 00000000..f90db340 --- /dev/null +++ b/pages/amvs/amvs.go @@ -0,0 +1,10 @@ +package amvs + +import ( + "github.com/aerogo/aero" +) + +// Get AMVs. +func Get(ctx *aero.Context) string { + return ctx.HTML("Coming soon™.") +} diff --git a/pages/anime/anime.go b/pages/anime/anime.go index e10bb0f1..5ee3d120 100644 --- a/pages/anime/anime.go +++ b/pages/anime/anime.go @@ -2,6 +2,7 @@ package anime import ( "net/http" + "sort" "github.com/aerogo/aero" "github.com/animenotifier/arn" @@ -9,6 +10,10 @@ import ( "github.com/animenotifier/notify.moe/utils" ) +const maxEpisodes = 26 +const maxEpisodesLongSeries = 10 +const maxDescriptionLength = 170 + // Get anime page. func Get(ctx *aero.Context) string { id := ctx.Get("id") @@ -19,5 +24,95 @@ func Get(ctx *aero.Context) string { return ctx.Error(http.StatusNotFound, "Anime not found", err) } - return ctx.HTML(components.Anime(anime, user)) + episodes := anime.Episodes().Items + // episodesReversed := false + + if len(episodes) > maxEpisodes { + // episodesReversed = true + episodes = episodes[len(episodes)-maxEpisodesLongSeries:] + + for i, j := 0, len(episodes)-1; i < j; i, j = i+1, j-1 { + episodes[i], episodes[j] = episodes[j], episodes[i] + } + } + + // Friends watching + var friends []*arn.User + friendsAnimeListItems := map[*arn.User]*arn.AnimeListItem{} + + if user != nil { + friends = user.Follows().Users() + + deleted := 0 + for i := range friends { + j := i - deleted + friendAnimeList := friends[j].AnimeList() + friendAnimeListItem := friendAnimeList.Find(anime.ID) + + if friendAnimeListItem == nil { + friends = friends[:j+copy(friends[j:], friends[j+1:])] + deleted++ + } else { + friendsAnimeListItems[friends[j]] = friendAnimeListItem + } + } + + arn.SortUsersLastSeen(friends) + } + + // Sort relations by start date + relations := anime.Relations() + + if relations != nil { + items := relations.Items + + sort.Slice(items, func(i, j int) bool { + return items[i].Anime().StartDate < items[j].Anime().StartDate + }) + } + + // Soundtracks + tracks, err := arn.FilterSoundTracks(func(track *arn.SoundTrack) bool { + return !track.IsDraft && len(track.Media) > 0 && arn.Contains(track.Tags, "anime:"+anime.ID) + }) + + sort.Slice(tracks, func(i, j int) bool { + return tracks[i].Title < tracks[j].Title + }) + + if err != nil { + return ctx.Error(http.StatusNotFound, "Error fetching soundtracks", err) + } + + // Open Graph + description := anime.Summary + + if len(description) > maxDescriptionLength { + description = description[:maxDescriptionLength-3] + "..." + } + + openGraph := &arn.OpenGraph{ + Tags: map[string]string{ + "og:title": anime.Title.Canonical, + "og:image": anime.Image.Large, + "og:url": "https://" + ctx.App.Config.Domain + anime.Link(), + "og:site_name": "notify.moe", + "og:description": description, + }, + Meta: map[string]string{ + "description": description, + "keywords": anime.Title.Canonical + ",anime", + }, + } + + switch anime.Type { + case "tv": + openGraph.Tags["og:type"] = "video.tv_show" + case "movie": + openGraph.Tags["og:type"] = "video.movie" + } + + ctx.Data = openGraph + + return ctx.HTML(components.Anime(anime, tracks, episodes, friends, friendsAnimeListItems, user)) } diff --git a/pages/anime/anime.pixy b/pages/anime/anime.pixy index 4ef007d7..9429f016 100644 --- a/pages/anime/anime.pixy +++ b/pages/anime/anime.pixy @@ -1,175 +1,226 @@ -component Anime(anime *arn.Anime, user *arn.User) +component Anime(anime *arn.Anime, tracks []*arn.SoundTrack, episodes []*arn.AnimeEpisode, friends []*arn.User, listItems map[*arn.User]*arn.AnimeListItem, user *arn.User) + .anime + .anime-main-column + AnimeMainColumn(anime, tracks, episodes, user) + .anime-side-column + AnimeSideColumn(anime, friends, listItems, user) + +component AnimeMainColumn(anime *arn.Anime, tracks []*arn.SoundTrack, episodes []*arn.AnimeEpisode, user *arn.User) .anime-header(data-id=anime.ID) - if anime.Image.Small != "" - .anime-image-container - img.anime-cover-image(src=anime.Image.Small, alt=anime.Title.Canonical) + if anime.Image.Large != "" + .anime-image-container.mountable + img.anime-cover-image(src=anime.Image.Large, alt=anime.Title.ByUser(user)) + + //- if anime.StartDate != "" + //- .anime-start-date + //- span(title="Start date: " + anime.StartDate)= anime.StartDate[:4] + //- if anime.EndDate != "" && anime.StartDate[:4] != anime.EndDate[:4] + //- span - + //- span(title="End date: " + anime.EndDate)= anime.EndDate[:4] .space .anime-info - h2.anime-title(title=anime.Type)= anime.Title.Canonical + h1.anime-title.mountable(title=anime.Type)= anime.Title.ByUser(user) - //- if user && user.titleLanguage === "japanese" - //- span.second-title(title=anime.Title.English !== anime.Title.Romaji ? anime.Title.English : null)= anime.Title.Romaji - //- else - if anime.Title.Japanese != anime.Title.Canonical - .anime-alternative-title - a(href="http://jisho.org/search/" + anime.Title.Japanese, target="_blank", title="Look up reading on jisho.org", rel="nofollow")= anime.Title.Japanese + h2.anime-alternative-title.mountable + Japanese(anime.Title.Japanese) //- h3.anime-section-name.anime-summary-header Summary - p.anime-summary= anime.Summary + p.anime-summary.mountable= anime.Summary + + AnimeActions(anime, user) + AnimeCharacters(anime) + AnimeRelations(anime, user) + AnimeTracks(anime, tracks) + AnimeEpisodes(episodes) + + //- //- h3.anime-section-name Reviews + //- //- p Coming soon. + + //- .footer + //- span Powered by Kitsu. + +component AnimeSideColumn(anime *arn.Anime, friends []*arn.User, listItems map[*arn.User]*arn.AnimeListItem, user *arn.User) + AnimeTrailer(anime) + AnimeInformation(anime) + AnimeRatings(anime, user) + AnimePopularity(anime) + AnimeFriends(friends, listItems) + AnimeLinks(anime) + +component AnimeActions(anime *arn.Anime, user *arn.User) if user != nil - .buttons.anime-actions + .buttons.anime-actions.mountable + if user.Role == "editor" || user.Role == "admin" + a.button.ajax(href=anime.Link() + "/edit") + Icon("pencil-square-o") + span Edit anime + if user.AnimeList().Contains(anime.ID) - a.button.ajax(href="/+" + user.Nick + "/animelist/" + anime.ID) + a.button.ajax(href="/+" + user.Nick + "/animelist/anime/" + anime.ID) Icon("pencil") span Edit in collection else - button.action(data-action="addAnimeToCollection", data-trigger="click", data-anime-id=anime.ID, data-user-id=user.ID, data-user-nick=user.Nick) + button.action(data-api="/api/animelist/" + user.ID, data-action="addAnimeToCollection", data-trigger="click", data-anime-id=anime.ID) Icon("plus") span Add to collection - h3.anime-section-name Ratings - .anime-rating-categories - .anime-rating-category(title=toString(anime.Rating.Overall / 10)) - .anime-rating-category-name Overall - Rating(anime.Rating.Overall) - .anime-rating-category(title=toString(anime.Rating.Story / 10)) - .anime-rating-category-name Story - Rating(anime.Rating.Story) - .anime-rating-category(title=toString(anime.Rating.Visuals / 10)) - .anime-rating-category-name Visuals - Rating(anime.Rating.Visuals) - .anime-rating-category(title=toString(anime.Rating.Soundtrack / 10)) - .anime-rating-category-name Soundtrack - Rating(anime.Rating.Soundtrack) +component AnimeRatings(anime *arn.Anime, user *arn.User) + section.anime-section.mountable + h3.anime-section-name Ratings - if len(anime.Trailers) > 0 && anime.Trailers[0].Service == "Youtube" && anime.Trailers[0].VideoID != "" - h3.anime-section-name Video - .anime-trailer.video-container - iframe.video(src="https://www.youtube.com/embed/" + anime.Trailers[0].VideoID + "?showinfo=0", allowfullscreen="allowfullscreen") + table.anime-info-table + tr.mountable(data-mountable-type="info") + td.anime-info-key + if anime.Status == "upcoming" + span Hype: + else + span Overall: + td.anime-info-value + Rating(anime.Rating.Overall, user) - //- if anime.Tracks != nil && anime.Tracks.Opening != nil - //- h3.anime-section-name Tracks - //- iframe.anime-track(src="https://w.soundcloud.com/player/?url=" + anime.Tracks.Opening.URI + "?auto_play=false&hide_related=true&show_comments=true&show_user=true&show_reposts=false&visual=true") + if anime.Rating.Story > 0 + tr.mountable(data-mountable-type="info") + td.anime-info-key Story: + td.anime-info-value + Rating(anime.Rating.Story, user) - //- if user && friendsWatching && friendsWatching.length > 0 - //- include ../messages/avatar.pug - - //- h3.anime-section-name Watching - //- .user-list - //- each watcher in friendsWatching - //- +avatar(watcher) + if anime.Rating.Visuals > 0 + tr.mountable(data-mountable-type="info") + td.anime-info-key Visuals: + td.anime-info-value + Rating(anime.Rating.Visuals, user) - //- if len(anime.Relations) > 0 - //- h3.anime-section-name Relations - //- .relations - //- each relation in anime.Relations - //- a.relation.ajax(href="/anime/" + toString(relation.ID), title=relation.Anime().Title.Romaji) - //- img.anime-image.relation-image(src=relation.Anime().Image, alt=relation.Anime().Title.Romaji) - //- .relation-type= arn.Capitalize(relation.Type) + if anime.Rating.Soundtrack > 0 + tr.mountable(data-mountable-type="info") + td.anime-info-key Soundtrack: + td.anime-info-value + Rating(anime.Rating.Soundtrack, user) - //- if len(anime.Genres) > 0 - //- h3.anime-section-name Genres - //- .light-button-group - //- each genre in anime.Genres - //- if genre != "" - //- a.light-button.ajax(href="/genres/" + arn.GetGenreIDByName(genre)) - //- Icon(arn.GetGenreIcon(genre)) - //- span= genre - - //- if len(anime.Studios) > 0 - //- h3.anime-section-name Studios - //- .light-button-group - //- each studio in anime.Studios - //- a.light-button(href="https://anilist.co/studio/" + toString(studio.ID), target="_blank") - //- Icon("building") - //- span= studio.Name +component AnimePopularity(anime *arn.Anime) + if anime.Popularity.Total() > 0 + section.anime-section.mountable + h3.anime-section-name Popularity - //- //-if crunchy - //- //- h3.anime-section-name Episodes + table.anime-info-table + if anime.Popularity.Watching > 0 + tr.mountable(data-mountable-type="info") + td.anime-info-key Watching: + td.anime-info-value= anime.Popularity.Watching - //- if canEdit - //- #staff-info - //- h3.anime-section-name Links - //- table - //- tbody - //- tr - //- td MyAnimeList - //- td - //- input.save-on-change(id="MyAnimeList", type="text", value=providers.MyAnimeList ? providers.MyAnimeList.providerId : ", disabled=(providers.MyAnimeList && providers.MyAnimeList.similarity === 1) ? true : false) - //- td - //- a(href="https://www.google.co.jp/search?q=site:myanimelist.net/anime+" + anime.title.romaji.replace(/ /g, "+"), target="_blank") - //- .fa.fa-search - //- td - //- tr - //- td HummingBird - //- td - //- input.save-on-change(id="HummingBird", type="text", value=providers.HummingBird ? providers.HummingBird.providerId : ", disabled=(providers.HummingBird && providers.HummingBird.similarity === 1) ? true : false) - //- td - //- a(href="https://www.google.co.jp/search?q=site:hummingbird.me/anime+" + anime.title.romaji.replace(/ /g, "+"), target="_blank") - //- .fa.fa-search - //- td - //- tr - //- td AnimePlanet - //- td - //- input.save-on-change(id="AnimePlanet", type="text", value=providers.AnimePlanet ? providers.AnimePlanet.providerId : ", disabled=(providers.AnimePlanet && providers.AnimePlanet.similarity === 1) ? true : false) - //- td - //- a(href="https://www.google.co.jp/search?q=site:anime-planet.com/anime+" + anime.title.english.replace(/ /g, "+"), target="_blank") - //- .fa.fa-search - //- td + if anime.Popularity.Completed > 0 + tr.mountable(data-mountable-type="info") + td.anime-info-key Completed: + td.anime-info-value= anime.Popularity.Completed + + if anime.Popularity.Planned > 0 + tr.mountable(data-mountable-type="info") + td.anime-info-key Planned: + td.anime-info-value= anime.Popularity.Planned - //- - var title = providers.Nyaa ? providers.Nyaa.title : " - //- - var proposedTitle = nyaa.buildNyaaTitle(anime.title.romaji) - //- tr - //- td Nyaa - //- td - //- input.save-on-change(id="Nyaa", type="text", value=title, placeholder=proposedTitle) - //- td - //- a(href="https://www.nyaa.se/?page=search&cats=1_37&filter=0&sort=2&term=" + (title ? title.replace(/ /g, "+") : proposedTitle), target="_blank") - //- .fa.fa-search - //- td - //- if providers.Nyaa && providers.Nyaa.episodes !== undefined - //- span(class=providers.Nyaa.episodes === 0 ? "entry-error" : "entry-ok")= providers.Nyaa.episodes + " eps" + if anime.Popularity.Hold > 0 + tr.mountable(data-mountable-type="info") + td.anime-info-key On hold: + td.anime-info-value= anime.Popularity.Hold - h3.anime-section-name Tracks - p Coming soon. + if anime.Popularity.Dropped > 0 + tr.mountable(data-mountable-type="info") + td.anime-info-key Dropped: + td.anime-info-value= anime.Popularity.Dropped - h3.anime-section-name Reviews - p Coming soon. +component AnimeLinks(anime *arn.Anime) + section.anime-section.mountable + h3.anime-section-name Links + .light-button-group + a.light-button(href="https://kitsu.io/anime/" + anime.ID, target="_blank", rel="noopener") + Icon("external-link") + span Kitsu + + each mapping in anime.Mappings + a.light-button(href=mapping.Link(), target="_blank", rel="noopener") + Icon("external-link") + span= mapping.Name() - h3.anime-section-name Links - .light-button-group - //- if anime.Links != nil - //- each link in anime.Links - //- a.light-button(href=link.URL, target="_blank") - //- Icon("external-link") - //- span= link.Title - a.light-button(href="https://kitsu.io/anime/" + anime.ID, target="_blank", rel="noopener") - Icon("external-link") - span Kitsu +component AnimeRelations(anime *arn.Anime, user *arn.User) + if anime.Relations() != nil && len(anime.Relations().Items) > 0 + section.anime-section.mountable + h3.anime-section-name Relations + .anime-relations + each relation in anime.Relations().Items + a.anime-relation.mountable.ajax(href=relation.Anime().Link(), title=relation.Anime().Title.ByUser(user), data-mountable-type="relation") + img.anime-relation-image.lazy(data-src=relation.Anime().Image.Tiny, alt=relation.Anime().Title.ByUser(user)) + .anime-relation-type= relation.HumanReadableType() + .anime-relation-year + if relation.Anime().StartDate != "" + span= relation.Anime().StartDate[:4] - //- if providers.HummingBird - //- a.light-button(href="https://hummingbird.me/anime/" + providers.HummingBird.providerId, target="_blank") HummingBird +component AnimeTrailer(anime *arn.Anime) + if len(anime.Trailers) > 0 && anime.Trailers[0].Service == "Youtube" && anime.Trailers[0].ServiceID != "" + section.anime-section.mountable + h3.anime-section-name Trailer + .anime-trailer.video-container + iframe.video(src="https://www.youtube.com/embed/" + anime.Trailers[0].ServiceID + "?showinfo=0", allowfullscreen="allowfullscreen") - //- if providers.MyAnimeList - //- a.light-button(href="http://myanimelist.net/anime/" + providers.MyAnimeList.providerId, target="_blank") MyAnimeList +component AnimeFriends(friends []*arn.User, listItems map[*arn.User]*arn.AnimeListItem) + if len(friends) > 0 + section.anime-section.mountable + h3.anime-section-name Friends + + .anime-friends + .user-avatars + each friend in friends + if friend.Nick != "" + .mountable(data-mountable-type="friend") + if friend.IsActive() + FriendEntry(friend, listItems) + else + .inactive-user + FriendEntry(friend, listItems) - //- if providers.AnimePlanet - //- a.light-button(href="http://www.anime-planet.com/anime/" + providers.AnimePlanet.providerId, target="_blank") AnimePlanet +component AnimeInformation(anime *arn.Anime) + section.anime-section.mountable + h3.anime-section-name Information + table.anime-info-table + tr.mountable(data-mountable-type="info") + td.anime-info-key Type: + td.anime-info-value= anime.TypeHumanReadable() - .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 + "." - //- //- - //- h3.anime-section-name Synonyms - //- if anime.title.synonyms - //- ul.anime-synonyms - //- li.anime-japanese-title= anime.title.japanese - //- each synonym in anime.title.synonyms - //- li= synonym \ No newline at end of file + if anime.EpisodeCount != 0 + tr.mountable(data-mountable-type="info") + td.anime-info-key Episodes: + td.anime-info-value= anime.EpisodeCount + + if anime.EpisodeLength != 0 + tr.mountable(data-mountable-type="info") + td.anime-info-key Episode length: + td.anime-info-value= strconv.Itoa(anime.EpisodeLength) + " min." + + tr.mountable(data-mountable-type="info") + td.anime-info-key Status: + td.anime-info-value= anime.StatusHumanReadable() + + if anime.StartDate == anime.EndDate && anime.StartDate != "" && anime.EndDate != "" + if anime.StartDate != "" + tr.mountable(data-mountable-type="info") + td.anime-info-key Airing date: + td.anime-info-value= anime.StartDate + else + if anime.StartDate != "" + tr.mountable(data-mountable-type="info") + td.anime-info-key Start date: + td.anime-info-value= anime.StartDate + + if anime.EndDate != "" + tr.mountable(data-mountable-type="info") + td.anime-info-key End date: + td.anime-info-value= anime.EndDate + + if anime.FirstChannel != "" + tr.mountable(data-mountable-type="info") + td.anime-info-key Channel: + td.anime-info-value= anime.FirstChannel + +component FriendEntry(friend *arn.User, listItems map[*arn.User]*arn.AnimeListItem) + CustomAvatar(friend, listItems[friend].Link(friend.Nick), friend.Nick + " => " + listItems[friend].Status + " | " + toString(listItems[friend].Episodes) + " eps | " + fmt.Sprintf("%.1f", listItems[friend].Rating.Overall) + " rating") diff --git a/pages/anime/anime.scarlet b/pages/anime/anime.scarlet index b1eb03fb..82d38b6d 100644 --- a/pages/anime/anime.scarlet +++ b/pages/anime/anime.scarlet @@ -1,20 +1,110 @@ -.anime-header - horizontal +.anime + vertical -< 800px +.anime-main-column + vertical + flex 1 + +.anime-side-column + vertical + margin-left 0 + margin-top 1rem + flex-basis 300px + +> 1350px + .anime + horizontal + + .anime-side-column + margin-left content-padding + margin-top 0 + +.anime-header + vertical + +.anime-title + text-align center + margin-bottom 0.5rem + +> 800px .anime-header - vertical + horizontal + padding-bottom content-padding + border-bottom 1px solid rgba(0, 0, 0, 0.05) + + .anime-title + text-align left + + .anime-alternative-title + text-align left + + .anime-actions + flex 1 + justify-content flex-end + align-items flex-end + + button, + .button + margin-right 0 + margin-bottom 0 + +.anime-info-table + margin 0 + // width 100% + // max-width 600px + +.anime-info-value + text-align right + +.anime-soundtracks + vertical + margin-top 1rem + +.anime-soundtrack + vertical + width 100% + margin-bottom 0.5rem + +> 500px + .anime-soundtracks + horizontal-wrap + justify-content flex-start + margin-top 0 + + .anime-soundtrack + max-width 200px + margin calc(content-padding / 2) + +// .anime-soundtracks +// horizontal-wrap + +// .anime-soundtrack +// flex-basis 400px +// padding-bottom video-padding + +.anime-section + margin-top 1rem + + :first-child + margin-top 0 !important .anime-section-name font-weight bold .anime-image-container - horizontal - justify-content center - align-items flex-start + vertical + justify-content flex-start + align-items center + +.anime-start-date + font-size 0.7rem + line-height 1.7em + opacity 0.65 + margin-top 0.5rem .anime-cover-image - width 230px + // width 142px + width 250px height auto border-radius 3px @@ -23,7 +113,10 @@ saturate-up shadow-up - + +.anime-summary + // ... + .anime-info vertical flex 1 @@ -32,52 +125,52 @@ width content-padding height content-padding -.anime-title - text-align left - margin-bottom 0.5rem - .anime-alternative-title font-size 0.9em + margin-top 0 margin-bottom 0.5rem - a - color rgba(60, 60, 60, 0.5) !important + text-align center + font-weight normal + line-height content-line-height + + .japanese + color anime-alternative-title-color !important .anime-actions horizontal justify-content center // Action button margin - margin calc(content-padding - 0.5rem) -0.5rem + // margin calc(content-padding - 0.5rem) -0.5rem // Setting z-index requires setting a background as well - z-index 10 - background-color bg-color + // z-index 10 -> 900px - .anime-actions - position absolute - top 0 - right content-padding +// > 1450px +// .anime-actions +// position fixed +// bottom 0 +// right content-padding -.anime-rating-categories - horizontal - width 100% +// .anime-rating-categories +// horizontal +// width 100% -.anime-rating-category - ui-element - flex 1 - text-align center - margin 0.5rem +// .anime-rating-category +// ui-element +// flex 1 +// text-align center +// margin 0.5rem -.anime-rating-category-name - font-size 1.3rem - margin-top 0.5rem +// .anime-rating-category-name +// font-size 1.3rem +// margin-top 0.5rem -.anime-rating - margin 0.5rem - letter-spacing 3px - font-size 1.3rem - color link-color +// .anime-rating +// margin 0.5rem +// letter-spacing 3px +// font-size 1.3rem +// color link-color .anime-widget margin-top 0.6rem @@ -87,11 +180,21 @@ .anime-rating-categories vertical +.anime-friends + .user-avatars + justify-content flex-start + .footer font-size 0.8rem opacity 0.7 margin-top 0.5rem + &.mountable + opacity 0 !important + + &.mounted + opacity 0.7 !important + .relations horizontal-wrap diff --git a/pages/anime/animetabs.pixy b/pages/anime/animetabs.pixy new file mode 100644 index 00000000..8fa01e18 --- /dev/null +++ b/pages/anime/animetabs.pixy @@ -0,0 +1,6 @@ +component AnimeTabs(anime *arn.Anime) + .tabs + Tab("Anime", "tv", anime.Link()) + Tab("Episodes", "list-ol", anime.Link() + "/episodes") + Tab("Characters", "male", anime.Link() + "/characters") + Tab("Tracks", "music", anime.Link() + "/tracks") \ No newline at end of file diff --git a/pages/anime/character.scarlet b/pages/anime/character.scarlet new file mode 100644 index 00000000..39cddcba --- /dev/null +++ b/pages/anime/character.scarlet @@ -0,0 +1,31 @@ +.characters + horizontal-wrap + +.character + vertical + align-items center + margin 0.5rem + default-transition + transform scale(1) + + :hover + transform scale(1.05) + + // .character-name + // opacity 1 + +.character-image + border-radius 3px + box-shadow shadow-medium + object-fit cover + +// .character-name +// font-size 0.75rem +// color text-color +// opacity 0.5 +// transition opacity transition-speed ease + +.character-image + width 112px + height 112px + border-radius 10% \ No newline at end of file diff --git a/pages/anime/characters.go b/pages/anime/characters.go new file mode 100644 index 00000000..e03b8462 --- /dev/null +++ b/pages/anime/characters.go @@ -0,0 +1,22 @@ +package anime + +import ( + "net/http" + + "github.com/animenotifier/notify.moe/components" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" +) + +// Characters ... +func Characters(ctx *aero.Context) string { + id := ctx.Get("id") + anime, err := arn.GetAnime(id) + + if err != nil { + return ctx.Error(http.StatusNotFound, "Anime not found", err) + } + + return ctx.HTML(components.AnimeCharacters(anime)) +} diff --git a/pages/anime/characters.pixy b/pages/anime/characters.pixy new file mode 100644 index 00000000..2cf9174b --- /dev/null +++ b/pages/anime/characters.pixy @@ -0,0 +1,11 @@ +component AnimeCharacters(anime *arn.Anime) + //- AnimeTabs(anime) + + if anime.Characters() != nil && len(anime.Characters().Items) > 0 + .anime-section + h3.anime-section-name Characters + .characters + each character in anime.Characters().Items + if character.Role == "main" && character.Character() != nil + .mountable(data-mountable-type="character") + Character(character.Character()) \ No newline at end of file diff --git a/pages/anime/episode.scarlet b/pages/anime/episode.scarlet new file mode 100644 index 00000000..e84bdd8e --- /dev/null +++ b/pages/anime/episode.scarlet @@ -0,0 +1,24 @@ +.episodes + max-width 100% + +.episode + horizontal + +.episode-number + flex-basis 3.2rem + // text-align right + +.episode-title + flex 1 + +.episode-airing-date-start + flex-basis 150px + text-align right + +< 800px + .episode-airing-date-start + display none + +< 320px + .episode-actions + display none \ No newline at end of file diff --git a/pages/anime/episodes.go b/pages/anime/episodes.go new file mode 100644 index 00000000..901d3b75 --- /dev/null +++ b/pages/anime/episodes.go @@ -0,0 +1,23 @@ +package anime + +import ( + "net/http" + + "github.com/animenotifier/notify.moe/components" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" +) + +// Episodes ... +func Episodes(ctx *aero.Context) string { + id := ctx.Get("id") + + anime, err := arn.GetAnime(id) + + if err != nil { + return ctx.Error(http.StatusNotFound, "Anime not found", err) + } + + return ctx.HTML(components.AnimeEpisodes(anime.Episodes().Items)) +} diff --git a/pages/anime/episodes.pixy b/pages/anime/episodes.pixy new file mode 100644 index 00000000..5b149e8b --- /dev/null +++ b/pages/anime/episodes.pixy @@ -0,0 +1,26 @@ +component AnimeEpisodes(episodes []*arn.AnimeEpisode) + if len(episodes) > 0 + .anime-section.mountable + h3.anime-section-name Episodes + table.episodes + tbody + each episode in episodes + tr.episode.mountable(data-mountable-type="episode") + td.episode-number + if episode.Number != -1 + span= episode.Number + td.episode-title + if episode.Title.Japanese != "" + Japanese(episode.Title.Japanese) + else + span - + td.episode-actions + for name, link := range episode.Links + a(href=link, target="_blank", rel="noopener", title="Watch episode " + toString(episode.Number) + " on " + name) + RawIcon("eye") + //- a(href="https://translate.google.com/#ja/en/" + episode.Title.Japanese, target="_blank", rel="noopener") + //- RawIcon("google") + if validator.IsValidDate(episode.AiringDate.Start) + td.episode-airing-date-start.utc-airing-date(data-start-date=episode.AiringDate.Start, data-end-date=episode.AiringDate.End, data-episode-number=episode.Number)= episode.AiringDate.StartDateHuman() + else + td.episode-airing-date-start \ No newline at end of file diff --git a/pages/anime/relation.scarlet b/pages/anime/relation.scarlet new file mode 100644 index 00000000..aab9872c --- /dev/null +++ b/pages/anime/relation.scarlet @@ -0,0 +1,24 @@ +.anime-relations + horizontal-wrap + +.anime-relation + anime-mini-item + vertical + +.anime-relation-image + anime-mini-item-image + +.anime-relation-type, +.anime-relation-year + font-size 0.7rem + line-height 1.7em + text-align center + color text-color + opacity 0.8 + +.anime-relation-type + margin-top 0.2rem + +.anime-relation-year + font-size 0.6rem + opacity 0.65 \ No newline at end of file diff --git a/pages/anime/tracks.go b/pages/anime/tracks.go new file mode 100644 index 00000000..c82a1f95 --- /dev/null +++ b/pages/anime/tracks.go @@ -0,0 +1,31 @@ +package anime + +import ( + "net/http" + + "github.com/animenotifier/notify.moe/components" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" +) + +// Tracks ... +func Tracks(ctx *aero.Context) string { + id := ctx.Get("id") + + anime, err := arn.GetAnime(id) + + if err != nil { + return ctx.Error(http.StatusNotFound, "Anime not found", err) + } + + tracks, err := arn.FilterSoundTracks(func(track *arn.SoundTrack) bool { + return !track.IsDraft && len(track.Media) > 0 && arn.Contains(track.Tags, "anime:"+anime.ID) + }) + + if err != nil { + return ctx.Error(http.StatusNotFound, "Error fetching soundtracks", err) + } + + return ctx.HTML(components.AnimeTracks(anime, tracks)) +} diff --git a/pages/anime/tracks.pixy b/pages/anime/tracks.pixy new file mode 100644 index 00000000..72b93e15 --- /dev/null +++ b/pages/anime/tracks.pixy @@ -0,0 +1,12 @@ +component AnimeTracks(anime *arn.Anime, tracks []*arn.SoundTrack) + //- AnimeTabs(anime) + + if len(tracks) > 0 + .anime-section.mountable + h3.anime-section-name Tracks + .anime-soundtracks + each track in tracks + .anime-soundtrack.mountable(data-mountable-type="track") + .video-container + iframe.video.lazy(data-src=track.Media[0].EmbedLink(), allowfullscreen="allowfullscreen") + a.sound-track-footer.ajax(href=track.Link())= track.Title \ No newline at end of file diff --git a/pages/animeepisode/animeepisode.go b/pages/animeepisode/animeepisode.go new file mode 100644 index 00000000..736ac5e4 --- /dev/null +++ b/pages/animeepisode/animeepisode.go @@ -0,0 +1,7 @@ +package animeepisode + +import "github.com/aerogo/aero" + +func Get(ctx *aero.Context) string { + return ctx.HTML("") +} diff --git a/pages/animelist/animelist.go b/pages/animelist/animelist.go index 6851effb..71198c7c 100644 --- a/pages/animelist/animelist.go +++ b/pages/animelist/animelist.go @@ -2,7 +2,6 @@ package animelist import ( "net/http" - "sort" "github.com/aerogo/aero" "github.com/animenotifier/arn" @@ -26,9 +25,8 @@ func Get(ctx *aero.Context) string { 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() - }) + animeList.PrefetchAnime() + animeList.Sort() - return ctx.HTML(components.AnimeList(animeList, user)) + return ctx.HTML(components.ProfileAnimeLists(animeList.SplitByStatus(), animeList.User(), user, ctx.URI())) } diff --git a/pages/animelist/animelist.pixy b/pages/animelist/animelist.pixy index 7ea276f8..96b7e4b1 100644 --- a/pages/animelist/animelist.pixy +++ b/pages/animelist/animelist.pixy @@ -1,26 +1,81 @@ -component AnimeList(animeList *arn.AnimeList, user *arn.User) +component ProfileAnimeLists(animeLists map[string]*arn.AnimeList, viewUser *arn.User, user *arn.User, uri string) + ProfileHeader(viewUser, user, uri) + + h1.page-title.anime-list-owner= viewUser.Nick + "'s collection" + + AnimeLists(animeLists, viewUser, user) + + //- for status, animeList := range animeLists + //- h3= status + //- AnimeList(animeList, user) + +component AnimeLists(animeLists map[string]*arn.AnimeList, viewUser *arn.User, user *arn.User) + if len(animeLists[arn.AnimeListStatusWatching].Items) == 0 && len(animeLists[arn.AnimeListStatusCompleted].Items) == 0 && len(animeLists[arn.AnimeListStatusPlanned].Items) == 0 && len(animeLists[arn.AnimeListStatusHold].Items) == 0 && len(animeLists[arn.AnimeListStatusDropped].Items) == 0 + p.no-data.mountable= viewUser.Nick + " hasn't added any anime yet." + else + if len(animeLists[arn.AnimeListStatusWatching].Items) > 0 + .anime-list-container + h3.status-name Watching + AnimeList(animeLists[arn.AnimeListStatusWatching], viewUser, user) + + if len(animeLists[arn.AnimeListStatusCompleted].Items) > 0 + .anime-list-container + h3.status-name Completed + AnimeList(animeLists[arn.AnimeListStatusCompleted], viewUser, user) + + if len(animeLists[arn.AnimeListStatusPlanned].Items) > 0 + .anime-list-container + h3.status-name Planned + AnimeList(animeLists[arn.AnimeListStatusPlanned], viewUser, user) + + if len(animeLists[arn.AnimeListStatusHold].Items) > 0 + .anime-list-container + h3.status-name On hold + AnimeList(animeLists[arn.AnimeListStatusHold], viewUser, user) + + if len(animeLists[arn.AnimeListStatusDropped].Items) > 0 + .anime-list-container + h3.status-name Dropped + AnimeList(animeLists[arn.AnimeListStatusDropped], viewUser, user) + +component AnimeList(animeList *arn.AnimeList, viewUser *arn.User, user *arn.User) table.anime-list - thead - tr - th.anime-list-item-name Anime - th.anime-list-item-episodes Episodes - th.anime-list-item-rating Rating - if user != nil - th.anime-list-item-actions Actions tbody each item in animeList.Items - tr.anime-list-item.mountable(title=item.Notes) + tr.anime-list-item.mountable(title=item.Notes, data-api="/api/animelist/" + animeList.UserID + "/field/Items[AnimeID=\"" + item.AnimeID + "\"]") + td.anime-list-item-image-container + a.ajax(href=item.Anime().Link()) + img.anime-list-item-image.lazy(data-src=item.Anime().Image.Tiny, alt=item.Anime().Title.ByUser(user)) + td.anime-list-item-name - a.ajax(href=item.Link(animeList.User().Nick))= item.Anime().Title.Canonical + a.ajax(href=item.Link(animeList.User().Nick))= item.Anime().Title.ByUser(user) + + td.anime-list-item-actions + if user != nil && item.Status == arn.AnimeListStatusWatching && item.Anime().EpisodeByNumber(item.Episodes + 1) != nil + for _, link := range item.Anime().EpisodeByNumber(item.Episodes + 1).Links + a(href=link, title="Watch episode " + toString(item.Episodes + 1) + " on twist.moe", target="_blank", rel="noopener") + RawIcon("eye") + //- a(href=arn.Nyaa.GetLink(item.Anime()), title="Search on Nyaa", target="_blank", rel="noopener") + //- RawIcon("download") + + td.anime-list-item-airing-date + if (item.Status == arn.AnimeListStatusWatching || item.Status == arn.AnimeListStatusPlanned) && item.Anime().UpcomingEpisode() != nil + span.utc-airing-date(data-start-date=item.Anime().UpcomingEpisode().Episode.AiringDate.Start, data-end-date=item.Anime().UpcomingEpisode().Episode.AiringDate.End, data-episode-number=item.Anime().UpcomingEpisode().Episode.Number) + td.anime-list-item-episodes - .anime-list-item-episodes-watched= item.Episodes + .anime-list-item-episodes-watched + .action(contenteditable=utils.SameUser(user, viewUser), data-field="Episodes", data-type="number", data-trigger="focusout", data-action="save")= item.Episodes + if item.Status == arn.AnimeListStatusWatching + .plus-episode.action(data-action="increaseEpisode", data-trigger="click") + + .anime-list-item-episodes-separator / .anime-list-item-episodes-max= item.Anime().EpisodeCountString() - //- .anime-list-item-episodes-edit - //- a.ajax(href=, title="Edit anime") - //- RawIcon("pencil") - td.anime-list-item-rating= item.FinalRating() - if user != nil - td.anime-list-item-actions - a(href=arn.Nyaa.GetLink(item.Anime()), title="Search on Nyaa", target="_blank", rel="noopener") - RawIcon("download") \ No newline at end of file + + td.anime-list-item-rating(title="Overall rating") + .action(contenteditable=utils.SameUser(user, viewUser), data-field="Rating.Overall", data-type="number", data-trigger="focusout", data-action="save")= utils.FormatRating(item.Rating.Overall) + //- td.anime-list-item-rating(title="Story rating") + //- .action(contenteditable=utils.SameUser(user, viewUser), data-field="Rating.Story", data-type="number", data-trigger="focusout", data-action="save")= fmt.Sprintf("%.1f", item.Rating.Story) + //- td.anime-list-item-rating(title="Visuals rating") + //- .action(contenteditable=utils.SameUser(user, viewUser), data-field="Rating.Visuals", data-type="number", data-trigger="focusout", data-action="save")= fmt.Sprintf("%.1f", item.Rating.Visuals) + //- td.anime-list-item-rating(title="Soundtrack rating") + //- .action(contenteditable=utils.SameUser(user, viewUser), data-field="Rating.Soundtrack", data-type="number", data-trigger="focusout", data-action="save")= fmt.Sprintf("%.1f", item.Rating.Soundtrack) diff --git a/pages/animelist/animelist.scarlet b/pages/animelist/animelist.scarlet index 76badd5d..2934ffcb 100644 --- a/pages/animelist/animelist.scarlet +++ b/pages/animelist/animelist.scarlet @@ -1,28 +1,63 @@ -.anime-list +.anime-list-container vertical width 100% + max-width table-width-normal + margin 0 auto + margin-bottom 1rem - tr - horizontal - - thead - display none +.anime-list + vertical + +.anime-list-item + horizontal + + td + display flex + align-items center + +.anime-list-item-image-container + padding 0 + width 39px + +.anime-list-item-image + width 39px + height 39px + border-radius 2px + object-fit cover .anime-list-item-name flex 1 - white-space nowrap - text-overflow ellipsis - overflow hidden + clip-long-text + + a + color anime-list-item-name-color + :hover + color link-hover-color .anime-list-item-episodes horizontal justify-content flex-end text-align right white-space nowrap - flex-basis 120px + width 130px + + :hover + .plus-episode + opacity 1 + pointer-events auto .anime-list-item-episodes-watched flex 0.4 + horizontal + justify-content flex-end + +.plus-episode + display inline-block + cursor pointer + opacity 0 + pointer-events none + margin-left 1px + transition opacity transition-speed ease .anime-list-item-episodes-separator flex 0.2 @@ -32,25 +67,40 @@ flex 0.4 opacity 0.5 -// .anime-list-item-episodes-edit -// flex 0.5 +.anime-list-item-rating + text-align right + justify-content flex-end + width 65px + +.anime-list-item-actions + display none !important // // Beautify icon alignment // .raw-icon -// margin-bottom -2px +// margin-bottom -4px -.anime-list-item-rating - flex-basis 100px - text-align center +> 740px + .anime-list-item-actions + display flex !important + width 30px -.anime-list-item-actions - flex-basis 40px - text-align right + :empty + display none !important - // Beautify icon alignment - .raw-icon - margin-bottom -4px +.anime-list-item-airing-date + display none !important + +> 700px + .anime-list-item-airing-date + display flex !important + text-align right + width 150px + opacity 0.8 + justify-content flex-end < 1100px .anime-list-item-rating - display none \ No newline at end of file + display none !important + +.fill-screen + min-height calc(100vh - content-padding * 2 - 1rem - 43px - 23px) \ No newline at end of file diff --git a/pages/animelist/status.go b/pages/animelist/status.go new file mode 100644 index 00000000..567fe1af --- /dev/null +++ b/pages/animelist/status.go @@ -0,0 +1,46 @@ +package animelist + +import ( + "net/http" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" +) + +// FilterByStatus returns a handler for the given anime list item status. +func FilterByStatus(status string) aero.Handle { + return func(ctx *aero.Context) string { + user := utils.GetUser(ctx) + list, response := statusList(ctx, status) + + if response != "" { + return response + } + + return ctx.HTML(components.ProfileAnimeListFilteredByStatus(list, list.User(), user, status, ctx.URI())) + } +} + +// statusList handles the request for an anime list with a given status. +func statusList(ctx *aero.Context, status string) (*arn.AnimeList, string) { + nick := ctx.Get("nick") + viewUser, err := arn.GetUserByNick(nick) + + if err != nil { + return nil, ctx.Error(http.StatusNotFound, "User not found", err) + } + + animeList := viewUser.AnimeList() + + if animeList == nil { + return nil, ctx.Error(http.StatusNotFound, "Anime list not found", nil) + } + + watchingList := animeList.FilterStatus(status) + watchingList.PrefetchAnime() + watchingList.Sort() + + return watchingList, "" +} diff --git a/pages/animelist/status.pixy b/pages/animelist/status.pixy new file mode 100644 index 00000000..ff66a1c7 --- /dev/null +++ b/pages/animelist/status.pixy @@ -0,0 +1,12 @@ +component ProfileAnimeListFilteredByStatus(animeList *arn.AnimeList, viewUser *arn.User, user *arn.User, status string, uri string) + ProfileHeader(viewUser, user, uri) + + AnimeListFilteredByStatus(animeList, viewUser, user, status) + +component AnimeListFilteredByStatus(animeList *arn.AnimeList, viewUser *arn.User, user *arn.User, status string) + if len(animeList.Items) == 0 + p.no-data.mountable= viewUser.Nick + " hasn't added any anime to this list yet." + else + .anime-list-container.fill-screen + //- h3.status-name= arn.ListItemStatusName(status) + AnimeList(animeList, viewUser, user) \ No newline at end of file diff --git a/pages/animelistitem/animelistitem.go b/pages/animelistitem/animelistitem.go index 5afa5a56..97365146 100644 --- a/pages/animelistitem/animelistitem.go +++ b/pages/animelistitem/animelistitem.go @@ -7,12 +7,12 @@ import ( "github.com/aerogo/aero" "github.com/animenotifier/arn" "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" ) // Get anime page. func Get(ctx *aero.Context) string { - // user := utils.GetUser(ctx) - + user := utils.GetUser(ctx) nick := ctx.Get("nick") viewUser, err := arn.GetUserByNick(nick) @@ -35,7 +35,7 @@ func Get(ctx *aero.Context) string { anime := item.Anime() - return ctx.HTML(components.AnimeListItem(animeList.User(), item, anime)) + return ctx.HTML(components.AnimeListItem(animeList.User(), item, anime, user)) } // t := reflect.TypeOf(item).Elem() diff --git a/pages/animelistitem/animelistitem.pixy b/pages/animelistitem/animelistitem.pixy index 4c1276c4..54cb7b9e 100644 --- a/pages/animelistitem/animelistitem.pixy +++ b/pages/animelistitem/animelistitem.pixy @@ -1,12 +1,23 @@ -component AnimeListItem(viewUser *arn.User, item *arn.AnimeListItem, anime *arn.Anime) - .widgets.mountable - .widget.anime-list-item-view(data-api="/api/animelist/" + viewUser.ID + "/update/" + anime.ID) - h2= anime.Title.Canonical - - InputNumber("Episodes", float64(item.Episodes), "Episodes", "Number of episodes you watched", "0", arn.EpisodeCountMax(anime.EpisodeCount), "1") +component AnimeListItem(viewUser *arn.User, item *arn.AnimeListItem, anime *arn.Anime, user *arn.User) + .widget-form.mountable + .widget.anime-list-item-view(data-api="/api/animelist/" + viewUser.ID + "/field/Items[AnimeID=\"" + anime.ID + "\"]") + h1= anime.Title.ByUser(user) + .anime-list-item-progress-edit + .anime-list-item-episodes-edit + InputNumber("Episodes", float64(item.Episodes), "Episodes", "Number of episodes you watched", "0", arn.EpisodeCountMax(anime.EpisodeCount), "1") + + .widget-section.anime-list-item-status-edit + label(for="Status") Status: + select.widget-ui-element.action(id="Status", data-field="Status", value=item.Status, data-action="save", data-trigger="change") + option(value=arn.AnimeListStatusWatching) Watching + option(value=arn.AnimeListStatusCompleted) Completed + option(value=arn.AnimeListStatusPlanned) Plan to watch + option(value=arn.AnimeListStatusHold) On hold + option(value=arn.AnimeListStatusDropped) Dropped + .anime-list-item-rating-edit - InputNumber("Rating.Overall", item.Rating.Overall, "Overall", "Overall rating on a scale of 0 to 10", "0", "10", "0.1") + InputNumber("Rating.Overall", item.Rating.Overall, arn.OverallRatingName(item.Episodes), "Overall rating on a scale of 0 to 10", "0", "10", "0.1") InputNumber("Rating.Story", item.Rating.Story, "Story", "Story rating on a scale of 0 to 10", "0", "10", "0.1") InputNumber("Rating.Visuals", item.Rating.Visuals, "Visuals", "Visuals rating on a scale of 0 to 10", "0", "10", "0.1") InputNumber("Rating.Soundtrack", item.Rating.Soundtrack, "Soundtrack", "Soundtrack rating on a scale of 0 to 10", "0", "10", "0.1") @@ -16,12 +27,12 @@ component AnimeListItem(viewUser *arn.User, item *arn.AnimeListItem, anime *arn. InputTextArea("Notes", item.Notes, "Notes", "Your notes") .buttons.mountable - a.ajax.button(href="/+" + viewUser.Nick + "/animelist") + a.ajax.button(href="/animelist/" + item.Status) Icon("list") span View collection a.ajax.button(href=anime.Link()) Icon("search-plus") span View anime - button.action(data-action="removeAnimeFromCollection", data-trigger="click", data-anime-id=anime.ID, data-user-id=viewUser.ID, data-user-nick=viewUser.Nick) + button.action(data-action="removeAnimeFromCollection", data-trigger="click", data-api="/api/animelist/" + viewUser.ID, data-anime-id=anime.ID, data-nick=viewUser.Nick) Icon("trash") span Remove from collection \ No newline at end of file diff --git a/pages/animelistitem/animelistitem.scarlet b/pages/animelistitem/animelistitem.scarlet index 8603071c..9fa47362 100644 --- a/pages/animelistitem/animelistitem.scarlet +++ b/pages/animelistitem/animelistitem.scarlet @@ -1,10 +1,18 @@ -.anime-list-item-view-image - max-width 55px - margin-bottom 1rem +// .anime-list-item-progress-edit +// horizontal-wrap +// justify-content space-between +// width 100% + +// .anime-list-item-episodes-edit +// flex 1 +// margin-right content-padding + +// .anime-list-item-status-edit +// flex-basis 50% .anime-list-item-rating-edit horizontal-wrap justify-content space-between width 100% - .widget-input - max-width 120px \ No newline at end of file + .widget-section + max-width 20% \ No newline at end of file diff --git a/pages/apiview/api.go b/pages/apiview/api.go new file mode 100644 index 00000000..31213c6e --- /dev/null +++ b/pages/apiview/api.go @@ -0,0 +1,22 @@ +package apiview + +import ( + "sort" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" +) + +// Get api page. +func Get(ctx *aero.Context) string { + types := []string{} + + for typeName := range arn.DB.Types() { + types = append(types, typeName) + } + + sort.Strings(types) + + return ctx.HTML(components.API(types)) +} diff --git a/pages/apiview/api.pixy b/pages/apiview/api.pixy new file mode 100644 index 00000000..4ff6fc7d --- /dev/null +++ b/pages/apiview/api.pixy @@ -0,0 +1,13 @@ +component API(types []string) + h1 API + + table + //- thead + //- tr + //- th Table + tbody + each typeName in types + tr + td= typeName + td + a(href="/api/" + strings.ToLower(typeName) + "/")= "/api/" + strings.ToLower(typeName) + "/" \ No newline at end of file diff --git a/pages/artworks/artworks.go b/pages/artworks/artworks.go new file mode 100644 index 00000000..58b0893e --- /dev/null +++ b/pages/artworks/artworks.go @@ -0,0 +1,10 @@ +package artworks + +import ( + "github.com/aerogo/aero" +) + +// Get artworks. +func Get(ctx *aero.Context) string { + return ctx.HTML("Coming soon™.") +} diff --git a/pages/best/best.go b/pages/best/best.go new file mode 100644 index 00000000..5257a910 --- /dev/null +++ b/pages/best/best.go @@ -0,0 +1,13 @@ +package best + +import ( + "github.com/aerogo/aero" + "github.com/animenotifier/notify.moe/components" +) + +const maxEntries = 7 + +// Get search page. +func Get(ctx *aero.Context) string { + return ctx.HTML(components.BestAnime(nil, nil, nil, nil, nil)) +} diff --git a/pages/best/best.pixy b/pages/best/best.pixy new file mode 100644 index 00000000..3455ed32 --- /dev/null +++ b/pages/best/best.pixy @@ -0,0 +1,15 @@ +component BestAnime(overall []*arn.Anime, story []*arn.Anime, visuals []*arn.Anime, soundtrack []*arn.Anime, airing []*arn.Anime) + h2 Currently Airing + AnimeGrid(airing) + + h2 Best Overall + AnimeGrid(overall) + + h2 Best Story + AnimeGrid(story) + + h2 Best Visuals + AnimeGrid(visuals) + + h2 Best Soundtrack + AnimeGrid(soundtrack) \ No newline at end of file diff --git a/pages/character/character.go b/pages/character/character.go new file mode 100644 index 00000000..38876149 --- /dev/null +++ b/pages/character/character.go @@ -0,0 +1,21 @@ +package character + +import ( + "net/http" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" +) + +// Get character. +func Get(ctx *aero.Context) string { + id := ctx.Get("id") + character, err := arn.GetCharacter(id) + + if err != nil { + return ctx.Error(http.StatusNotFound, "Character not found", err) + } + + return ctx.HTML(components.CharacterDetails(character)) +} diff --git a/pages/character/character.pixy b/pages/character/character.pixy new file mode 100644 index 00000000..2c437e44 --- /dev/null +++ b/pages/character/character.pixy @@ -0,0 +1,7 @@ +component CharacterDetails(character *arn.Character) + h1= character.Name + + p + img(src=character.Image, alt=character.Name) + + p.character-description= character.Description \ No newline at end of file diff --git a/pages/character/character.scarlet b/pages/character/character.scarlet new file mode 100644 index 00000000..49043fb8 --- /dev/null +++ b/pages/character/character.scarlet @@ -0,0 +1,4 @@ +.character-description + max-width 800px + margin 0 auto + margin-top 1.6rem \ No newline at end of file diff --git a/pages/charge/charge.go b/pages/charge/charge.go new file mode 100644 index 00000000..441b4563 --- /dev/null +++ b/pages/charge/charge.go @@ -0,0 +1,21 @@ +package charge + +import ( + "net/http" + + "github.com/animenotifier/notify.moe/components" + + "github.com/aerogo/aero" + "github.com/animenotifier/notify.moe/utils" +) + +// Get charge page. +func Get(ctx *aero.Context) string { + user := utils.GetUser(ctx) + + if user == nil { + return ctx.Error(http.StatusUnauthorized, "Not logged in", nil) + } + + return ctx.HTML(components.Charge(user)) +} diff --git a/pages/charge/charge.pixy b/pages/charge/charge.pixy new file mode 100644 index 00000000..239e84a5 --- /dev/null +++ b/pages/charge/charge.pixy @@ -0,0 +1,29 @@ +component Charge(user *arn.User) + ShopTabs(user) + + h1.mountable Charge up + + p.text-center.mountable You can add balance via PayPal. 1 Japanese Yen equals 1 Gem. + + .buttons + button.action.mountable(data-trigger="click", data-action="chargeUp", data-amount=1000) + Icon("diamond") + span 1000 + + button.action.mountable(data-trigger="click", data-action="chargeUp", data-amount=2000) + Icon("diamond") + span 2000 + + button.action.mountable(data-trigger="click", data-action="chargeUp", data-amount=3000) + Icon("diamond") + span 3000 + + button.action.mountable(data-trigger="click", data-action="chargeUp", data-amount=6000) + Icon("diamond") + span 6000 + + button.action.mountable(data-trigger="click", data-action="chargeUp", data-amount=12000) + Icon("diamond") + span 12000 + + .footer.text-center.mountable Different currencies will automatically be converted. \ No newline at end of file diff --git a/pages/compare/animelist.go b/pages/compare/animelist.go new file mode 100644 index 00000000..784c661d --- /dev/null +++ b/pages/compare/animelist.go @@ -0,0 +1,84 @@ +package compare + +import ( + "net/http" + "sort" + + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" +) + +// AnimeList ... +func AnimeList(ctx *aero.Context) string { + user := utils.GetUser(ctx) + nickA := ctx.Get("nick-1") + nickB := ctx.Get("nick-2") + + a, err := arn.GetUserByNick(nickA) + + if err != nil || a == nil { + return ctx.Error(http.StatusNotFound, "User not found: "+nickA, err) + } + + b, err := arn.GetUserByNick(nickB) + + if err != nil || b == nil { + return ctx.Error(http.StatusNotFound, "User not found: "+nickB, err) + } + + comparisons := []*utils.Comparison{} + countA := 0 + countB := 0 + + for _, item := range a.AnimeList().Items { + if item.Status == arn.AnimeListStatusPlanned { + continue + } + + countA++ + + comparisons = append(comparisons, &utils.Comparison{ + Anime: item.Anime(), + ItemA: item, + ItemB: b.AnimeList().Find(item.AnimeID), + }) + } + + for _, item := range b.AnimeList().Items { + if item.Status == arn.AnimeListStatusPlanned { + continue + } + + countB++ + + if Contains(comparisons, item.AnimeID) { + continue + } + + comparisons = append(comparisons, &utils.Comparison{ + Anime: item.Anime(), + ItemA: a.AnimeList().Find(item.AnimeID), + ItemB: item, + }) + } + + sort.Slice(comparisons, func(i, j int) bool { + return comparisons[i].Anime.Popularity.Total() > comparisons[j].Anime.Popularity.Total() + }) + + return ctx.HTML(components.CompareAnimeList(a, b, countA, countB, comparisons, user)) +} + +// Contains ... +func Contains(comparisons []*utils.Comparison, animeID string) bool { + for _, comparison := range comparisons { + if comparison.Anime.ID == animeID { + return true + } + } + + return false +} diff --git a/pages/compare/animelist.pixy b/pages/compare/animelist.pixy new file mode 100644 index 00000000..d1aeadee --- /dev/null +++ b/pages/compare/animelist.pixy @@ -0,0 +1,67 @@ +component CompareAnimeList(a *arn.User, b *arn.User, countA int, countB int, comparisons []*utils.Comparison, user *arn.User) + h1 Anime list comparison + + p.comparison-info= a.Nick + "'s list contains " + strconv.Itoa(countA) + " anime and " + b.Nick + "'s list contains " + strconv.Itoa(countB) + " anime." + + table.anime-list + thead + tr.anime-list-item.mountable + th.anime-list-item-image-container + th.anime-list-item-name + th.comparison + Avatar(a) + th.comparison + th.comparison + Avatar(b) + th.comparison + + tbody + each comparison in comparisons + tr.anime-list-item.mountable + td.anime-list-item-image-container + a.ajax(href=comparison.Anime.Link()) + img.anime-list-item-image.lazy(data-src=comparison.Anime.Image.Tiny, alt=comparison.Anime.Title.ByUser(user)) + + td.anime-list-item-name + a.ajax(href=comparison.Anime.Link())= comparison.Anime.Title.ByUser(user) + + td.comparison + if comparison.ItemA != nil + span= comparison.ItemA.Status + else + span - + + td.comparison + if comparison.ItemA != nil + if comparison.ItemA.Rating.Overall != 0 + if comparison.ItemB != nil && comparison.ItemB.Rating.Overall != 0 && comparison.ItemA.Rating.Overall == comparison.ItemB.Rating.Overall + span.comparison-rating-equal= utils.FormatRating(comparison.ItemA.Rating.Overall) + else + span= utils.FormatRating(comparison.ItemA.Rating.Overall) + else + span - + else + span - + + td.comparison + if comparison.ItemB != nil + span= comparison.ItemB.Status + else + span - + + td.comparison + if comparison.ItemB != nil + if comparison.ItemB.Rating.Overall != 0 + if comparison.ItemA != nil && comparison.ItemA.Rating.Overall != 0 + if comparison.ItemA.Rating.Overall == comparison.ItemB.Rating.Overall + span.comparison-rating-equal= utils.FormatRating(comparison.ItemB.Rating.Overall) + else if comparison.ItemB.Rating.Overall > comparison.ItemA.Rating.Overall + span.comparison-rating-higher(title=utils.FormatRating(comparison.ItemB.Rating.Overall))= "+" + utils.FormatRating(comparison.ItemB.Rating.Overall - comparison.ItemA.Rating.Overall) + else + span.comparison-rating-lower(title=utils.FormatRating(comparison.ItemB.Rating.Overall))= "-" + utils.FormatRating(comparison.ItemA.Rating.Overall - comparison.ItemB.Rating.Overall) + else + span= utils.FormatRating(comparison.ItemB.Rating.Overall) + else + span - + else + span - \ No newline at end of file diff --git a/pages/compare/animelist.scarlet b/pages/compare/animelist.scarlet new file mode 100644 index 00000000..13ca95ae --- /dev/null +++ b/pages/compare/animelist.scarlet @@ -0,0 +1,21 @@ +.comparison-info + text-align center + font-size 0.9rem + opacity 0.5 + margin-top 0 + margin-bottom content-padding + +.comparison + width 100px + text-align center + horizontal + justify-content center + +.comparison-rating-equal + // ... + +.comparison-rating-lower + color red + +.comparison-rating-higher + color green \ No newline at end of file diff --git a/pages/dashboard/dashboard.go b/pages/dashboard/dashboard.go index 8aa9b386..8733f999 100644 --- a/pages/dashboard/dashboard.go +++ b/pages/dashboard/dashboard.go @@ -1,59 +1,100 @@ package dashboard import ( + "net/http" + "sort" + "github.com/aerogo/aero" "github.com/aerogo/flow" "github.com/animenotifier/arn" "github.com/animenotifier/notify.moe/components" - "github.com/animenotifier/notify.moe/pages/frontpage" "github.com/animenotifier/notify.moe/utils" ) -const maxPosts = 5 +const maxForumActivity = 5 const maxFollowing = 5 +const maxSoundTracks = 5 +const maxScheduleItems = 5 +// Get the dashboard. func Get(ctx *aero.Context) string { + var forumActivity []arn.Postable + var followingList []*arn.User + var soundTracks []*arn.SoundTrack + var upcomingEpisodes []*arn.UpcomingEpisode + user := utils.GetUser(ctx) if user == nil { - return frontpage.Get(ctx) + return ctx.Error(http.StatusUnauthorized, "Not logged in", nil) } - return Dashboard(ctx) -} - -// Get dashboard. -func Dashboard(ctx *aero.Context) string { - var posts []*arn.Post - var err error - var followIDList []string - var userList interface{} - var followingList []*arn.User - user := utils.GetUser(ctx) - flow.Parallel(func() { - posts, err = arn.AllPostsSlice() - arn.SortPostsLatestFirst(posts) + posts := arn.AllPosts() + threads := arn.AllThreads() - if len(posts) > maxPosts { - posts = posts[:maxPosts] + arn.SortPostsLatestFirst(posts) + arn.SortThreadsLatestFirst(threads) + + posts = arn.FilterPostsWithUniqueThreads(posts, maxForumActivity) + + postPostables := arn.ToPostables(posts) + threadPostables := arn.ToPostables(threads) + + allPostables := append(postPostables, threadPostables...) + + arn.SortPostablesLatestFirst(allPostables) + forumActivity = arn.FilterPostablesWithUniqueThreads(allPostables, maxForumActivity) + }, func() { + animeList, err := arn.GetAnimeList(user.ID) + + if err != nil { + return } + animeList = animeList.Watching() + animeList.PrefetchAnime() + + for _, item := range animeList.Items { + futureEpisodes := item.Anime().UpcomingEpisodes() + + if len(futureEpisodes) == 0 { + continue + } + + upcomingEpisodes = append(upcomingEpisodes, futureEpisodes...) + } + + sort.Slice(upcomingEpisodes, func(i, j int) bool { + return upcomingEpisodes[i].Episode.AiringDate.Start < upcomingEpisodes[j].Episode.AiringDate.Start + }) + + if len(upcomingEpisodes) >= maxScheduleItems { + upcomingEpisodes = upcomingEpisodes[:maxScheduleItems] + } }, func() { - followIDList = user.Following - userList, err = arn.DB.GetMany("User", followIDList) - followingList = userList.([]*arn.User) - followingList = arn.SortUsersLastSeen(followingList) + var err error + soundTracks, err = arn.FilterSoundTracks(func(track *arn.SoundTrack) bool { + return !track.IsDraft && len(track.Media) > 0 + }) + + if err != nil { + return + } + + arn.SortSoundTracksLatestFirst(soundTracks) + + if len(soundTracks) > maxSoundTracks { + soundTracks = soundTracks[:maxSoundTracks] + } + }, func() { + followingList = user.Follows().Users() + arn.SortUsersLastSeen(followingList) if len(followingList) > maxFollowing { followingList = followingList[:maxFollowing] } - }) - if err != nil { - return ctx.Error(500, "Error displaying dashboard", err) - } - - return ctx.HTML(components.Dashboard(posts, followingList)) + return ctx.HTML(components.Dashboard(upcomingEpisodes, forumActivity, soundTracks, followingList, user)) } diff --git a/pages/dashboard/dashboard.pixy b/pages/dashboard/dashboard.pixy index 7fee6c6f..a40b6bd8 100644 --- a/pages/dashboard/dashboard.pixy +++ b/pages/dashboard/dashboard.pixy @@ -1,77 +1,101 @@ -component Dashboard(posts []*arn.Post, following []*arn.User) - h2.page-title Dash +component Dashboard(schedule []*arn.UpcomingEpisode, posts []arn.Postable, soundTracks []*arn.SoundTrack, following []*arn.User, user *arn.User) + h1.page-title Dashboard - .widgets + .dashboard .widget.mountable h3.widget-title Schedule - for i := 1; i <= 5; i++ - .widget-element - .widget-element-text - Icon("calendar-o") - span ... + for i := 0; i <= 4; i++ + if i < len(schedule) + .widget-ui-element + .widget-ui-element-text + a.schedule-item-link.ajax(href=schedule[i].Anime.Link()) + Icon("calendar-o") + .schedule-item-title= schedule[i].Anime.Title.ByUser(user) + .spacer + .schedule-item-date.utc-airing-date(data-start-date=schedule[i].Episode.AiringDate.Start, data-end-date=schedule[i].Episode.AiringDate.End, data-episode-number=schedule[i].Episode.Number) + else + .widget-ui-element + .widget-ui-element-text + Icon("calendar-o") + span ... .widget.mountable h3.widget-title Forums each post in posts - a.widget-element.ajax(href=post.Link()) - .widget-element-text + a.widget-ui-element.ajax(href=post.Thread().Link()) + .widget-ui-element-text Icon(arn.GetForumIcon(post.Thread().Tags[0])) span= post.Thread().Title + + .widget.mountable + h3.widget-title Artworks + + for i := 1; i <= 5; i++ + .widget-ui-element + .widget-ui-element-text + Icon("paint-brush") + span ... + + .widget.mountable + h3.widget-title Soundtracks + + for i := 0; i <= 4; i++ + if i < len(soundTracks) + a.widget-ui-element.ajax(href=soundTracks[i].Link()) + .widget-ui-element-text + Icon("music") + if soundTracks[i].Title == "" + span untitled + else + span= soundTracks[i].Title + else + .widget-ui-element + .widget-ui-element-text + Icon("music") + span ... + + .widget.mountable + h3.widget-title AMVs + + for i := 1; i <= 5; i++ + .widget-ui-element + .widget-ui-element-text + Icon("video-camera") + span ... + + .widget.mountable + h3.widget-title Reviews + + for i := 1; i <= 5; i++ + .widget-ui-element + .widget-ui-element-text + Icon("book") + span ... .widget.mountable h3.widget-title Groups for i := 1; i <= 5; i++ - .widget-element - .widget-element-text + .widget-ui-element + .widget-ui-element-text Icon("group") span ... - + .widget.mountable - h3.widget-title Messages + h3.widget-title Contacts - for i := 1; i <= 5; i++ - .widget-element - .widget-element-text - Icon("comment") - span ... - - if len(following) > 0 - .widget.mountable - h3.widget-title Contacts - - each user in following - a.widget-element.ajax(href="/+" + user.Nick) - .widget-element-text + for i := 0; i <= 4; i++ + if i < len(following) + a.widget-ui-element.ajax(href="/+" + following[i].Nick) + .widget-ui-element-text Icon("address-card") - span= user.Nick + span= following[i].Nick + else + .widget-ui-element + .widget-ui-element-text + Icon("address-card") + span ... - .widget.mountable - h3.widget-title Follow - - a.widget-element(href="https://discord.gg/0kimAmMCeXGXuzNF", target="_blank", rel="noopener") - .widget-element-text - Icon("microphone") - span Discord - - a.widget-element(href="https://www.facebook.com/animenotifier", target="_blank", rel="noopener") - .widget-element-text - Icon("facebook") - span Facebook - - a.widget-element(href="https://twitter.com/animenotifier", target="_blank", rel="noopener") - .widget-element-text - Icon("twitter") - span Twitter - - a.widget-element(href="https://plus.google.com/+AnimeReleaseNotifierOfficial", target="_blank", rel="noopener") - .widget-element-text - Icon("google-plus") - span Google+ - - a.widget-element(href="https://github.com/animenotifier/notify.moe", target="_blank", rel="noopener") - .widget-element-text - Icon("github") - span GitHub + Footer \ No newline at end of file diff --git a/pages/dashboard/dashboard.scarlet b/pages/dashboard/dashboard.scarlet new file mode 100644 index 00000000..ca979fcf --- /dev/null +++ b/pages/dashboard/dashboard.scarlet @@ -0,0 +1,20 @@ +.schedule-item-link, +.schedule-item-title + clip-long-text + +.schedule-item-link + horizontal + align-items center + +.schedule-item-date + text-align right + +.footer-element + :after + content " | " + color text-color + opacity 0.5 + + :last-child + :after + display none \ No newline at end of file diff --git a/pages/database/database.go b/pages/database/database.go new file mode 100644 index 00000000..b2cf4b24 --- /dev/null +++ b/pages/database/database.go @@ -0,0 +1,11 @@ +package database + +import ( + "github.com/aerogo/aero" + "github.com/animenotifier/notify.moe/components" +) + +// Get the dashboard. +func Get(ctx *aero.Context) string { + return ctx.HTML(components.Database()) +} diff --git a/pages/database/database.pixy b/pages/database/database.pixy new file mode 100644 index 00000000..300d19e2 --- /dev/null +++ b/pages/database/database.pixy @@ -0,0 +1,36 @@ +component Database + EditorTabs + + .widget-form + .widget + h1.mountable Database search + + .widget-section.mountable + label(for="data-type") Search + select#data-type.widget-ui-element(value="Anime") + option(value="Analytics") Analytics + option(value="Anime") Anime + option(value="AnimeList") AnimeList + option(value="Character") Character + option(value="Group") Group + option(value="Post") Post + option(value="Settings") Settings + option(value="SoundTrack") SoundTrack + option(value="Thread") Thread + option(value="User") User + + .widget-section.mountable + label(for="field") where + input#field.widget-ui-element(type="text", placeholder="Field name (e.g. Title or Title.Canonical)") + + .widget-section.mountable + label(for="field-value") is + input#field-value.widget-ui-element(type="text") + + .buttons.mountable + button.action(data-action="searchDB", data-trigger="click") + Icon("search") + span Search + + h3.text-center Results + #records \ No newline at end of file diff --git a/pages/database/database.scarlet b/pages/database/database.scarlet new file mode 100644 index 00000000..9c1638d1 --- /dev/null +++ b/pages/database/database.scarlet @@ -0,0 +1,25 @@ +#records + horizontal-wrap + justify-content space-around + margin-top content-padding + +.record + vertical + ui-element + padding 0.5rem 1rem + margin 0.5rem + +.record-id + :before + content "ID: " + +.record-view + // + +.record-view-api + // + +.record-count + text-align right + font-size 0.8rem + opacity 0.5 \ No newline at end of file diff --git a/pages/database/select.go b/pages/database/select.go new file mode 100644 index 00000000..a9666d1c --- /dev/null +++ b/pages/database/select.go @@ -0,0 +1,70 @@ +package database + +import ( + "net/http" + + "github.com/aerogo/aero" + "github.com/aerogo/mirror" + "github.com/animenotifier/arn" +) + +// QueryResponse .. +type QueryResponse struct { + Results []interface{} `json:"results"` +} + +// Select ... +func Select(ctx *aero.Context) string { + dataTypeName := ctx.Get("data-type") + field := ctx.Get("field") + searchValue := ctx.Get("field-value") + + // Empty values + if dataTypeName == "+" { + dataTypeName = "" + } + + if field == "+" { + field = "" + } + + if searchValue == "+" { + searchValue = "" + } + + // Check empty parameters + if dataTypeName == "" || field == "" { + return ctx.Error(http.StatusBadRequest, "Not enough parameters", nil) + } + + // Check data type parameter + _, found := arn.DB.Types()[dataTypeName] + + if !found { + return ctx.Error(http.StatusBadRequest, "Invalid type", nil) + } + + response := &QueryResponse{ + Results: []interface{}{}, + } + + stream := arn.DB.All(dataTypeName) + + process := func(obj interface{}) { + _, _, value, _ := mirror.GetField(obj, field) + + if value.String() == searchValue { + response.Results = append(response.Results, obj) + } + } + + for obj := range stream { + process(obj) + } + + for _, obj := range response.Results { + mirror.GetField(obj, field) + } + + return ctx.JSON(response) +} diff --git a/pages/editanime/editanime.go b/pages/editanime/editanime.go new file mode 100644 index 00000000..592d979f --- /dev/null +++ b/pages/editanime/editanime.go @@ -0,0 +1,28 @@ +package editanime + +import ( + "net/http" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" +) + +// Get anime edit page. +func Get(ctx *aero.Context) string { + id := ctx.Get("id") + user := utils.GetUser(ctx) + + if user == nil || (user.Role != "editor" && user.Role != "admin") { + return ctx.Error(http.StatusBadRequest, "Not logged in or not auhorized to edit this anime", nil) + } + + anime, err := arn.GetAnime(id) + + if err != nil { + return ctx.Error(http.StatusNotFound, "Anime not found", err) + } + + return ctx.HTML(components.EditAnime(anime)) +} diff --git a/pages/editanime/editanime.pixy b/pages/editanime/editanime.pixy new file mode 100644 index 00000000..2ad96b86 --- /dev/null +++ b/pages/editanime/editanime.pixy @@ -0,0 +1,17 @@ +component EditAnime(anime *arn.Anime) + h1= anime.Title.Canonical + + .widget-form.mountable + .widget(data-api="/api/anime/" + anime.ID) + h3.widget-title Mappings + InputText("Virtual:ShoboiID", anime.GetMapping("shoboi/anime"), "Shoboi TID", "TID on cal.syoboi.jp") + InputText("Virtual:AniListID", anime.GetMapping("anilist/anime"), "AniList ID", "ID on anilist.co") + + .buttons + a.button.ajax(href="/anime/" + anime.ID) + Icon("arrow-left") + span View anime + + a.button(href="/api/anime/" + anime.ID, target="_blank") + Icon("search-plus") + span JSON API \ No newline at end of file diff --git a/pages/editor/anilist.go b/pages/editor/anilist.go new file mode 100644 index 00000000..f3c1415f --- /dev/null +++ b/pages/editor/anilist.go @@ -0,0 +1,38 @@ +package editor + +import ( + "sort" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" +) + +const maxAniListEntries = 70 + +// AniList ... +func AniList(ctx *aero.Context) string { + missing := arn.FilterAnime(func(anime *arn.Anime) bool { + return anime.GetMapping("anilist/anime") == "" + }) + + sort.Slice(missing, func(i, j int) bool { + a := missing[i] + b := missing[j] + + aPop := a.Popularity.Total() + bPop := b.Popularity.Total() + + if aPop == bPop { + return a.Title.Canonical < b.Title.Canonical + } + + return aPop > bPop + }) + + if len(missing) > maxAniListEntries { + missing = missing[:maxAniListEntries] + } + + return ctx.HTML(components.AniListMissingMapping(missing)) +} diff --git a/pages/editor/anilist.pixy b/pages/editor/anilist.pixy new file mode 100644 index 00000000..be296eca --- /dev/null +++ b/pages/editor/anilist.pixy @@ -0,0 +1,25 @@ +component AniListMissingMapping(missing []*arn.Anime) + h1.page-title Anime without Anilist links + + EditorTabs + + table + thead + tr + th(title="Popularity") Pop. + th Title + th Type + th Year + th Tools + tbody + each anime in missing + tr.mountable + td= anime.Popularity.Total() + td + a(href=anime.Link(), target="_blank", rel="noopener")= anime.Title.Canonical + td= anime.Type + td + if len(anime.StartDate) >= 4 + span= anime.StartDate[:4] + td + a(href="https://anilist.co/search?type=anime&q=" + anime.Title.Canonical, target="_blank", rel="noopener") Search diff --git a/pages/editor/editor.go b/pages/editor/editor.go new file mode 100644 index 00000000..3abccc97 --- /dev/null +++ b/pages/editor/editor.go @@ -0,0 +1,18 @@ +package editor + +import ( + "github.com/aerogo/aero" + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" +) + +// Get ... +func Get(ctx *aero.Context) string { + user := utils.GetUser(ctx) + + if user == nil || (user.Role != "admin" && user.Role != "editor") { + return ctx.Redirect("/") + } + + return ctx.HTML(components.Editor()) +} diff --git a/pages/editor/editor.pixy b/pages/editor/editor.pixy new file mode 100644 index 00000000..e62c936d --- /dev/null +++ b/pages/editor/editor.pixy @@ -0,0 +1,17 @@ +component Editor + h1.page-title Editor Panel + + EditorTabs + + p.text-center.mountable Welcome to the Editor Panel! + +component EditorTabs + .tabs + Tab("Editor", "pencil", "/editor") + Tab("Search", "search", "/database") + Tab("Shoboi", "calendar", "/editor/shoboi") + Tab("AniList", "list", "/editor/anilist") + + //- a.tab.ajax(href="/admin", aria-label="Admin") + //- Icon("wrench") + //- span.tab-text Admin \ No newline at end of file diff --git a/pages/editor/shoboi.go b/pages/editor/shoboi.go new file mode 100644 index 00000000..96a1df94 --- /dev/null +++ b/pages/editor/shoboi.go @@ -0,0 +1,38 @@ +package editor + +import ( + "sort" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" +) + +const maxShoboiEntries = 70 + +// Shoboi ... +func Shoboi(ctx *aero.Context) string { + missing := arn.FilterAnime(func(anime *arn.Anime) bool { + return anime.GetMapping("shoboi/anime") == "" + }) + + sort.Slice(missing, func(i, j int) bool { + a := missing[i] + b := missing[j] + + aPop := a.Popularity.Total() + bPop := b.Popularity.Total() + + if aPop == bPop { + return a.Title.Canonical < b.Title.Canonical + } + + return aPop > bPop + }) + + if len(missing) > maxShoboiEntries { + missing = missing[:maxShoboiEntries] + } + + return ctx.HTML(components.ShoboiMissingMapping(missing)) +} diff --git a/pages/editor/shoboi.pixy b/pages/editor/shoboi.pixy new file mode 100644 index 00000000..9783b645 --- /dev/null +++ b/pages/editor/shoboi.pixy @@ -0,0 +1,25 @@ +component ShoboiMissingMapping(missing []*arn.Anime) + h1.page-title Anime without Shoboi links + + EditorTabs + + table + thead + tr + th(title="Popularity") Pop. + th Title + th Type + th Year + th Tools + tbody + each anime in missing + tr.mountable + td= anime.Popularity.Total() + td + a(href=anime.Link(), target="_blank", rel="noopener")= anime.Title.Canonical + td= anime.Type + td + if len(anime.StartDate) >= 4 + span= anime.StartDate[:4] + td + a(href="http://cal.syoboi.jp/find?type=quick&sd=1&kw=" + anime.Title.Japanese, target="_blank", rel="noopener") Search diff --git a/pages/embed/embed-pro-notice.pixy b/pages/embed/embed-pro-notice.pixy new file mode 100644 index 00000000..b0ac4249 --- /dev/null +++ b/pages/embed/embed-pro-notice.pixy @@ -0,0 +1,8 @@ +component EmbedProNotice(user *arn.User) + h1 notify.moe is in a financial crisis right now + p + spa If we don't get the necessary funding to keep it alive, the site needs to shut down. The developer works 12 hours per day on this project and doesn't receive a single cent. Please help us fund the project and get yourself a + a(href="https://notify.moe/shop", target="_blank") PRO account + span to support the site. It only costs about 2.6 USD per month. Read more about it + a(href="https://notify.moe/thread/A9nC8uakR", target="_blank") here + span . \ No newline at end of file diff --git a/pages/embed/embed.go b/pages/embed/embed.go index cf33a2f9..5eb51d0b 100644 --- a/pages/embed/embed.go +++ b/pages/embed/embed.go @@ -2,7 +2,7 @@ package embed import ( "net/http" - "sort" + "time" "github.com/aerogo/aero" "github.com/animenotifier/notify.moe/components" @@ -17,15 +17,19 @@ func Get(ctx *aero.Context) string { return utils.AllowEmbed(ctx, ctx.HTML(components.Login())) } + if !user.IsPro() && user.TimeSinceRegistered() > 14*24*time.Hour { + return utils.AllowEmbed(ctx, ctx.HTML(components.EmbedProNotice(user))) + } + 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() - }) + watchingList := animeList.Watching() + watchingList.PrefetchAnime() + watchingList.Sort() - return utils.AllowEmbed(ctx, ctx.HTML(components.AnimeList(animeList, user))) + return utils.AllowEmbed(ctx, ctx.HTML(components.AnimeList(watchingList, animeList.User(), user))) } diff --git a/pages/explore/explore.go b/pages/explore/explore.go new file mode 100644 index 00000000..be55016b --- /dev/null +++ b/pages/explore/explore.go @@ -0,0 +1,55 @@ +package explore + +import ( + "sort" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" +) + +const ( + currentlyAiringBonus = 5.0 + popularityThreshold = 5 + popularityPenalty = 8.0 + watchingPopularityWeight = 0.3 + plannedPopularityWeight = 0.2 +) + +// Get ... +func Get(ctx *aero.Context) string { + animeList := arn.GetAiringAnime() + + sort.Slice(animeList, func(i, j int) bool { + a := animeList[i] + b := animeList[j] + scoreA := a.Rating.Overall + scoreB := b.Rating.Overall + + if a.Status == "current" { + scoreA += currentlyAiringBonus + } + + if b.Status == "current" { + scoreB += currentlyAiringBonus + } + + if a.Popularity.Total() < popularityThreshold { + scoreA -= popularityPenalty + } + + if b.Popularity.Total() < popularityThreshold { + scoreB -= popularityPenalty + } + + scoreA += float64(a.Popularity.Watching) * watchingPopularityWeight + scoreB += float64(b.Popularity.Watching) * watchingPopularityWeight + + scoreA += float64(a.Popularity.Planned) * plannedPopularityWeight + scoreB += float64(b.Popularity.Planned) * plannedPopularityWeight + + return scoreA > scoreB + }) + + return ctx.HTML(components.Explore(animeList)) +} diff --git a/pages/explore/explore.pixy b/pages/explore/explore.pixy new file mode 100644 index 00000000..7c7c2cc0 --- /dev/null +++ b/pages/explore/explore.pixy @@ -0,0 +1,3 @@ +component Explore(animeList []*arn.Anime) + h1.page-title(title=toString(len(animeList)) + " anime") Explore + AnimeGrid(animeList) \ No newline at end of file diff --git a/pages/forum/forum.go b/pages/forum/forum.go index 1d648a98..49eaf27b 100644 --- a/pages/forum/forum.go +++ b/pages/forum/forum.go @@ -12,7 +12,7 @@ const ThreadsPerPage = 20 // Get forum category. func Get(ctx *aero.Context) string { tag := ctx.Get("tag") - threads, _ := arn.GetThreadsByTag(tag) + threads := arn.GetThreadsByTag(tag) arn.SortThreads(threads) if len(threads) > ThreadsPerPage { diff --git a/pages/forum/forum.pixy b/pages/forum/forum.pixy index dd4f9ee1..093fb157 100644 --- a/pages/forum/forum.pixy +++ b/pages/forum/forum.pixy @@ -1,18 +1,19 @@ component Forum(tag string, threads []*arn.Thread, threadsPerPage int) - h2.page-title Forum + h1.page-title Forum ForumTags .forum ThreadList(threads) - if len(threads) == threadsPerPage - .buttons - button - Icon("refresh") - span Load more + .buttons + button#new-thread.action(data-action="load", data-trigger="click", data-url="/new/thread") + Icon("plus") + span New thread + //- if len(threads) == threadsPerPage + //- LoadMore component ThreadList(threads []*arn.Thread) if len(threads) == 0 - p No threads found. + p.no-data.mountable No threads found. else each thread in threads ThreadLink(thread) \ No newline at end of file diff --git a/pages/forum/forum.scarlet b/pages/forum/forum.scarlet index 4775f365..68aba8a8 100644 --- a/pages/forum/forum.scarlet +++ b/pages/forum/forum.scarlet @@ -6,28 +6,8 @@ width 100% max-width forum-width -.forum-tag - color text-color !important - - :hover, - &.active - color white !important - background-color forum-tag-hover-color !important - - // color text-color !important - // :hover - // color white !important - // &.active - // color white !important - // background-color link-hover-color - -< 920px - .forum-tag - .icon - margin-right 0 - - .forum-tag-text - display none - -#load-more-threads - margin-top 1rem \ No newline at end of file +> 1250px + #new-thread + position fixed + right content-padding + bottom content-padding diff --git a/pages/forums/forums.pixy b/pages/forums/forums.pixy deleted file mode 100644 index 2372d037..00000000 --- a/pages/forums/forums.pixy +++ /dev/null @@ -1,3 +0,0 @@ -component Forums - h2.forum-header Forum - ForumTags \ No newline at end of file diff --git a/pages/frontpage/frontpage.go b/pages/frontpage/frontpage.go index 94feb8fc..32d66d4b 100644 --- a/pages/frontpage/frontpage.go +++ b/pages/frontpage/frontpage.go @@ -2,10 +2,27 @@ package frontpage import ( "github.com/aerogo/aero" + "github.com/animenotifier/arn" "github.com/animenotifier/notify.moe/components" ) // Get ... func Get(ctx *aero.Context) string { + description := "Anime list and notifier for new anime episodes. Create your own anime list and keep track of your progress as you watch." + + ctx.Data = &arn.OpenGraph{ + Tags: map[string]string{ + "og:title": ctx.App.Config.Title, + "og:description": description, + "og:type": "website", + "og:url": "https://" + ctx.App.Config.Domain, + "og:image": "https://" + ctx.App.Config.Domain + "/images/brand/220.png", + }, + Meta: map[string]string{ + "description": description, + "keywords": "anime,list,tracker,notifier", + }, + } + return ctx.HTML(components.FrontPage()) } diff --git a/pages/frontpage/frontpage.pixy b/pages/frontpage/frontpage.pixy index 0a364af2..46fc1632 100644 --- a/pages/frontpage/frontpage.pixy +++ b/pages/frontpage/frontpage.pixy @@ -1,12 +1,39 @@ component FrontPage + .frontpage-background + .frontpage - h2 notify.moe + h1.mountable notify.moe + + h2.mountable Your home for everything about anime. - img.action.screenshot(src="/images/elements/extension-screenshot.png", alt="Screenshot of the browser extension", title="Click to install the Chrome Extension", data-action="installExtension", data-trigger="click") - Login + Footer + + video.bg-video(autoplay="autoplay", loop="loop") + source(src="//s1.webmshare.com/nZVby.webm", type="video/webm") + source(src="//cdn-e2.streamable.com/video/mp4/e5mx7.mp4?token=1500414089_8b2b3b0665984dcf4dc8d33e534bc1c8881b2da1", type="video/mp4") - .footer - a(href="https://paypal.me/blitzprog", target="_blank", rel="noopener") Support the development - span | - a(href="https://github.com/animenotifier/notify.moe", target="_blank", rel="noopener") Source on GitHub \ No newline at end of file +component Footer + .footer.text-center.mountable + SocialMediaLinks + +component SocialMediaLinks + a.footer-element(href="https://discord.gg/0kimAmMCeXGXuzNF", target="_blank", rel="noopener") + Icon("microphone") + span Discord + + a.footer-element(href="https://www.facebook.com/animenotifier", target="_blank", rel="noopener") + Icon("facebook") + span Facebook + + a.footer-element(href="https://twitter.com/animenotifier", target="_blank", rel="noopener") + Icon("twitter") + span Twitter + + a.footer-element(href="https://plus.google.com/+AnimeReleaseNotifierOfficial", target="_blank", rel="noopener") + Icon("google-plus") + span Google+ + + a.footer-element(href="https://github.com/animenotifier/notify.moe", target="_blank", rel="noopener") + Icon("github") + span GitHub \ No newline at end of file diff --git a/pages/frontpage/frontpage.scarlet b/pages/frontpage/frontpage.scarlet index 98cb03ae..c0521f83 100644 --- a/pages/frontpage/frontpage.scarlet +++ b/pages/frontpage/frontpage.scarlet @@ -1,17 +1,85 @@ +frontpage-bg-color = rgb(32, 32, 32) + +.frontpage-background + position absolute + top 0 + left 0 + width 100% + height 100% + z-index -200 + background frontpage-bg-color + .frontpage vertical align-items center + position absolute + top 50% + left 50% + transform translateX(-50%) translateY(-50%) + + a, h1, h2 + color white !important + text-shadow 0px 0px 4px rgb(0, 0, 0, 0.75) - h2 + h1 font-size 2.5rem font-weight normal - letter-spacing 3px + letter-spacing 5px text-transform uppercase + line-height 1.2em + + h2 + font-size 2rem + font-weight normal + margin-top 0 + margin-bottom 1em + line-height 1.2em + text-align center .footer text-align center margin-top content-padding +.bg-video + display none + position absolute + top 50% + left 50% + transform translateX(-50%) translateY(-50%) + min-width 100% + min-height 100% + width auto + height auto + z-index -100 + background frontpage-bg-color + +> 1100px + .bg-video + display block + +.login-button + horizontal + align-items center + border-radius 3px + padding 0.75rem 1.25rem + margin 0.5rem + font-size 1.2rem + text-shadow none !important + box-shadow shadow-medium + default-transition + +.login-button-google + background hsl(8, 75%, 43%) + + :hover + background hsl(8, 75%, 48%) + +.login-button-facebook + background hsl(222, 67%, 42%) + + :hover + background hsl(222, 67%, 47%) + .screenshot max-width 100% border-radius 3px diff --git a/pages/genres/genres.pixy b/pages/genres/genres.pixy index 6a684db4..5e5663d6 100644 --- a/pages/genres/genres.pixy +++ b/pages/genres/genres.pixy @@ -1,5 +1,5 @@ component Genres(genres []*arn.Genre) - h2.page-title Genres + h1.page-title Genres .genres each genre in genres diff --git a/pages/group/edit.go b/pages/group/edit.go new file mode 100644 index 00000000..efaa21e0 --- /dev/null +++ b/pages/group/edit.go @@ -0,0 +1,32 @@ +package group + +import ( + "net/http" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" + "github.com/animenotifier/notify.moe/utils/editform" +) + +// Edit ... +func Edit(ctx *aero.Context) string { + id := ctx.Get("id") + group, err := arn.GetGroup(id) + user := utils.GetUser(ctx) + + if err != nil { + return ctx.Error(http.StatusNotFound, "Track not found", err) + } + + ctx.Data = &arn.OpenGraph{ + Tags: map[string]string{ + "og:title": group.Name, + "og:url": "https://" + ctx.App.Config.Domain + group.Link(), + "og:site_name": "notify.moe", + }, + } + + return ctx.HTML(components.GroupTabs(group) + editform.Render(group, "Edit group", user)) +} diff --git a/pages/group/forum.go b/pages/group/forum.go new file mode 100644 index 00000000..4db63468 --- /dev/null +++ b/pages/group/forum.go @@ -0,0 +1,21 @@ +package group + +import ( + "net/http" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" +) + +// Forum ... +func Forum(ctx *aero.Context) string { + id := ctx.Get("id") + group, err := arn.GetGroup(id) + + if err != nil { + return ctx.Error(http.StatusNotFound, "Group not found", err) + } + + return ctx.HTML(components.GroupForum(group)) +} diff --git a/pages/group/forum.pixy b/pages/group/forum.pixy new file mode 100644 index 00000000..5253aa94 --- /dev/null +++ b/pages/group/forum.pixy @@ -0,0 +1,4 @@ +component GroupForum(group *arn.Group) + GroupTabs(group) + + h1 Forum \ No newline at end of file diff --git a/pages/group/group.go b/pages/group/group.go new file mode 100644 index 00000000..5946a495 --- /dev/null +++ b/pages/group/group.go @@ -0,0 +1,29 @@ +package group + +import ( + "net/http" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" +) + +// Get ... +func Get(ctx *aero.Context) string { + id := ctx.Get("id") + group, err := arn.GetGroup(id) + + if err != nil { + return ctx.Error(http.StatusNotFound, "Group not found", err) + } + + ctx.Data = &arn.OpenGraph{ + Tags: map[string]string{ + "og:title": group.Name, + "og:url": "https://" + ctx.App.Config.Domain + group.Link(), + "og:site_name": "notify.moe", + }, + } + + return ctx.HTML(components.Group(group)) +} diff --git a/pages/group/group.pixy b/pages/group/group.pixy new file mode 100644 index 00000000..7e2a13bc --- /dev/null +++ b/pages/group/group.pixy @@ -0,0 +1,38 @@ +component Group(group *arn.Group) + GroupTabs(group) + + if group.Name != "" + h1.mountable= group.Name + else + h1.mountable untitled + + .group-view + .group-sidebar.mountable + if group.Description != "" + .group-sidebar-section + h3 Description + .group-description!= markdown.Render(group.Description) + + if group.Rules != "" + .group-sidebar-section + h3 Rules + .group-rules!= markdown.Render(group.Rules) + + .group-sidebar-section + h3 Members + .user-avatars.group-members + each member in group.Members + Avatar(member.User()) + + .group-feed.mountable + if len(group.Posts()) == 0 + p.text-center.mountable No posts in this group yet. + else + each post in group.Posts() + p!= post.HTML() + +component GroupTabs(group *arn.Group) + .tabs + Tab("Group", "users", group.Link()) + Tab("Forum", "comment", group.Link() + "/forum") + Tab("Edit", "pencil", group.Link() + "/edit") \ No newline at end of file diff --git a/pages/group/group.scarlet b/pages/group/group.scarlet new file mode 100644 index 00000000..b273cb2e --- /dev/null +++ b/pages/group/group.scarlet @@ -0,0 +1,23 @@ +.group-view + horizontal-wrap + width 100% + +< 1100px + .group-view + vertical + +.group-feed + flex 0.75 + padding 1rem + +.group-sidebar + flex 0.25 + +.group-sidebar-section + ui-element + padding 0.5rem 1rem + margin-bottom content-padding + +.group-members + margin-bottom 0.5rem + justify-content flex-start \ No newline at end of file diff --git a/pages/groups/groups.go b/pages/groups/groups.go new file mode 100644 index 00000000..04082165 --- /dev/null +++ b/pages/groups/groups.go @@ -0,0 +1,40 @@ +package groups + +import ( + "net/http" + "sort" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" +) + +const groupsPerPage = 12 + +// Get ... +func Get(ctx *aero.Context) string { + user := utils.GetUser(ctx) + + groups, err := arn.FilterGroups(func(group *arn.Group) bool { + return !group.IsDraft + }) + + sort.Slice(groups, func(i, j int) bool { + if len(groups[i].Members) == len(groups[j].Members) { + return groups[i].Created > groups[j].Created + } + + return len(groups[i].Members) > len(groups[j].Members) + }) + + if len(groups) > groupsPerPage { + groups = groups[:groupsPerPage] + } + + if err != nil { + return ctx.Error(http.StatusInternalServerError, "Error fetching groups", err) + } + + return ctx.HTML(components.Groups(groups, groupsPerPage, user)) +} diff --git a/pages/groups/groups.pixy b/pages/groups/groups.pixy new file mode 100644 index 00000000..de4c9e74 --- /dev/null +++ b/pages/groups/groups.pixy @@ -0,0 +1,31 @@ +component Groups(groups []*arn.Group, groupsPerPage int, user *arn.User) + .tabs + Tab("Groups", "users", "/groups") + + h1.page-title Groups + + .buttons + if user != nil + if user.DraftIndex().GroupID == "" + button.action(data-action="newObject", data-trigger="click", data-type="group") + Icon("plus") + span New group + else + a.button.ajax(href="/group/" + user.DraftIndex().GroupID + "/edit") + Icon("pencil") + span Edit draft + + #load-more-target.groups + each group in groups + a.group.mountable.ajax(href=group.Link()) + img.group-image.lazy(data-src=group.ImageURL(), alt=group.Name) + .group-info + h3.group-name= group.Name + .group-tagline= group.Tagline + .group-member-count + Icon("user") + span= len(group.Members) + + if len(groups) == groupsPerPage + .buttons + LoadMore(groupsPerPage) \ No newline at end of file diff --git a/pages/groups/groups.scarlet b/pages/groups/groups.scarlet new file mode 100644 index 00000000..15f9184d --- /dev/null +++ b/pages/groups/groups.scarlet @@ -0,0 +1,44 @@ +group-padding-y = 0.75rem +group-padding-x = 0.75rem + +.groups + horizontal-wrap + justify-content space-around + +.group + horizontal + ui-element + position relative + width 100% + max-width 520px + padding group-padding-y group-padding-x + margin calc(content-padding / 2) + color text-color + + :hover + color white + background-color rgb(60, 60, 60) + +.group-image + width 70px + height 70px + margin-right 1rem + border-radius ui-element-border-radius + +.group-info + vertical + +.group-name + horizontal + align-items center + +.group-tagline + opacity 0.6 + +.group-member-count + position absolute + top 0.5rem + right 1rem + text-align right + font-size 0.8rem + opacity 0.5 \ No newline at end of file diff --git a/pages/home/animelist.go b/pages/home/animelist.go new file mode 100644 index 00000000..8cff7a18 --- /dev/null +++ b/pages/home/animelist.go @@ -0,0 +1,39 @@ +package home + +import ( + "net/http" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/pages/frontpage" + "github.com/animenotifier/notify.moe/utils" +) + +// FilterByStatus returns a handler for the given anime list item status. +func FilterByStatus(status string) aero.Handle { + return func(ctx *aero.Context) string { + user := utils.GetUser(ctx) + + if user == nil { + return frontpage.Get(ctx) + } + + return AnimeList(ctx, user, status) + } +} + +// AnimeList sends the anime list with the given status for given user. +func AnimeList(ctx *aero.Context, user *arn.User, status string) string { + viewUser := user + animeList := viewUser.AnimeList() + + if animeList == nil { + return ctx.Error(http.StatusNotFound, "Anime list not found", nil) + } + + animeList.PrefetchAnime() + animeList.Sort() + + return ctx.HTML(components.Home(animeList.FilterStatus(status), viewUser, user, status)) +} diff --git a/pages/home/home.go b/pages/home/home.go new file mode 100644 index 00000000..1a31d48a --- /dev/null +++ b/pages/home/home.go @@ -0,0 +1,19 @@ +package home + +import ( + "github.com/aerogo/aero" + "github.com/animenotifier/notify.moe/pages/frontpage" + "github.com/animenotifier/notify.moe/utils" +) + +// Get the anime list or the frontpage when logged out. +func Get(ctx *aero.Context) string { + user := utils.GetUser(ctx) + + if user == nil { + return frontpage.Get(ctx) + } + + return ctx.Redirect("/animelist/watching") + //return AnimeList(ctx, user, arn.AnimeListStatusWatching) +} diff --git a/pages/home/home.pixy b/pages/home/home.pixy new file mode 100644 index 00000000..6ab77741 --- /dev/null +++ b/pages/home/home.pixy @@ -0,0 +1,3 @@ +component Home(animeList *arn.AnimeList, viewUser *arn.User, user *arn.User, status string) + StatusTabs("/animelist") + AnimeListFilteredByStatus(animeList, viewUser, user, status) \ No newline at end of file diff --git a/pages/inventory/inventory.go b/pages/inventory/inventory.go new file mode 100644 index 00000000..e7aa1904 --- /dev/null +++ b/pages/inventory/inventory.go @@ -0,0 +1,30 @@ +package inventory + +import ( + "net/http" + + "github.com/animenotifier/arn" + + "github.com/animenotifier/notify.moe/components" + + "github.com/aerogo/aero" + "github.com/animenotifier/notify.moe/utils" +) + +// Get inventory page. +func Get(ctx *aero.Context) string { + user := utils.GetUser(ctx) + viewUser := user + + if user == nil { + return ctx.Error(http.StatusUnauthorized, "Not logged in", nil) + } + + inventory, err := arn.GetInventory(viewUser.ID) + + if err != nil { + return ctx.Error(http.StatusInternalServerError, "Error fetching inventory data", err) + } + + return ctx.HTML(components.Inventory(inventory, viewUser, user)) +} diff --git a/pages/inventory/inventory.pixy b/pages/inventory/inventory.pixy new file mode 100644 index 00000000..d50cec70 --- /dev/null +++ b/pages/inventory/inventory.pixy @@ -0,0 +1,18 @@ +component Inventory(inventory *arn.Inventory, viewUser *arn.User, user *arn.User) + ShopTabs(user) + + h1.page-title Inventory + + .inventory(data-api="/api/inventory/" + viewUser.ID) + for index, slot := range inventory.Slots + if slot.ItemID == "" + .inventory-slot.mountable(draggable="false", data-index=index) + else + .inventory-slot.mountable(title=slot.Item().Name, draggable="true", data-index=index, data-item-id=slot.ItemID, data-consumable=slot.Item().Consumable) + .item-icon + Icon(slot.Item().Icon) + if slot.Quantity > 1 + .inventory-slot-quantity= slot.Quantity + + .footer.text-center.mountable + p You can consume items by double-clicking them. \ No newline at end of file diff --git a/pages/inventory/inventory.scarlet b/pages/inventory/inventory.scarlet new file mode 100644 index 00000000..fa8e858a --- /dev/null +++ b/pages/inventory/inventory.scarlet @@ -0,0 +1,68 @@ +inventory-slot-size = 64px + +.inventory + display grid + grid-gap 0.25rem + grid-template-columns repeat(auto-fit, inventory-slot-size) + grid-auto-rows inventory-slot-size + justify-content center + width 100% + max-width 450px + margin 0 auto + +.inventory-slot + ui-element + position relative + display flex + align-items center + justify-content center + font-size 2.5rem + + [draggable="true"] + :hover + cursor pointer + + .item-icon + animation hover-item 1s infinite ease-in-out + + .icon + margin 0 + pointer-events none + + // [data-item-id="pro-account-3"] + // .item-icon + // opacity 0.7 + + // [data-item-id="pro-account-6"] + // .item-icon + // opacity 0.8 + + // [data-item-id="pro-account-12"] + // .item-icon + // opacity 0.9 + + // [data-item-id="pro-account-24"] + // .item-icon + // opacity 1.0 + +animation hover-item + 0% + transform rotateZ(0) + 20% + transform rotateZ(5deg) + 80% + transform rotateZ(-5deg) + 100% + transform rotateZ(0) + +.inventory-slot-quantity + position absolute + bottom 0.25rem + right 0.25rem + font-size 0.8rem + line-height 1em + opacity 0.5 + pointer-events none + +.drag-enter + border-style dashed \ No newline at end of file diff --git a/pages/listimport/listimport.go b/pages/listimport/listimport.go new file mode 100644 index 00000000..8956baf7 --- /dev/null +++ b/pages/listimport/listimport.go @@ -0,0 +1,20 @@ +package listimport + +import ( + "net/http" + + "github.com/aerogo/aero" + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" +) + +// Get ... +func Get(ctx *aero.Context) string { + user := utils.GetUser(ctx) + + if user == nil { + return ctx.Error(http.StatusBadRequest, "Not logged in", nil) + } + + return ctx.HTML(components.ImportLists(user)) +} diff --git a/pages/listimport/listimport.pixy b/pages/listimport/listimport.pixy new file mode 100644 index 00000000..aff1e117 --- /dev/null +++ b/pages/listimport/listimport.pixy @@ -0,0 +1,22 @@ +component ImportLists(user *arn.User) + if user.Accounts.AniList.Nick != "" + label AniList: + + .widget-section + a.button.mountable.ajax(href="/import/anilist/animelist") + Icon("download") + span Import AniList + + if user.Accounts.Kitsu.Nick != "" + label Kitsu: + .widget-section + a.button.mountable.ajax(href="/import/kitsu/animelist") + Icon("download") + span Import Kitsu + + if user.Accounts.MyAnimeList.Nick != "" + label MyAnimeList: + .widget-section + a.button.mountable.ajax(href="/import/myanimelist/animelist") + Icon("download") + span Import MyAnimeList \ No newline at end of file diff --git a/pages/listimport/listimport.scarlet b/pages/listimport/listimport.scarlet new file mode 100644 index 00000000..5f42a32f --- /dev/null +++ b/pages/listimport/listimport.scarlet @@ -0,0 +1,2 @@ +.buttons-vertical + width 100% \ No newline at end of file diff --git a/pages/listimport/listimportanilist/anilist.go b/pages/listimport/listimportanilist/anilist.go new file mode 100644 index 00000000..c3e6e0f0 --- /dev/null +++ b/pages/listimport/listimportanilist/anilist.go @@ -0,0 +1,136 @@ +package listimportanilist + +import ( + "net/http" + + "github.com/aerogo/aero" + "github.com/animenotifier/anilist" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" +) + +// Preview shows an import preview. +func Preview(ctx *aero.Context) string { + user := utils.GetUser(ctx) + + if user == nil { + return ctx.Error(http.StatusBadRequest, "Not logged in", nil) + } + + matches, response := getMatches(ctx) + + if response != "" { + return response + } + + return ctx.HTML(components.ImportAnilist(user, matches)) +} + +// Finish ... +func Finish(ctx *aero.Context) string { + user := utils.GetUser(ctx) + + if user == nil { + return ctx.Error(http.StatusBadRequest, "Not logged in", nil) + } + + matches, response := getMatches(ctx) + + if response != "" { + return response + } + + animeList := user.AnimeList() + + for _, match := range matches { + if match.ARNAnime == nil || match.AniListItem == nil { + continue + } + + item := &arn.AnimeListItem{ + AnimeID: match.ARNAnime.ID, + Status: arn.AniListAnimeListStatus(match.AniListItem), + Episodes: match.AniListItem.EpisodesWatched, + Notes: match.AniListItem.Notes, + Rating: &arn.AnimeRating{ + Overall: float64(match.AniListItem.ScoreRaw) / 10.0, + }, + RewatchCount: match.AniListItem.Rewatched, + Created: arn.DateTimeUTC(), + Edited: arn.DateTimeUTC(), + } + + animeList.Import(item) + } + + animeList.Save() + + return ctx.Redirect("/+" + user.Nick + "/animelist") +} + +// getMatches finds and returns all matches for the logged in user. +func getMatches(ctx *aero.Context) ([]*arn.AniListMatch, string) { + user := utils.GetUser(ctx) + + if user == nil { + return nil, ctx.Error(http.StatusBadRequest, "Not logged in", nil) + } + + authErr := anilist.Authorize() + + if authErr != nil { + return nil, ctx.Error(http.StatusBadRequest, "Couldn't authorize the Anime Notifier app on AniList", authErr) + } + + allAnime, allErr := arn.AllAnime() + + if allErr != nil { + return nil, ctx.Error(http.StatusBadRequest, "Couldn't load notify.moe list of all anime", allErr) + } + + anilistAnimeList, err := anilist.GetAnimeList(user.Accounts.AniList.Nick) + + if err != nil { + return nil, ctx.Error(http.StatusBadRequest, "Couldn't load your anime list from AniList", err) + } + + matches := findAllMatches(allAnime, anilistAnimeList) + + return matches, "" +} + +// findAllMatches returns all matches for the anime inside an anilist anime list. +func findAllMatches(allAnime []*arn.Anime, animeList *anilist.AnimeList) []*arn.AniListMatch { + matches := []*arn.AniListMatch{} + + matches = importList(matches, allAnime, animeList.Lists.Watching) + matches = importList(matches, allAnime, animeList.Lists.Completed) + matches = importList(matches, allAnime, animeList.Lists.PlanToWatch) + matches = importList(matches, allAnime, animeList.Lists.OnHold) + matches = importList(matches, allAnime, animeList.Lists.Dropped) + + custom, ok := animeList.CustomLists.(map[string][]*anilist.AnimeListItem) + + if !ok { + return matches + } + + for _, list := range custom { + matches = importList(matches, allAnime, list) + } + + return matches +} + +// importList imports a single list inside an anilist anime list collection. +func importList(matches []*arn.AniListMatch, allAnime []*arn.Anime, animeListItems []*anilist.AnimeListItem) []*arn.AniListMatch { + for _, item := range animeListItems { + matches = append(matches, &arn.AniListMatch{ + AniListItem: item, + ARNAnime: arn.FindAniListAnime(item.Anime, allAnime), + }) + } + + return matches +} diff --git a/pages/listimport/listimportanilist/anilist.pixy b/pages/listimport/listimportanilist/anilist.pixy new file mode 100644 index 00000000..711f7aee --- /dev/null +++ b/pages/listimport/listimportanilist/anilist.pixy @@ -0,0 +1,23 @@ +component ImportAnilist(user *arn.User, matches []*arn.AniListMatch) + h1= "anilist.co Import (" + user.Accounts.AniList.Nick + ", " + toString(len(matches)) + " anime)" + + table.import-list + thead + tr + th anilist.co + th notify.moe + tbody + each match in matches + tr + td + a(href=match.AniListItem.Anime.Link(), target="_blank", rel="noopener")= match.AniListItem.Anime.TitleRomaji + td + if match.ARNAnime == nil + span.import-error Not found on notify.moe + else + a(href=match.ARNAnime.Link(), target="_blank", rel="noopener")= match.ARNAnime.Title.Canonical + + .buttons + a.button.mountable(href="/import/anilist/animelist/finish") + Icon("refresh") + span Import \ No newline at end of file diff --git a/pages/listimport/listimportkitsu/kitsu.go b/pages/listimport/listimportkitsu/kitsu.go new file mode 100644 index 00000000..52254045 --- /dev/null +++ b/pages/listimport/listimportkitsu/kitsu.go @@ -0,0 +1,129 @@ +package listimportkitsu + +import ( + "net/http" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/kitsu" + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" +) + +// Preview shows an import preview. +func Preview(ctx *aero.Context) string { + user := utils.GetUser(ctx) + + if user == nil { + return ctx.Error(http.StatusBadRequest, "Not logged in", nil) + } + + matches, response := getMatches(ctx) + + if response != "" { + return response + } + + return ctx.HTML(components.ImportKitsu(user, matches)) +} + +// Finish ... +func Finish(ctx *aero.Context) string { + user := utils.GetUser(ctx) + + if user == nil { + return ctx.Error(http.StatusBadRequest, "Not logged in", nil) + } + + matches, response := getMatches(ctx) + + if response != "" { + return response + } + + animeList := user.AnimeList() + + for _, match := range matches { + if match.ARNAnime == nil || match.KitsuItem == nil { + continue + } + + rating := match.KitsuItem.Attributes.RatingTwenty + + if rating < 2 { + rating = 2 + } + + if rating > 20 { + rating = 20 + } + + // Convert rating + convertedRating := (float64(rating-2) / 18.0) * 10.0 + + item := &arn.AnimeListItem{ + AnimeID: match.ARNAnime.ID, + Status: arn.KitsuStatusToARNStatus(match.KitsuItem.Attributes.Status), + Episodes: match.KitsuItem.Attributes.Progress, + Notes: match.KitsuItem.Attributes.Notes, + Rating: &arn.AnimeRating{ + Overall: convertedRating, + }, + RewatchCount: match.KitsuItem.Attributes.ReconsumeCount, + Created: arn.DateTimeUTC(), + Edited: arn.DateTimeUTC(), + } + + animeList.Import(item) + } + + animeList.Save() + + return ctx.Redirect("/+" + user.Nick + "/animelist") +} + +// getMatches finds and returns all matches for the logged in user. +func getMatches(ctx *aero.Context) ([]*arn.KitsuMatch, string) { + user := utils.GetUser(ctx) + + if user == nil { + return nil, ctx.Error(http.StatusBadRequest, "Not logged in", nil) + } + + kitsuUser, err := kitsu.GetUser(user.Accounts.Kitsu.Nick) + + if err != nil { + return nil, ctx.Error(http.StatusBadRequest, "Couldn't load your user info from Kitsu", err) + } + + library := kitsuUser.StreamLibraryEntries() + matches := findAllMatches(library) + + return matches, "" +} + +// findAllMatches returns all matches for the anime inside an anilist anime list. +func findAllMatches(library chan *kitsu.LibraryEntry) []*arn.KitsuMatch { + matches := []*arn.KitsuMatch{} + + for item := range library { + // Ignore non-anime entries + if item.Anime == nil { + continue + } + + var anime *arn.Anime + connection, err := arn.GetKitsuToAnime(item.Anime.ID) + + if err == nil { + anime, _ = arn.GetAnime(connection.AnimeID) + } + + matches = append(matches, &arn.KitsuMatch{ + KitsuItem: item, + ARNAnime: anime, + }) + } + + return matches +} diff --git a/pages/listimport/listimportkitsu/kitsu.pixy b/pages/listimport/listimportkitsu/kitsu.pixy new file mode 100644 index 00000000..1a39b7a8 --- /dev/null +++ b/pages/listimport/listimportkitsu/kitsu.pixy @@ -0,0 +1,23 @@ +component ImportKitsu(user *arn.User, matches []*arn.KitsuMatch) + h1= "kitsu.io Import (" + user.Accounts.Kitsu.Nick + ", " + toString(len(matches)) + " anime)" + + table.import-list + thead + tr + th kitsu.io + th notify.moe + tbody + each match in matches + tr + td + a(href=match.KitsuItem.Anime.Link(), target="_blank", rel="noopener")= match.KitsuItem.Anime.Attributes.CanonicalTitle + td + if match.ARNAnime == nil + span.import-error Not found on notify.moe + else + a(href=match.ARNAnime.Link(), target="_blank", rel="noopener")= match.ARNAnime.Title.Canonical + + .buttons + a.button.mountable(href="/import/kitsu/animelist/finish") + Icon("refresh") + span Import \ No newline at end of file diff --git a/pages/listimport/listimportmyanimelist/myanimelist.go b/pages/listimport/listimportmyanimelist/myanimelist.go new file mode 100644 index 00000000..455a9f8b --- /dev/null +++ b/pages/listimport/listimportmyanimelist/myanimelist.go @@ -0,0 +1,119 @@ +package listimportmyanimelist + +import ( + "net/http" + "strconv" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/mal" + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" +) + +// Preview shows an import preview. +func Preview(ctx *aero.Context) string { + user := utils.GetUser(ctx) + + if user == nil { + return ctx.Error(http.StatusBadRequest, "Not logged in", nil) + } + + matches, response := getMatches(ctx) + + if response != "" { + return response + } + + return ctx.HTML(components.ImportMyAnimeList(user, matches)) +} + +// Finish ... +func Finish(ctx *aero.Context) string { + user := utils.GetUser(ctx) + + if user == nil { + return ctx.Error(http.StatusBadRequest, "Not logged in", nil) + } + + matches, response := getMatches(ctx) + + if response != "" { + return response + } + + animeList := user.AnimeList() + + for _, match := range matches { + if match.ARNAnime == nil || match.MyAnimeListItem == nil { + continue + } + + rating, _ := strconv.ParseFloat(match.MyAnimeListItem.MyScore, 64) + episodesWatched, _ := strconv.Atoi(match.MyAnimeListItem.MyWatchedEpisodes) + rewatchCount, convErr := strconv.Atoi(match.MyAnimeListItem.MyRewatching) + + if convErr != nil { + rewatchCount = 0 + } + + item := &arn.AnimeListItem{ + AnimeID: match.ARNAnime.ID, + Status: arn.MyAnimeListStatusToARNStatus(match.MyAnimeListItem.MyStatus), + Episodes: episodesWatched, + Notes: "", + Rating: &arn.AnimeRating{ + Overall: rating, + }, + RewatchCount: rewatchCount, + Created: arn.DateTimeUTC(), + Edited: arn.DateTimeUTC(), + } + + animeList.Import(item) + } + + animeList.Save() + + return ctx.Redirect("/+" + user.Nick + "/animelist") +} + +// getMatches finds and returns all matches for the logged in user. +func getMatches(ctx *aero.Context) ([]*arn.MyAnimeListMatch, string) { + user := utils.GetUser(ctx) + + if user == nil { + return nil, ctx.Error(http.StatusBadRequest, "Not logged in", nil) + } + + malAnimeList, err := mal.GetAnimeList(user.Accounts.MyAnimeList.Nick) + + if err != nil { + return nil, ctx.Error(http.StatusBadRequest, "Couldn't load your anime list from MyAnimeList", err) + } + + matches := findAllMatches(malAnimeList) + + return matches, "" +} + +// findAllMatches returns all matches for the anime inside an anilist anime list. +func findAllMatches(animeList *mal.AnimeList) []*arn.MyAnimeListMatch { + matches := []*arn.MyAnimeListMatch{} + + for _, item := range animeList.Items { + var anime *arn.Anime + connection, err := arn.GetMyAnimeListToAnime(item.AnimeID) + + if err == nil { + anime, _ = arn.GetAnime(connection.AnimeID) + } + + matches = append(matches, &arn.MyAnimeListMatch{ + MyAnimeListItem: item, + ARNAnime: anime, + }) + } + + return matches +} diff --git a/pages/listimport/listimportmyanimelist/myanimelist.pixy b/pages/listimport/listimportmyanimelist/myanimelist.pixy new file mode 100644 index 00000000..2908e126 --- /dev/null +++ b/pages/listimport/listimportmyanimelist/myanimelist.pixy @@ -0,0 +1,23 @@ +component ImportMyAnimeList(user *arn.User, matches []*arn.MyAnimeListMatch) + h1= "myanimelist.net Import (" + user.Accounts.MyAnimeList.Nick + ", " + toString(len(matches)) + " anime)" + + table.import-list + thead + tr + th myanimelist.net + th notify.moe + tbody + each match in matches + tr + td + a(href=match.MyAnimeListItem.AnimeLink(), target="_blank", rel="noopener")= match.MyAnimeListItem.AnimeTitle + td + if match.ARNAnime == nil + span.import-error Not found on notify.moe + else + a(href=match.ARNAnime.Link(), target="_blank", rel="noopener")= match.ARNAnime.Title.Canonical + + .buttons + a.button.mountable(href="/import/myanimelist/animelist/finish") + Icon("refresh") + span Import \ No newline at end of file diff --git a/pages/login/login.pixy b/pages/login/login.pixy index ce41d9f9..34d4b59f 100644 --- a/pages/login/login.pixy +++ b/pages/login/login.pixy @@ -1,4 +1,9 @@ component Login - .login-buttons - a.login-button(href="/auth/google") - img.login-button-image(src="/images/login/google", alt="Google Login", title="Login with your Google account") \ No newline at end of file + .login-buttons.mountable + a.login-button.login-button-google(href="/auth/google") + Icon("google") + span Sign in via Google + + a.login-button.login-button-facebook(href="/auth/facebook") + Icon("facebook") + span Sign in via Facebook \ No newline at end of file diff --git a/pages/login/login.scarlet b/pages/login/login.scarlet index 2a43abf7..c9c80033 100644 --- a/pages/login/login.scarlet +++ b/pages/login/login.scarlet @@ -4,8 +4,8 @@ justify-content center .login-button - // + padding 0.5rem + color white -.login-button-image - max-width 236px - max-height 44px \ No newline at end of file + :hover + color white \ No newline at end of file diff --git a/pages/me/me.go b/pages/me/me.go new file mode 100644 index 00000000..51a42c9b --- /dev/null +++ b/pages/me/me.go @@ -0,0 +1,17 @@ +package me + +import ( + "github.com/aerogo/aero" + "github.com/animenotifier/notify.moe/utils" +) + +// Get ... +func Get(ctx *aero.Context) string { + user := utils.GetUser(ctx) + + if user == nil { + return ctx.JSON(nil) + } + + return ctx.JSON(user) +} diff --git a/pages/newthread/newthread.go b/pages/newthread/newthread.go new file mode 100644 index 00000000..614af029 --- /dev/null +++ b/pages/newthread/newthread.go @@ -0,0 +1,20 @@ +package newthread + +import ( + "net/http" + + "github.com/aerogo/aero" + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" +) + +// Get forums page. +func Get(ctx *aero.Context) string { + user := utils.GetUser(ctx) + + if user == nil { + return ctx.Error(http.StatusBadRequest, "Not logged in", nil) + } + + return ctx.HTML(components.NewThread(user)) +} diff --git a/pages/newthread/newthread.pixy b/pages/newthread/newthread.pixy new file mode 100644 index 00000000..cd28f085 --- /dev/null +++ b/pages/newthread/newthread.pixy @@ -0,0 +1,23 @@ +component NewThread(user *arn.User) + h1 New thread + + .widget-form + .widget + input#title.widget-ui-element(type="text", placeholder="Title") + + textarea#text.widget-ui-element(placeholder="Content") + + select#tag.widget-ui-element(value="general") + option(value="general") General + option(value="news") News + option(value="anime") Anime + option(value="bug") Bug + option(value="suggestion") Suggestion + + if user.Role == "admin" + option(value="update") Update + + .buttons + button.action(data-action="createThread", data-trigger="click") + Icon("check") + span Create thread \ No newline at end of file diff --git a/pages/notifications/notifications.go b/pages/notifications/notifications.go new file mode 100644 index 00000000..8bf4a4ac --- /dev/null +++ b/pages/notifications/notifications.go @@ -0,0 +1,28 @@ +package notifications + +import ( + "net/http" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/utils" +) + +// Test ... +func Test(ctx *aero.Context) string { + user := utils.GetUser(ctx) + + if user == nil { + return ctx.Error(http.StatusBadRequest, "Not logged in", nil) + } + + notification := &arn.Notification{ + Title: "Anime Notifier", + Message: "Yay, it works!", + Icon: "https://" + ctx.App.Config.Domain + "/images/brand/220.png", + } + + user.SendNotification(notification) + + return "ok" +} diff --git a/pages/paypal/cancel.go b/pages/paypal/cancel.go new file mode 100644 index 00000000..d607bf5c --- /dev/null +++ b/pages/paypal/cancel.go @@ -0,0 +1,15 @@ +package paypal + +import ( + "fmt" + + "github.com/aerogo/aero" +) + +// Cancel ... +func Cancel(ctx *aero.Context) string { + token := ctx.Query("token") + fmt.Println("cancel", token) + + return ctx.HTML("cancel") +} diff --git a/pages/paypal/paypal.go b/pages/paypal/paypal.go new file mode 100644 index 00000000..af1813f6 --- /dev/null +++ b/pages/paypal/paypal.go @@ -0,0 +1,102 @@ +package paypal + +import ( + "net/http" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/utils" + "github.com/logpacker/PayPal-Go-SDK" +) + +// CreatePayment ... +func CreatePayment(ctx *aero.Context) string { + user := utils.GetUser(ctx) + + if user == nil { + return ctx.Error(http.StatusUnauthorized, "Not logged in", nil) + } + + amount, err := ctx.Request().Body().String() + + if err != nil { + return ctx.Error(http.StatusBadRequest, "Could not read amount", err) + } + + // Verify amount + switch amount { + case "1000", "2000", "3000", "6000", "12000": + // OK + default: + return ctx.Error(http.StatusBadRequest, "Incorrect amount", nil) + } + + // Initiate PayPal client + c, err := arn.PayPal() + + if err != nil { + return ctx.Error(http.StatusInternalServerError, "Could not initiate PayPal client", err) + } + + // Get access token + _, err = c.GetAccessToken() + + if err != nil { + return ctx.Error(http.StatusInternalServerError, "Could not get PayPal access token", err) + } + + // webprofile := paypalsdk.WebProfile{ + // Name: "Anime Notifier", + // Presentation: paypalsdk.Presentation{ + // BrandName: "Anime Notifier", + // LogoImage: "https://notify.moe/brand/220", + // LocaleCode: "US", + // }, + + // InputFields: paypalsdk.InputFields{ + // AllowNote: true, + // NoShipping: paypalsdk.NoShippingDisplay, + // AddressOverride: paypalsdk.AddrOverrideFromCall, + // }, + + // FlowConfig: paypalsdk.FlowConfig{ + // LandingPageType: paypalsdk.LandingPageTypeBilling, + // }, + // } + + // result, err := c.CreateWebProfile(webprofile) + // c.SetWebProfile(*result) + + // if err != nil { + // return ctx.Error(http.StatusInternalServerError, "Could not create PayPal web profile", err) + // } + + // total := amount[:len(amount)-2] + "." + amount[len(amount)-2:] + + // Create payment + p := paypalsdk.Payment{ + Intent: "sale", + Payer: &paypalsdk.Payer{ + PaymentMethod: "paypal", + }, + Transactions: []paypalsdk.Transaction{paypalsdk.Transaction{ + Amount: &paypalsdk.Amount{ + Currency: "JPY", + Total: amount, + }, + Description: "Top Up Balance", + }}, + RedirectURLs: &paypalsdk.RedirectURLs{ + ReturnURL: "https://" + ctx.App.Config.Domain + "/paypal/success", + CancelURL: "https://" + ctx.App.Config.Domain + "/paypal/cancel", + }, + } + + paymentResponse, err := c.CreatePayment(p) + + if err != nil { + return ctx.Error(http.StatusInternalServerError, "Could not create PayPal payment", err) + } + + return ctx.JSON(paymentResponse) +} diff --git a/pages/paypal/success.go b/pages/paypal/success.go new file mode 100644 index 00000000..6bd509e3 --- /dev/null +++ b/pages/paypal/success.go @@ -0,0 +1,89 @@ +package paypal + +import ( + "errors" + "net/http" + "strconv" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" +) + +const adminID = "4J6qpK1ve" + +// Success ... +func Success(ctx *aero.Context) string { + user := utils.GetUser(ctx) + + if user == nil { + return ctx.Error(http.StatusUnauthorized, "Not logged in", nil) + } + + paymentID := ctx.Query("paymentId") + token := ctx.Query("token") + payerID := ctx.Query("PayerID") + + if paymentID == "" || payerID == "" || token == "" { + return ctx.Error(http.StatusBadRequest, "Invalid parameters", errors.New("paymentId, token and PayerID are required")) + } + + c, err := arn.PayPal() + + if err != nil { + return ctx.Error(http.StatusInternalServerError, "Could not initiate PayPal client", err) + } + + c.SetAccessToken(token) + execute, err := c.ExecuteApprovedPayment(paymentID, payerID) + + if err != nil { + return ctx.Error(http.StatusInternalServerError, "Error executing PayPal payment", err) + } + + if execute.State != "approved" { + return ctx.Error(http.StatusInternalServerError, "PayPal payment has not been approved", err) + } + + sdkPayment, err := c.GetPayment(paymentID) + + if err != nil { + return ctx.Error(http.StatusInternalServerError, "Could not retrieve payment information", err) + } + + arn.PrettyPrint(sdkPayment) + + transaction := sdkPayment.Transactions[0] + + payment := &arn.PayPalPayment{ + ID: paymentID, + PayerID: payerID, + UserID: user.ID, + Method: sdkPayment.Payer.PaymentMethod, + Amount: transaction.Amount.Total, + Currency: transaction.Amount.Currency, + Created: arn.DateTimeUTC(), + } + + payment.Save() + + // Increase user's balance + user.Balance += payment.Gems() + + // Save in DB + user.Save() + + // Notify admin + go func() { + admin, _ := arn.GetUser(adminID) + admin.SendNotification(&arn.Notification{ + Title: user.Nick + " bought " + strconv.Itoa(payment.Gems()) + " gems", + Message: user.Nick + " paid " + payment.Amount + " " + payment.Currency + " making his new balance " + strconv.Itoa(user.Balance), + Icon: user.LargeAvatar(), + Link: "https://" + ctx.App.Config.Domain + "/api/paypalpayment/" + payment.ID, + }) + }() + + return ctx.HTML(components.PayPalSuccess(payment)) +} diff --git a/pages/paypal/success.pixy b/pages/paypal/success.pixy new file mode 100644 index 00000000..3b20c271 --- /dev/null +++ b/pages/paypal/success.pixy @@ -0,0 +1,16 @@ +component PayPalSuccess(payment *arn.PayPalPayment) + h1 Thank you for your support! + + .new-payment.mountable + span + + .new-payment-amount.count-up= payment.Gems() + span.new-payment-currency + Icon("diamond") + + p.mountable + img.new-payment-thank-you-image(src="/images/elements/thank-you.jpg", alt="Thank you!") + + .buttons + a.button.ajax(href="/shop") + Icon("shopping-cart") + span Return to the shop \ No newline at end of file diff --git a/pages/paypal/success.scarlet b/pages/paypal/success.scarlet new file mode 100644 index 00000000..d528c358 --- /dev/null +++ b/pages/paypal/success.scarlet @@ -0,0 +1,13 @@ +.new-payment + horizontal + margin 2rem auto + font-size 4rem + line-height 1em + color green + +.new-payment-currency + margin-left 1rem + margin-bottom content-padding + +.new-payment-thank-you-image + width 683px \ No newline at end of file diff --git a/pages/popularanime/old/popular.pixy b/pages/popularanime/old/popular.pixy deleted file mode 100644 index 4d650c83..00000000 --- a/pages/popularanime/old/popular.pixy +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 6ee9f2dd..00000000 --- a/pages/popularanime/old/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/popularanime/popular.go b/pages/popularanime/popular.go deleted file mode 100644 index 1d80e7ef..00000000 --- a/pages/popularanime/popular.go +++ /dev/null @@ -1,20 +0,0 @@ -package popularanime - -import ( - "net/http" - - "github.com/aerogo/aero" - "github.com/animenotifier/arn" - "github.com/animenotifier/notify.moe/components" -) - -// Get search page. -func Get(ctx *aero.Context) string { - animeList, err := arn.GetPopularAnimeCached() - - if err != nil { - return ctx.Error(http.StatusInternalServerError, "Error fetching popular anime", err) - } - - return ctx.HTML(components.PopularAnime(animeList)) -} diff --git a/pages/popularanime/popular.pixy b/pages/popularanime/popular.pixy deleted file mode 100644 index 3d5aeec7..00000000 --- a/pages/popularanime/popular.pixy +++ /dev/null @@ -1,6 +0,0 @@ -component PopularAnime(animeList []*arn.Anime) - h2 Top 3 - AnimeGrid(animeList[:3]) - - h2 Popular - AnimeGrid(animeList[3:]) \ No newline at end of file diff --git a/pages/posts/posts.go b/pages/posts/posts.go index de0e45bd..b6bf6aca 100644 --- a/pages/posts/posts.go +++ b/pages/posts/posts.go @@ -1,19 +1,23 @@ package posts import ( + "net/http" + "github.com/aerogo/aero" "github.com/animenotifier/arn" "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" ) // Get post. func Get(ctx *aero.Context) string { id := ctx.Get("id") + user := utils.GetUser(ctx) post, err := arn.GetPost(id) if err != nil { - return ctx.Error(404, "Post not found", err) + return ctx.Error(http.StatusNotFound, "Post not found", err) } - return ctx.HTML(components.Post(post)) + return ctx.HTML(components.Post(post, user)) } diff --git a/pages/posts/posts.pixy b/pages/posts/posts.pixy index 6d02e8cc..2de3e8b1 100644 --- a/pages/posts/posts.pixy +++ b/pages/posts/posts.pixy @@ -1,5 +1,5 @@ -component Post(post *arn.Post) - Postable(post.ToPostable(), "") +component Post(post *arn.Post, user *arn.User) + Postable(post.ToPostable(), user, "") .side-note a.ajax(href=post.Thread().Link())= post.Thread().Title diff --git a/pages/profile/followers.go b/pages/profile/followers.go new file mode 100644 index 00000000..3155df4e --- /dev/null +++ b/pages/profile/followers.go @@ -0,0 +1,26 @@ +package profile + +import ( + "net/http" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" +) + +// GetFollowers shows the followers of a particular user. +func GetFollowers(ctx *aero.Context) string { + nick := ctx.Get("nick") + viewUser, err := arn.GetUserByNick(nick) + + if err != nil { + return ctx.Error(http.StatusNotFound, "User not found", err) + } + + followers := viewUser.Followers() + arn.SortUsersLastSeen(followers) + + return ctx.HTML(components.ProfileFollowers(followers, viewUser, utils.GetUser(ctx), ctx.URI())) + +} diff --git a/pages/profile/followers.pixy b/pages/profile/followers.pixy new file mode 100644 index 00000000..63a2ccbd --- /dev/null +++ b/pages/profile/followers.pixy @@ -0,0 +1,19 @@ +component ProfileFollowers(followers []*arn.User, viewUser *arn.User, user *arn.User, uri string) + ProfileHeader(viewUser, user, uri) + + if len(followers) > 0 + UserGrid(followers) + else + p.no-data.mountable= viewUser.Nick + " doesn't have a follower yet." + +component UserGrid(users []*arn.User) + .user-avatars + each user in users + if user.Nick != "" + if user.IsActive() + .mountable + Avatar(user) + else + .mountable + .inactive-user + Avatar(user) \ No newline at end of file diff --git a/pages/profile/followers.scarlet b/pages/profile/followers.scarlet new file mode 100644 index 00000000..adf658da --- /dev/null +++ b/pages/profile/followers.scarlet @@ -0,0 +1,2 @@ +.inactive-user + // opacity 0.25 \ No newline at end of file diff --git a/pages/profile/posts.go b/pages/profile/posts.go index 9f91239a..c4fa922c 100644 --- a/pages/profile/posts.go +++ b/pages/profile/posts.go @@ -6,6 +6,7 @@ import ( "github.com/aerogo/aero" "github.com/animenotifier/arn" "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" ) const postLimit = 10 @@ -13,13 +14,13 @@ const postLimit = 10 // GetPostsByUser shows all forum posts of a particular user. func GetPostsByUser(ctx *aero.Context) string { nick := ctx.Get("nick") - user, err := arn.GetUserByNick(nick) + viewUser, err := arn.GetUserByNick(nick) if err != nil { return ctx.Error(http.StatusNotFound, "User not found", err) } - posts := user.Posts() + posts := viewUser.Posts() arn.SortPostsLatestFirst(posts) var postables []arn.Postable @@ -34,6 +35,6 @@ func GetPostsByUser(ctx *aero.Context) string { postables[i] = arn.ToPostable(post) } - return ctx.HTML(components.LatestPosts(postables, user)) + return ctx.HTML(components.LatestPosts(postables, viewUser, utils.GetUser(ctx), ctx.URI())) } diff --git a/pages/profile/posts.pixy b/pages/profile/posts.pixy index 5d18a568..d7176bd8 100644 --- a/pages/profile/posts.pixy +++ b/pages/profile/posts.pixy @@ -1,6 +1,8 @@ -component LatestPosts(postables []arn.Postable, viewUser *arn.User) +component LatestPosts(postables []arn.Postable, viewUser *arn.User, user *arn.User, uri string) + ProfileHeader(viewUser, user, uri) + if len(postables) > 0 - h2.thread-title= len(postables), " latest posts by ", postables[0].Author().Nick - PostableList(postables) + h1.page-title= len(postables), " latest posts by ", postables[0].Author().Nick + PostableList(postables, user) else - p= viewUser.Nick + " hasn't written any posts yet." \ No newline at end of file + p.no-data.mountable= viewUser.Nick + " hasn't written any posts yet." \ No newline at end of file diff --git a/pages/profile/profile.go b/pages/profile/profile.go index c146f2ac..9950a9ec 100644 --- a/pages/profile/profile.go +++ b/pages/profile/profile.go @@ -1,6 +1,8 @@ package profile import ( + "sort" + "github.com/aerogo/aero" "github.com/aerogo/flow" "github.com/animenotifier/arn" @@ -9,6 +11,7 @@ import ( ) const maxPosts = 5 +const maxTracks = 3 // Get user profile page. func Get(ctx *aero.Context) string { @@ -27,28 +30,38 @@ func Profile(ctx *aero.Context, viewUser *arn.User) string { var user *arn.User var threads []*arn.Thread var animeList *arn.AnimeList + var tracks []*arn.SoundTrack var posts []*arn.Post flow.Parallel(func() { user = utils.GetUser(ctx) }, func() { animeList = viewUser.AnimeList() - }, func() { - threads = viewUser.Threads() + animeList.PrefetchAnime() - arn.SortThreadsByDate(threads) - - if len(threads) > maxPosts { - threads = threads[:maxPosts] - } - }, func() { - posts = viewUser.Posts() - arn.SortPostsLatestFirst(posts) - - if len(posts) > maxPosts { - posts = posts[:maxPosts] - } + // Sort by rating + sort.Slice(animeList.Items, func(i, j int) bool { + return animeList.Items[i].Rating.Overall > animeList.Items[j].Rating.Overall + }) }) - return ctx.HTML(components.Profile(viewUser, user, animeList, threads, posts)) + openGraph := &arn.OpenGraph{ + Tags: map[string]string{ + "og:title": viewUser.Nick, + "og:image": viewUser.LargeAvatar(), + "og:url": "https://" + ctx.App.Config.Domain + viewUser.Link(), + "og:site_name": "notify.moe", + "og:description": viewUser.Tagline, + "og:type": "profile", + "profile:username": viewUser.Nick, + }, + Meta: map[string]string{ + "description": viewUser.Tagline, + "keywords": viewUser.Nick + ",profile", + }, + } + + ctx.Data = openGraph + + return ctx.HTML(components.Profile(viewUser, user, animeList, threads, posts, tracks, ctx.URI())) } diff --git a/pages/profile/profile.pixy b/pages/profile/profile.pixy index 3dcbd6b2..ef2967ed 100644 --- a/pages/profile/profile.pixy +++ b/pages/profile/profile.pixy @@ -1,12 +1,12 @@ -component ProfileHeader(viewUser *arn.User, user *arn.User) +component ProfileHeader(viewUser *arn.User, user *arn.User, uri string) .profile - img.profile-cover(src=viewUser.CoverImageURL(), alt="Cover image") + img.profile-cover.lazy(data-src=viewUser.CoverImageURL(), data-webp="true", alt="Cover image") - .image-container.mountable + .profile-image-container.mountable.never-unmount ProfileImage(viewUser) - .intro-container.mountable - h2#nick= viewUser.Nick + .intro-container.mountable.never-unmount + h1#nick= viewUser.Nick if viewUser.Tagline != "" p.profile-field.tagline @@ -20,12 +20,12 @@ component ProfileHeader(viewUser *arn.User, user *arn.User) if viewUser.Website != "" p.profile-field.website Icon("home") - a(href=viewUser.WebsiteURL(), target="_blank", rel="nofollow")= viewUser.Website + a(href=viewUser.WebsiteURL(), target="_blank", rel="nofollow")= viewUser.WebsiteShortURL() - if viewUser.Accounts.Osu.Nick != "" && viewUser.Accounts.Osu.PP >= 1000 - p.profile-field.osu(title="osu! performance points") + if viewUser.Accounts.Osu.Nick != "" && viewUser.Accounts.Osu.PP >= 100 + p.profile-field.osu(title="osu! Level " + toString(int(viewUser.Accounts.Osu.Level)) + " | Accuracy: " + fmt.Sprintf("%.1f", viewUser.Accounts.Osu.Accuracy) + "%") Icon("trophy") - span= toString(int(viewUser.Accounts.Osu.PP)) + " pp" + a(href="https://osu.ppy.sh/u/" + viewUser.Accounts.Osu.Nick, target="_blank", rel="noopener")= toString(int(viewUser.Accounts.Osu.PP)) + " pp" //- if viewUser.dataEditCount //- p.profile-field.editor-contribution(title="Anime data modifications") @@ -43,47 +43,120 @@ component ProfileHeader(viewUser *arn.User, user *arn.User) p.profile-field.role Icon("rocket") span= arn.Capitalize(viewUser.Role) - -component Profile(viewUser *arn.User, user *arn.User, animeList *arn.AnimeList, threads []*arn.Thread, posts []*arn.Post) - ProfileHeader(viewUser, user) - - .profile-category.mountable - h3 - a.ajax(href="/+" + viewUser.Nick + "/animelist", title="View all anime") Anime - - .profile-watching-list - if len(animeList.Items) == 0 - p No anime in the collection. - 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.lazy(data-src=item.Anime().Image.Tiny, alt=item.Anime().Title.Canonical) - - .profile-category.mountable - h3 - a.ajax(href="/+" + viewUser.Nick + "/threads", title="View all threads") Threads - - if len(threads) == 0 - p No threads on the forum. - else - each thread in threads - ThreadLink(thread) - .profile-category.mountable - h3 - a.ajax(href="/+" + viewUser.Nick + "/posts", title="View all posts") Posts - if len(posts) == 0 - p No posts on the forum. - else - each post in posts - .post - .post-author - Avatar(post.Author()) - .post-content - div!= post.HTML() - .post-toolbar.active - .spacer - .post-likes= len(post.Likes) + + if viewUser.IsPro() + p.profile-field.profile-pro-status + a.ajax(href="/shop", title="PRO user") + Icon("star") + span.profile-pro-status-text PRO + + if user != nil + .profile-actions + if user.ID != viewUser.ID + if !user.Follows().Contains(viewUser.ID) + button.profile-action.action(data-action="followUser", data-trigger="click", data-api="/api/userfollows/" + user.ID + "/add/" + viewUser.ID) + Icon("user-plus") + span Follow + else + button.profile-action.action(data-action="unfollowUser", data-trigger="click", data-api="/api/userfollows/" + user.ID + "/remove/" + viewUser.ID) + Icon("user-times") + span Unfollow + + a.button.profile-action.ajax(href="/compare/animelist/" + user.Nick + "/" + viewUser.Nick) + Icon("exchange") + span Compare + ProfileNavigation(viewUser, uri) + +component ProfileNavigation(viewUser *arn.User, uri string) + .tabs + a.tab.action(href="/+" + viewUser.Nick, data-action="diff", data-trigger="click") + Icon("th") + span.tab-text Anime + + a.tab.action(href="/+" + viewUser.Nick + "/animelist/watching", data-action="diff", data-trigger="click") + Icon("list") + span.tab-text Collection + + a.tab.action(href="/+" + viewUser.Nick + "/threads", data-action="diff", data-trigger="click") + Icon("comment") + span.tab-text Threads + + a.tab.action(href="/+" + viewUser.Nick + "/posts", data-action="diff", data-trigger="click") + Icon("comments") + span.tab-text Posts + + a.tab.action(href="/+" + viewUser.Nick + "/soundtracks", data-action="diff", data-trigger="click") + Icon("music") + span.tab-text Tracks + + a.tab.action(href="/+" + viewUser.Nick + "/stats", data-action="diff", data-trigger="click") + Icon("area-chart") + span.tab-text Stats + + a.tab.action(href="/+" + viewUser.Nick + "/followers", data-action="diff", data-trigger="click") + Icon("users") + span.tab-text Followers + + if strings.Contains(uri, "/animelist") + StatusTabs("/+" + viewUser.Nick + "/animelist") + +component Profile(viewUser *arn.User, user *arn.User, animeList *arn.AnimeList, threads []*arn.Thread, posts []*arn.Post, tracks []*arn.SoundTrack, uri string) + ProfileHeader(viewUser, user, uri) + + if len(animeList.Items) == 0 + p.no-data.mountable= viewUser.Nick + " hasn't added any anime yet." + else + .profile-watching-list.mountable + each item in animeList.Items + if item.Status == arn.AnimeListStatusWatching || item.Status == arn.AnimeListStatusCompleted + a.profile-watching-list-item.ajax(href=item.Anime().Link(), title=item.Anime().Title.ByUser(user) + " (" + toString(item.Episodes) + " / " + arn.EpisodesToString(item.Anime().EpisodeCount) + ")") + img.profile-watching-list-item-image.lazy(data-src=item.Anime().Image.Tiny, alt=item.Anime().Title.ByUser(user)) + + if user != nil && (user.Role == "admin" || user.Role == "editor") + .footer + .buttons + a.button.profile-action(href="/api/user/" + viewUser.ID, target="_blank", rel="noopener") + Icon("search-plus") + span JSON + + //- .profile-category.mountable + //- h3 + //- a.ajax(href="/+" + viewUser.Nick + "/threads", title="View all threads") Threads + + //- if len(threads) == 0 + //- p No threads on the forum. + //- else + //- each thread in threads + //- ThreadLink(thread) + + //- .profile-category.mountable + //- h3 + //- a.ajax(href="/+" + viewUser.Nick + "/posts", title="View all posts") Posts + //- if len(posts) == 0 + //- p No posts on the forum. + //- else + //- each post in posts + //- .post + //- .post-author + //- Avatar(post.Author()) + //- .post-content + //- div!= post.HTML() + //- .post-toolbar.active + //- .spacer + //- .post-likes= len(post.Likes) + + //- .profile-category.mountable + //- h3 + //- a.ajax(href="/+" + viewUser.Nick + "/tracks", title="View all tracks") Tracks + + //- if len(tracks) == 0 + //- p No soundtracks posted yet. + //- else + //- .sound-tracks + //- each track in tracks + //- SoundTrack(track) + //- 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/profile.scarlet b/pages/profile/profile.scarlet index f67aac62..6b1c9147 100644 --- a/pages/profile/profile.scarlet +++ b/pages/profile/profile.scarlet @@ -1,7 +1,8 @@ profile-boot-duration = 2s .profile - horizontal + vertical + align-items center position relative left calc(content-padding * -1) @@ -20,15 +21,64 @@ profile-boot-duration = 2s .profile-field text-align center -< 600px + a + color white + +.intro-container + vertical + align-items center + margin-top calc(content-padding * 1.5) + +.profile-actions + vertical + margin-top content-padding + + :empty + display none + +.profile-action + margin-bottom 0.5rem + text-shadow none !important + +.profile-pro-status + margin-top calc(typography-margin * 2) + + .icon + color pro-color + animation sk-pulse 1.5s infinite linear + +> 740px .profile - vertical - align-items center + horizontal + align-items flex-start + + .profile-field + text-align left .intro-container - align-items center - margin-top calc(content-padding * 1.5) - padding-left content-padding + align-items flex-start + margin-top 0 + padding content-padding + padding-top 0 + padding-left calc(content-padding * 2) + max-width 900px + + .profile-actions + position absolute + top 0 + right 0 + padding content-padding + margin-top 0 + + .profile-pro-status + position absolute + right 0 + bottom 0 + padding content-padding + margin-top 0 + + // .profile-pro-status-text + // display none // animation appear // 0% @@ -52,7 +102,7 @@ profile-boot-duration = 2s // default-transition // animation cover-animation profile-boot-duration // animation-fill-mode forwards - filter brightness(35%) blur(0) + filter brightness(30%) blur(0) // animation cover-animation // 0% @@ -65,29 +115,16 @@ profile-boot-duration = 2s width 100% height auto -.image-container +.profile-image-container flex 1 max-width 280px max-height 280px border-radius 3px overflow hidden -.intro-container - vertical - align-items flex-start - padding content-padding - padding-top 0 - padding-left calc(content-padding * 2) - max-width 900px - -.website - a - color white - #nick margin-bottom 1rem -// Categories - -.profile-category - margin-bottom content-padding \ No newline at end of file +.no-data + width 100% + text-align center \ No newline at end of file diff --git a/pages/profile/stats.go b/pages/profile/stats.go new file mode 100644 index 00000000..722db07b --- /dev/null +++ b/pages/profile/stats.go @@ -0,0 +1,66 @@ +package profile + +import ( + "net/http" + "strconv" + "time" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" +) + +type stats map[string]float64 + +// GetStatsByUser shows statistics for a given user. +func GetStatsByUser(ctx *aero.Context) string { + nick := ctx.Get("nick") + viewUser, err := arn.GetUserByNick(nick) + userStats := utils.UserStats{} + ratings := stats{} + status := stats{} + types := stats{} + years := stats{} + + if err != nil { + return ctx.Error(http.StatusNotFound, "User not found", err) + } + + animeList, err := arn.GetAnimeList(viewUser.ID) + animeList.PrefetchAnime() + + if err != nil { + return ctx.Error(http.StatusInternalServerError, "Anime list not found", err) + } + + for _, item := range animeList.Items { + currentWatch := item.Episodes * item.Anime().EpisodeLength + reWatch := item.RewatchCount * item.Anime().EpisodeCount * item.Anime().EpisodeLength + duration := time.Duration(currentWatch + reWatch) + userStats.AnimeWatchingTime += duration * time.Minute + + ratings[strconv.Itoa(int(item.Rating.Overall+0.5))]++ + status[item.Status]++ + types[item.Anime().Type]++ + + if item.Anime().StartDate != "" { + year := item.Anime().StartDate[:4] + + if year < "2000" { + year = "Before 2000" + } + + years[year]++ + } + } + + userStats.PieCharts = []*arn.PieChart{ + arn.NewPieChart("Ratings", ratings), + arn.NewPieChart("Status", status), + arn.NewPieChart("Types", types), + arn.NewPieChart("Years", years), + } + + return ctx.HTML(components.ProfileStats(&userStats, viewUser, utils.GetUser(ctx), ctx.URI())) +} diff --git a/pages/profile/stats.pixy b/pages/profile/stats.pixy new file mode 100644 index 00000000..8954e756 --- /dev/null +++ b/pages/profile/stats.pixy @@ -0,0 +1,15 @@ +component ProfileStats(stats *utils.UserStats, viewUser *arn.User, user *arn.User, uri string) + ProfileHeader(viewUser, user, uri) + + .stats + each pie in stats.PieCharts + .widget.mountable + h3.widget-title + Icon("pie-chart") + span= pie.Title + PieChart(pie.Slices) + + .footer.text-center + span= viewUser.Nick + " spent " + span= int(stats.AnimeWatchingTime / time.Hour / 24) + span days watching anime. \ No newline at end of file diff --git a/pages/profile/stats.scarlet b/pages/profile/stats.scarlet new file mode 100644 index 00000000..374457bf --- /dev/null +++ b/pages/profile/stats.scarlet @@ -0,0 +1,6 @@ +.stats + horizontal-wrap + justify-content space-around + + .widget + max-width 300px \ No newline at end of file diff --git a/pages/profile/threads.go b/pages/profile/threads.go index 29cdd457..ef7fc169 100644 --- a/pages/profile/threads.go +++ b/pages/profile/threads.go @@ -6,19 +6,26 @@ import ( "github.com/aerogo/aero" "github.com/animenotifier/arn" "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" ) +const maxThreads = 20 + // GetThreadsByUser shows all forum threads of a particular user. func GetThreadsByUser(ctx *aero.Context) string { nick := ctx.Get("nick") - user, err := arn.GetUserByNick(nick) + viewUser, err := arn.GetUserByNick(nick) if err != nil { return ctx.Error(http.StatusNotFound, "User not found", err) } - threads := user.Threads() - arn.SortThreadsByDate(threads) + threads := viewUser.Threads() + arn.SortThreadsLatestFirst(threads) - return ctx.HTML(components.ThreadList(threads)) + if len(threads) > maxThreads { + threads = threads[:maxThreads] + } + + return ctx.HTML(components.ProfileThreads(threads, viewUser, utils.GetUser(ctx), ctx.URI())) } diff --git a/pages/profile/threads.pixy b/pages/profile/threads.pixy new file mode 100644 index 00000000..2899cd14 --- /dev/null +++ b/pages/profile/threads.pixy @@ -0,0 +1,8 @@ +component ProfileThreads(threads []*arn.Thread, viewUser *arn.User, user *arn.User, uri string) + ProfileHeader(viewUser, user, uri) + + if len(threads) == 0 + p.no-data.mountable= viewUser.Nick + " hasn't written any threads yet." + else + .forum + ThreadList(threads) diff --git a/pages/profile/tracks.go b/pages/profile/tracks.go new file mode 100644 index 00000000..083f3ae9 --- /dev/null +++ b/pages/profile/tracks.go @@ -0,0 +1,34 @@ +package profile + +import ( + "net/http" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" +) + +// GetSoundTracksByUser shows all soundtracks of a particular user. +func GetSoundTracksByUser(ctx *aero.Context) string { + nick := ctx.Get("nick") + user := utils.GetUser(ctx) + viewUser, err := arn.GetUserByNick(nick) + + if err != nil { + return ctx.Error(http.StatusNotFound, "User not found", err) + } + + tracks, err := arn.FilterSoundTracks(func(track *arn.SoundTrack) bool { + return !track.IsDraft && len(track.Media) > 0 && track.CreatedBy == viewUser.ID + }) + + if err != nil { + return ctx.Error(http.StatusInternalServerError, "Error fetching soundtracks", err) + } + + arn.SortSoundTracksLatestFirst(tracks) + + return ctx.HTML(components.TrackList(tracks, viewUser, user, ctx.URI())) + +} diff --git a/pages/profile/tracks.pixy b/pages/profile/tracks.pixy new file mode 100644 index 00000000..894f06f6 --- /dev/null +++ b/pages/profile/tracks.pixy @@ -0,0 +1,12 @@ +component TrackList(tracks []*arn.SoundTrack, viewUser *arn.User, user *arn.User, uri string) + ProfileHeader(viewUser, user, uri) + + h1.page-title= "Tracks added by " + viewUser.Nick + + if len(tracks) == 0 + p.no-data.mountable= viewUser.Nick + " hasn't added any tracks yet." + else + .sound-tracks + each track in tracks + SoundTrack(track) + \ No newline at end of file diff --git a/pages/profile/watching.scarlet b/pages/profile/watching.scarlet index 6b3b9b13..82b2bcd3 100644 --- a/pages/profile/watching.scarlet +++ b/pages/profile/watching.scarlet @@ -1,13 +1,9 @@ .profile-watching-list horizontal-wrap + justify-content center .profile-watching-list-item - margin 0.25rem + anime-mini-item .profile-watching-list-item-image - width 55px !important - border-radius 2px - -< 380px - .profile-watching-list - justify-content center \ No newline at end of file + anime-mini-item-image \ No newline at end of file diff --git a/pages/search/search.go b/pages/search/search.go index a7dd0516..d454f76b 100644 --- a/pages/search/search.go +++ b/pages/search/search.go @@ -6,13 +6,15 @@ import ( "github.com/animenotifier/notify.moe/components" ) -const maxUsers = 9 * 7 -const maxAnime = 9 * 7 +const maxUsers = 36 +const maxAnime = 26 +const maxPosts = 3 +const maxThreads = 3 // Get search page. func Get(ctx *aero.Context) string { - term := ctx.Get("term") + term := ctx.Query("q") - userResults, animeResults := arn.Search(term, maxUsers, maxAnime) - return ctx.HTML(components.SearchResults(userResults, animeResults)) + userResults, animeResults, postResults, threadResults := arn.Search(term, maxUsers, maxAnime, maxPosts, maxThreads) + return ctx.HTML(components.SearchResults(term, userResults, animeResults, postResults, threadResults)) } diff --git a/pages/search/search.pixy b/pages/search/search.pixy index 6e12b652..ad630b01 100644 --- a/pages/search/search.pixy +++ b/pages/search/search.pixy @@ -1,21 +1,61 @@ -component SearchResults(users []*arn.User, animeResults []*arn.Anime) - .widgets +component SearchResults(term string, users []*arn.User, animeResults []*arn.Anime, postResults []*arn.Post, threadResults []*arn.Thread) + h1.page-title= "Search: " + term + + .search .widget - h3 Users - .user-avatars.user-search - if len(users) == 0 - p No users found. - else - each user in users - Avatar(user) - //- a.ajax(href=user.Link())= user.Nick - - .widget - h3 Anime + h3.widget-title + Icon("tv") + span Anime + .profile-watching-list.anime-search if len(animeResults) == 0 - p No anime found. + p.no-search-results.mountable No anime found. else each anime in animeResults - a.profile-watching-list-item.ajax(href=anime.Link(), title=anime.Title.Canonical) - img.anime-cover-image.anime-search-result(src=anime.Image.Tiny, alt=anime.Title.Canonical) \ No newline at end of file + a.profile-watching-list-item.mountable.ajax(href=anime.Link(), title=anime.Title.Canonical, data-mountable-type="anime") + img.anime-cover-image.anime-search-result(src=anime.Image.Tiny, alt=anime.Title.Canonical) + + .widget + h3.widget-title + Icon("comment") + span Forum + + if len(postResults) == 0 && len(threadResults) == 0 + p.no-search-results.mountable No posts found. + else + each thread in threadResults + .mountable(data-mountable-type="forum") + .forum-search-result + a.forum-search-result-title.ajax(href=thread.Link())= thread.Title + if thread.Author().HasNick() + .forum-search-result-author= thread.Author().Nick + .forum-search-result-sample= thread.Text + + each post in postResults + .mountable(data-mountable-type="forum") + .forum-search-result + a.forum-search-result-title.ajax(href=post.Link(), data-mountable-type="forum")= post.Thread().Title + if post.Author().HasNick() + .forum-search-result-author= post.Author().Nick + .forum-search-result-sample= post.Text + + .widget + h3.widget-title + Icon("music") + span Soundtracks + + p.no-search-results.mountable Soundtrack search coming soon. + + .widget + h3.widget-title + Icon("user") + span Users + + .user-avatars.user-search + if len(users) == 0 + p.no-search-results.mountable No users found. + else + each user in users + .mountable(data-mountable-type="user") + Avatar(user) + //- a.ajax(href=user.Link())= user.Nick \ No newline at end of file diff --git a/pages/search/search.scarlet b/pages/search/search.scarlet index 9253f4fe..89befb2f 100644 --- a/pages/search/search.scarlet +++ b/pages/search/search.scarlet @@ -1,2 +1,26 @@ +.anime-search, +.user-search + justify-content flex-start + .anime-search-result - width 55px !important \ No newline at end of file + width 55px !important + height 78px !important + +.forum-search-result + horizontal + +.forum-search-result-title + flex 1 + clip-long-text + +.forum-search-result-author + text-align right + opacity 0.5 + +.forum-search-result-sample + clip-long-text + margin-bottom 1rem + opacity 0.8 + +.no-search-results + text-align left \ No newline at end of file diff --git a/pages/settings/settings.go b/pages/settings/settings.go index 761cc9a5..87c47159 100644 --- a/pages/settings/settings.go +++ b/pages/settings/settings.go @@ -4,17 +4,19 @@ import ( "net/http" "github.com/aerogo/aero" - "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/arn" "github.com/animenotifier/notify.moe/utils" ) -// Get user settings page. -func Get(ctx *aero.Context) string { - user := utils.GetUser(ctx) +// Get settings. +func Get(component func(*arn.User) string) func(*aero.Context) string { + return func(ctx *aero.Context) string { + user := utils.GetUser(ctx) - if user == nil { - return ctx.Error(http.StatusForbidden, "Not logged in", nil) + if user == nil { + return ctx.Error(http.StatusUnauthorized, "Not logged in", nil) + } + + return ctx.HTML(component(user)) } - - return utils.AllowEmbed(ctx, ctx.HTML(components.Settings(user))) } diff --git a/pages/settings/settings.pixy b/pages/settings/settings.pixy index d00e2e1c..a42106e2 100644 --- a/pages/settings/settings.pixy +++ b/pages/settings/settings.pixy @@ -1,6 +1,19 @@ -component Settings(user *arn.User) - h2.page-title Settings - .widgets +component SettingsTabs + .tabs + Tab("Personal", "user", "/settings") + Tab("Accounts", "cubes", "/settings/accounts") + Tab("Notifications", "bell", "/settings/notifications") + Tab("Apps", "puzzle-piece", "/settings/apps") + Tab("Avatar", "picture-o", "/settings/avatar") + Tab("Formatting", "font", "/settings/formatting") + Tab("PRO", "star", "/settings/pro") + +component SettingsPersonal(user *arn.User) + SettingsTabs + + h1.page-title Personal settings + + .settings .widget.mountable(data-api="/api/user/" + user.ID) h3.widget-title Icon("user") @@ -10,6 +23,148 @@ component Settings(user *arn.User) InputText("Tagline", user.Tagline, "Tagline", "Text that appears below your username") InputText("Website", user.Website, "Website", "Your homepage") +component SettingsNotifications(user *arn.User) + SettingsTabs + + h1.page-title Notification settings + + .settings + .widget.mountable + h3.widget-title + Icon("bell") + span Notifications + + #enable-notifications.widget-section + label Enable: + button.action(data-action="enableNotifications", data-trigger="click") + Icon("toggle-off") + span Enable notifications + + #disable-notifications.widget-section + label Disable: + button.action(data-action="disableNotifications", data-trigger="click") + Icon("toggle-on") + span Disable notifications + + #test-notification.widget-section + label Test: + button.action(data-action="testNotification", data-trigger="click") + Icon("paper-plane") + span Send test notification + +component SettingsApps(user *arn.User) + SettingsTabs + + h1.page-title App settings + + .settings + .widget.mountable + h3.widget-title + Icon("puzzle-piece") + span Apps + + .widget-section + label Chrome Extension: + button.action(data-action="installExtension", data-trigger="click") + Icon("chrome") + span Get the Chrome Extension + + .widget-section + label Desktop App: + button.action(data-action="installApp", data-trigger="click") + Icon("desktop") + span Get the Desktop App + + .widget-section + label Android App: + a.button(href="https://www.youtube.com/watch?v=opyt4cw0ep8", target="_blank", rel="noopener") + Icon("android") + span Get the Android App + +component SettingsAvatar(user *arn.User) + SettingsTabs + + h1.page-title Avatar settings + + .settings + .widget.mountable(data-api="/api/settings/" + user.ID) + h3.widget-title + Icon("picture-o") + span Avatar + + .widget-section + label(for="Avatar.Source") Source: + select.widget-ui-element.action(id="Avatar.Source", data-field="Avatar.Source", value=user.Settings().Avatar.Source, data-action="save", data-trigger="change") + option(value="") Automatic + option(value="Gravatar") Gravatar + option(value="URL") Link + //- option(value="FileSystem") Upload + + if user.Settings().Avatar.Source == "URL" + InputText("Avatar.SourceURL", user.Settings().Avatar.SourceURL, "Link", "Post the link to the image here") + + if user.Settings().Avatar.Source == "Gravatar" || (user.Settings().Avatar.Source == "" && user.Avatar.Source == "Gravatar") + .profile-image-container.avatar-preview + img.profile-image.mountable(src=user.Gravatar(), alt="Gravatar") + + if user.Settings().Avatar.Source == "URL" && user.Settings().Avatar.SourceURL != "" + .profile-image-container.avatar-preview + img.profile-image.mountable(src=strings.Replace(user.Settings().Avatar.SourceURL, "http://", "https://", 1), alt="Avatar preview") + +component SettingsFormatting(user *arn.User) + SettingsTabs + + h1.page-title Formatting settings + + .settings + .widget.mountable(data-api="/api/settings/" + user.ID) + h3.widget-title + Icon("font") + span Formatting + + .widget-section + label(for="TitleLanguage")= "Title language:" + select.widget-ui-element.action(id="TitleLanguage", data-field="TitleLanguage", value=user.Settings().TitleLanguage, title="Language of anime titles", data-action="save", data-trigger="change") + option(value="canonical") Canonical + option(value="english") English + option(value="romaji") Romaji + option(value="japanese") 日本語 + + InputNumber("Format.RatingsPrecision", float64(user.Settings().Format.RatingsPrecision), "Ratings precision", "How many decimals after the comma would you like to display in ratings on anime pages?", "0", "2", "1") + +component SettingsPro(user *arn.User) + SettingsTabs + + h1.page-title PRO settings + + .settings + .widget.mountable(data-api="/api/settings/" + user.ID) + h3.widget-title + Icon("star") + span PRO + + if user.IsPro() + .widget-section + label + span Your PRO account expires in + span.utc-date(data-date=user.ProExpires) + span . + a.button.ajax(href="/shop") + Icon("star") + span Extend PRO account duration + else + .widget-section + label Would you like to support the site development? + a.button.ajax(href="/shop") + Icon("star") + span Go PRO + +component SettingsAccounts(user *arn.User) + SettingsTabs + + h1.page-title Accounts settings + + .settings .widget.mountable(data-api="/api/user/" + user.ID) h3.widget-title Icon("cubes") @@ -18,11 +173,51 @@ 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") + InputText("Accounts.Osu.Nick", user.Accounts.Osu.Nick, "Osu", "Your username on osu.ppy.sh") + //- 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 + .widget.mountable + h3.widget-title + Icon("user-plus") + span Connect - //- InputText("TitleLanguage", user.Settings().TitleLanguage, "Title language", "Language of anime titles") \ No newline at end of file + .widget-section.social-account + label(for="google") Google: + + a#google.button.social-account-button(href="/auth/google") + if user.Accounts.Google.ID != "" + Icon("check") + span Connected + else + Icon("circle-o") + span Not connected + + .widget-section.social-account + label(for="facebook") Facebook: + + a#facebook.button.social-account-button(href="/auth/facebook") + if user.Accounts.Facebook.ID != "" + Icon("check") + span Connected + else + + Icon("circle-o") + span Not connected + + .widget.mountable + h3.widget-title + Icon("download") + span Import + + ImportLists(user) + + .widget.mountable + h3.widget-title + Icon("upload") + span Export + + .widget-section + label JSON: + a.button(href="/api/animelist/" + user.ID) + Icon("upload") + span Export anime list as JSON \ No newline at end of file diff --git a/pages/settings/settings.scarlet b/pages/settings/settings.scarlet new file mode 100644 index 00000000..23a00d75 --- /dev/null +++ b/pages/settings/settings.scarlet @@ -0,0 +1,16 @@ +.settings + horizontal-wrap-center + // vertical + // margin 0 auto + // width 100% + // max-width 400px + + .widget + max-width 400px + +.widget-section > button, +.widget-section > .button + margin-bottom 1rem + +.avatar-preview + margin 0 auto \ No newline at end of file diff --git a/pages/shop/buyitem.go b/pages/shop/buyitem.go new file mode 100644 index 00000000..df2b1d78 --- /dev/null +++ b/pages/shop/buyitem.go @@ -0,0 +1,61 @@ +package shop + +import ( + "net/http" + "sync" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/utils" +) + +var itemBuyMutex sync.Mutex + +// BuyItem ... +func BuyItem(ctx *aero.Context) string { + // Lock via mutex to prevent race conditions + itemBuyMutex.Lock() + defer itemBuyMutex.Unlock() + + // Logged in user + user := utils.GetUser(ctx) + + if user == nil { + return ctx.Error(http.StatusUnauthorized, "Not logged in", nil) + } + + // Item ID and quantity + itemID := ctx.Get("item") + quantity, err := ctx.GetInt("quantity") + + if err != nil || quantity == 0 { + return ctx.Error(http.StatusBadRequest, "Invalid item quantity", err) + } + + item, err := arn.GetItem(itemID) + + if err != nil { + return ctx.Error(http.StatusInternalServerError, "Error fetching item data", err) + } + + // Calculate total price and subtract balance + totalPrice := int(item.Price) * quantity + + if user.Balance < totalPrice { + return ctx.Error(http.StatusBadRequest, "Not enough gems", nil) + } + + user.Balance -= totalPrice + user.Save() + + // Add item to user inventory + inventory := user.Inventory() + inventory.AddItem(itemID, uint(quantity)) + inventory.Save() + + // Save purchase + purchase := arn.NewPurchase(user.ID, itemID, quantity, int(item.Price), "gem") + purchase.Save() + + return "ok" +} diff --git a/pages/shop/history.go b/pages/shop/history.go new file mode 100644 index 00000000..87b77d80 --- /dev/null +++ b/pages/shop/history.go @@ -0,0 +1,34 @@ +package shop + +import ( + "net/http" + "sort" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" +) + +// PurchaseHistory ... +func PurchaseHistory(ctx *aero.Context) string { + user := utils.GetUser(ctx) + + if user == nil { + return ctx.Error(http.StatusUnauthorized, "Not logged in", nil) + } + + purchases, err := arn.FilterPurchases(func(purchase *arn.Purchase) bool { + return purchase.UserID == user.ID + }) + + if err != nil { + return ctx.Error(http.StatusInternalServerError, "Error fetching shop item data", err) + } + + sort.Slice(purchases, func(i, j int) bool { + return purchases[i].Date > purchases[j].Date + }) + + return ctx.HTML(components.PurchaseHistory(purchases, user)) +} diff --git a/pages/shop/history.pixy b/pages/shop/history.pixy new file mode 100644 index 00000000..31288ae6 --- /dev/null +++ b/pages/shop/history.pixy @@ -0,0 +1,28 @@ +component PurchaseHistory(purchases []*arn.Purchase, user *arn.User) + ShopTabs(user) + + h1.page-title Purchase History + + if len(purchases) == 0 + p.text-center.mountable You haven't bought any items yet. + else + table + thead + tr.mountable + th Icon + th Item + th.history-quantity Quantity + th.history-price Price + th.history-date Date + tbody + each purchase in purchases + tr.shop-history-item.mountable(data-item-id=purchase.ItemID) + PurchaseInfo(purchase) + +component PurchaseInfo(purchase *arn.Purchase) + td.item-icon + Icon(purchase.Item().Icon) + td= purchase.Item().Name + td.history-quantity= purchase.Quantity + td.history-price= purchase.Price + td.history-date.utc-date(data-date=purchase.Date) \ No newline at end of file diff --git a/pages/shop/history.scarlet b/pages/shop/history.scarlet new file mode 100644 index 00000000..da4725b5 --- /dev/null +++ b/pages/shop/history.scarlet @@ -0,0 +1,2 @@ +.history-price, .history-date, .history-quantity + text-align right \ No newline at end of file diff --git a/pages/shop/shop.go b/pages/shop/shop.go new file mode 100644 index 00000000..16ec90a1 --- /dev/null +++ b/pages/shop/shop.go @@ -0,0 +1,33 @@ +package shop + +import ( + "net/http" + "sort" + + "github.com/animenotifier/arn" + + "github.com/aerogo/aero" + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" +) + +// Get shop page. +func Get(ctx *aero.Context) string { + user := utils.GetUser(ctx) + + if user == nil { + return ctx.Error(http.StatusUnauthorized, "Not logged in", nil) + } + + items, err := arn.AllItems() + + if err != nil { + return ctx.Error(http.StatusInternalServerError, "Error fetching shop item data", err) + } + + sort.Slice(items, func(i, j int) bool { + return items[i].Order < items[j].Order + }) + + return ctx.HTML(components.Shop(user, items)) +} diff --git a/pages/shop/shop.pixy b/pages/shop/shop.pixy new file mode 100644 index 00000000..d0e97467 --- /dev/null +++ b/pages/shop/shop.pixy @@ -0,0 +1,28 @@ +component Shop(user *arn.User, items []*arn.Item) + ShopTabs(user) + + h1.page-title Shop + + .shop-items + each item in items + ShopItem(item) + +component ShopTabs(user *arn.User) + .tabs + Tab("Shop", "shopping-cart", "/shop") + Tab("Inventory", "briefcase", "/inventory") + Tab("History", "history", "/shop/history") + Tab(strconv.Itoa(user.Balance), "diamond", "/charge") + +component ShopItem(item *arn.Item) + .shop-item.mountable(data-item-id=item.ID) + h3.shop-item-name + .item-icon + Icon(item.Icon) + span= item.Name + //- span.shop-item-duration= " " + duration + .shop-item-description!= markdown.Render(item.Description) + .buttons.shop-buttons + button.shop-button-buy.action(data-item-id=item.ID, data-item-name=item.Name, data-price=item.Price, data-trigger="click", data-action="buyItem") + span.shop-item-price= "Buy for " + toString(item.Price) + Icon("diamond") \ No newline at end of file diff --git a/pages/shop/shop.scarlet b/pages/shop/shop.scarlet new file mode 100644 index 00000000..214f4cab --- /dev/null +++ b/pages/shop/shop.scarlet @@ -0,0 +1,64 @@ +item-color-pro-account = hsl(0, 100%, 71%) +item-color-anime-support-ticket = hsl(217, 64%, 50%) + +.shop-items + horizontal-wrap + justify-content space-around + +.shop-item + ui-element + flex 1 + flex-basis 380px + margin calc(content-padding / 2) + padding 0.5rem 1rem + +.shop-item-name + font-size 1.6rem + text-align center + padding 0.75rem 0 + // border-bottom 1px solid rgba(0, 0, 0, 0.1) + +.item-icon + display inline-block + +// Colors +.shop-item, .inventory-slot, .shop-history-item + [data-item-id="pro-account-3"] + .item-icon + color item-color-pro-account + + [data-item-id="pro-account-6"] + .item-icon + color item-color-pro-account + + [data-item-id="pro-account-12"] + .item-icon + color item-color-pro-account + + [data-item-id="pro-account-24"] + .item-icon + color item-color-pro-account + + [data-item-id="anime-support-ticket"] + .item-icon + color item-color-anime-support-ticket + +.shop-item-price + // ... + +.shop-item-price-currency + margin-left 0.3rem + margin-right 0 + +.shop-item-duration + opacity 0.5 + text-align right + float right + +.shop-buttons + margin-top 1rem + +.shop-button-buy + .icon-diamond + margin-left 0.3rem + margin-right 0 \ No newline at end of file diff --git a/pages/soundtrack/edit.go b/pages/soundtrack/edit.go new file mode 100644 index 00000000..f1ced793 --- /dev/null +++ b/pages/soundtrack/edit.go @@ -0,0 +1,38 @@ +package soundtrack + +import ( + "net/http" + + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" + "github.com/animenotifier/notify.moe/utils/editform" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" +) + +// Edit track. +func Edit(ctx *aero.Context) string { + id := ctx.Get("id") + track, err := arn.GetSoundTrack(id) + user := utils.GetUser(ctx) + + if err != nil { + return ctx.Error(http.StatusNotFound, "Track not found", err) + } + + ctx.Data = &arn.OpenGraph{ + Tags: map[string]string{ + "og:title": track.Title, + "og:url": "https://" + ctx.App.Config.Domain + track.Link(), + "og:site_name": "notify.moe", + "og:type": "music.song", + }, + } + + if track.MainAnime() != nil { + ctx.Data.(*arn.OpenGraph).Tags["og:image"] = track.MainAnime().Image.Large + } + + return ctx.HTML(components.SoundTrackTabs(track) + editform.Render(track, "Edit soundtrack", user)) +} diff --git a/pages/soundtrack/soundtrack.go b/pages/soundtrack/soundtrack.go new file mode 100644 index 00000000..cba2f1d8 --- /dev/null +++ b/pages/soundtrack/soundtrack.go @@ -0,0 +1,43 @@ +package soundtrack + +import ( + "net/http" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" +) + +// Get track. +func Get(ctx *aero.Context) string { + id := ctx.Get("id") + track, err := arn.GetSoundTrack(id) + + if err != nil { + return ctx.Error(http.StatusNotFound, "Track not found", err) + } + + openGraph := &arn.OpenGraph{ + Tags: map[string]string{ + "og:title": track.Title, + "og:url": "https://" + ctx.App.Config.Domain + track.Link(), + "og:site_name": "notify.moe", + "og:type": "music.song", + }, + } + + if track.MainAnime() != nil { + openGraph.Tags["og:image"] = track.MainAnime().Image.Large + } + + // Set video so that it can be played + youtube := track.MediaByService("Youtube") + + if len(youtube) > 0 { + openGraph.Tags["og:video"] = "https://www.youtube.com/v/" + youtube[0].ServiceID + } + + ctx.Data = openGraph + + return ctx.HTML(components.Track(track)) +} diff --git a/pages/soundtrack/soundtrack.pixy b/pages/soundtrack/soundtrack.pixy new file mode 100644 index 00000000..3beb4d91 --- /dev/null +++ b/pages/soundtrack/soundtrack.pixy @@ -0,0 +1,56 @@ +component Track(track *arn.SoundTrack) + SoundTrackTabs(track) + + .sound-track-full-page + if track.Title == "" + h1.mountable untitled + else + h1.mountable= track.Title + + .widget-form.sound-track-media-list + each media in track.Media + .widget.mountable + h3.widget-title= media.Service + .sound-track-media + ExternalMedia(media) + + .widget.mountable + h3.widget-title Anime + + .sound-track-anime-list + each anime in track.Anime() + a.sound-track-anime-list-item.ajax(href=anime.Link(), title=anime.Title.Canonical) + img.sound-track-anime-list-item-image.lazy(data-src=anime.Image.Tiny, alt=anime.Title.Canonical) + + if len(track.Beatmaps()) > 0 + .widget.mountable + h3.widget-title Beatmaps + ul.beatmaps + for index, beatmap := range track.Beatmaps() + li + a.beatmap(href="https://osu.ppy.sh/s/" + beatmap, target="_blank")= "Beatmap #" + strconv.Itoa(index + 1) + + .widget.mountable + h3.widget-title Tags + .tags + each tag in track.Tags + .tag= tag + + .footer.text-center.mountable + if track.EditedBy != "" + span Edited + span.utc-date(data-date=track.Edited) + span by + span= track.EditedByUser().Nick + else + span Posted + span.utc-date(data-date=track.Created) + span by + span= track.Creator().Nick + + span . + +component SoundTrackTabs(track *arn.SoundTrack) + .tabs + Tab("Soundtrack", "music", track.Link()) + Tab("Edit", "pencil", track.Link() + "/edit") \ No newline at end of file diff --git a/pages/soundtrack/soundtrack.scarlet b/pages/soundtrack/soundtrack.scarlet new file mode 100644 index 00000000..a1ad6dc2 --- /dev/null +++ b/pages/soundtrack/soundtrack.scarlet @@ -0,0 +1,15 @@ +.sound-track-media-list + vertical + +.sound-track-media + iframe + width 100% + +.sound-track-anime-list + horizontal-wrap + +.sound-track-anime-list-item + anime-mini-item + +.sound-track-anime-list-item-image + anime-mini-item-image \ No newline at end of file diff --git a/pages/soundtracks/soundtracks.go b/pages/soundtracks/soundtracks.go new file mode 100644 index 00000000..ce2052ec --- /dev/null +++ b/pages/soundtracks/soundtracks.go @@ -0,0 +1,76 @@ +package soundtracks + +import ( + "net/http" + "strconv" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" +) + +const maxTracks = 12 + +// Get renders the soundtracks page. +func Get(ctx *aero.Context) string { + user := utils.GetUser(ctx) + + tracks, err := arn.FilterSoundTracks(func(track *arn.SoundTrack) bool { + return !track.IsDraft && len(track.Media) > 0 + }) + + if err != nil { + return ctx.Error(http.StatusInternalServerError, "Error fetching soundtracks", err) + } + + arn.SortSoundTracksLatestFirst(tracks) + + if len(tracks) > maxTracks { + tracks = tracks[:maxTracks] + } + + return ctx.HTML(components.SoundTracks(tracks, maxTracks, user)) +} + +// From renders the soundtracks from the given index. +func From(ctx *aero.Context) string { + user := utils.GetUser(ctx) + index, err := ctx.GetInt("index") + + if err != nil { + return ctx.Error(http.StatusBadRequest, "Invalid start index", err) + } + + allTracks, err := arn.FilterSoundTracks(func(track *arn.SoundTrack) bool { + return !track.IsDraft && len(track.Media) > 0 + }) + + if err != nil { + return ctx.Error(http.StatusInternalServerError, "Error fetching soundtracks", err) + } + + if index < 0 || index >= len(allTracks) { + return ctx.Error(http.StatusBadRequest, "Invalid start index (maximum is "+strconv.Itoa(len(allTracks))+")", nil) + } + + arn.SortSoundTracksLatestFirst(allTracks) + + tracks := allTracks[index:] + + if len(tracks) > maxTracks { + tracks = tracks[:maxTracks] + } + + nextIndex := index + maxTracks + + if nextIndex >= len(allTracks) { + // End of data - no more scrolling + ctx.Response().Header().Set("X-LoadMore-Index", "-1") + } else { + // Send the index for the next request + ctx.Response().Header().Set("X-LoadMore-Index", strconv.Itoa(nextIndex)) + } + + return ctx.HTML(components.SoundTracksScrollable(tracks, user)) +} diff --git a/pages/soundtracks/soundtracks.pixy b/pages/soundtracks/soundtracks.pixy new file mode 100644 index 00000000..a219f5d7 --- /dev/null +++ b/pages/soundtracks/soundtracks.pixy @@ -0,0 +1,24 @@ +component SoundTracks(tracks []*arn.SoundTrack, tracksPerPage int, user *arn.User) + h1 Soundtracks + + .music-buttons + if user != nil + if user.DraftIndex().SoundTrackID == "" + button.action(data-action="newObject", data-trigger="click", data-type="soundtrack") + Icon("plus") + span Add soundtrack + else + a.button.ajax(href="/soundtrack/" + user.DraftIndex().SoundTrackID + "/edit") + Icon("pencil") + span Edit draft + + #load-more-target.sound-tracks + SoundTracksScrollable(tracks, user) + + if len(tracks) == tracksPerPage + .buttons + LoadMore(tracksPerPage) + +component SoundTracksScrollable(tracks []*arn.SoundTrack, user *arn.User) + each track in tracks + SoundTrack(track) \ No newline at end of file diff --git a/pages/soundtracks/soundtracks.scarlet b/pages/soundtracks/soundtracks.scarlet new file mode 100644 index 00000000..d61517ee --- /dev/null +++ b/pages/soundtracks/soundtracks.scarlet @@ -0,0 +1,46 @@ +.sound-tracks + horizontal-wrap + justify-content space-around + +.sound-track + vertical + flex 1 + flex-basis 500px + padding 1rem + +.sound-track-content + horizontal + + iframe + width 100% + + box-shadow shadow-light + +.sound-track-footer + text-align center + margin-bottom 1rem + margin-top 0.4rem + font-size 0.9em + + span + opacity 0.65 + +.sound-track-anime-link + display none + +> 800px + .sound-track-anime-link + display block + +.sound-track-anime-image + max-width 142px + +.music-buttons + display flex + justify-content center + +> 600px + .music-buttons + position absolute + top content-padding + right content-padding \ No newline at end of file diff --git a/pages/statistics/anime.go b/pages/statistics/anime.go new file mode 100644 index 00000000..06b04db6 --- /dev/null +++ b/pages/statistics/anime.go @@ -0,0 +1,130 @@ +package statistics + +import ( + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" +) + +// Anime ... +func Anime(ctx *aero.Context) string { + pieCharts := getAnimeStats() + return ctx.HTML(components.Statistics(pieCharts)) +} + +func getAnimeStats() []*arn.PieChart { + shoboi := stats{} + anilist := stats{} + mal := stats{} + anidb := stats{} + status := stats{} + types := stats{} + shoboiEdits := stats{} + anilistEdits := stats{} + malEdits := stats{} + anidbEdits := stats{} + rating := stats{} + twist := stats{} + + for anime := range arn.StreamAnime() { + for _, external := range anime.Mappings { + if external.Service == "shoboi/anime" { + if external.CreatedBy == "" { + shoboiEdits["(auto-generated)"]++ + } else { + user, err := arn.GetUser(external.CreatedBy) + arn.PanicOnError(err) + shoboiEdits[user.Nick]++ + } + } + + if external.Service == "anilist/anime" { + if external.CreatedBy == "" { + anilistEdits["(auto-generated)"]++ + } else { + user, err := arn.GetUser(external.CreatedBy) + arn.PanicOnError(err) + anilistEdits[user.Nick]++ + } + } + + if external.Service == "myanimelist/anime" { + if external.CreatedBy == "" { + malEdits["(auto-generated)"]++ + } else { + user, err := arn.GetUser(external.CreatedBy) + arn.PanicOnError(err) + malEdits[user.Nick]++ + } + } + + if external.Service == "anidb/anime" { + if external.CreatedBy == "" { + anidbEdits["(auto-generated)"]++ + } else { + user, err := arn.GetUser(external.CreatedBy) + arn.PanicOnError(err) + anidbEdits[user.Nick]++ + } + } + } + + if anime.GetMapping("shoboi/anime") != "" { + shoboi["Connected with Shoboi"]++ + } else { + shoboi["Not connected with Shoboi"]++ + } + + if anime.GetMapping("anilist/anime") != "" { + anilist["Connected with AniList"]++ + } else { + anilist["Not connected with AniList"]++ + } + + if anime.GetMapping("myanimelist/anime") != "" { + mal["Connected with MyAnimeList"]++ + } else { + mal["Not connected with MyAnimeList"]++ + } + + if anime.GetMapping("anidb/anime") != "" { + anidb["Connected with AniDB"]++ + } else { + anidb["Not connected with AniDB"]++ + } + + rating[arn.ToString(int(anime.Rating.Overall+0.5))]++ + + found := false + for _, episode := range anime.Episodes().Items { + if episode.Links != nil && episode.Links["twist.moe"] != "" { + found = true + break + } + } + + if found { + twist["Connected with AnimeTwist"]++ + } else { + twist["Not connected with AnimeTwist"]++ + } + + status[anime.Status]++ + types[anime.Type]++ + } + + return []*arn.PieChart{ + arn.NewPieChart("Type", types), + arn.NewPieChart("Status", status), + arn.NewPieChart("Rating", rating), + arn.NewPieChart("MyAnimeList", mal), + arn.NewPieChart("AniList", anilist), + arn.NewPieChart("AniDB", anidb), + arn.NewPieChart("Shoboi", shoboi), + arn.NewPieChart("AnimeTwist", twist), + // arn.NewPieChart("MyAnimeList Editors", malEdits), + arn.NewPieChart("AniList Editors", anilistEdits), + // arn.NewPieChart("AniDB Editors", anidbEdits), + arn.NewPieChart("Shoboi Editors", shoboiEdits), + } +} diff --git a/pages/statistics/statistics.go b/pages/statistics/statistics.go new file mode 100644 index 00000000..649deb63 --- /dev/null +++ b/pages/statistics/statistics.go @@ -0,0 +1,108 @@ +package statistics + +import ( + "fmt" + "strings" + + "github.com/aerogo/aero" + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" +) + +type stats map[string]float64 + +// Get ... +func Get(ctx *aero.Context) string { + pieCharts := getUserStats() + return ctx.HTML(components.Statistics(pieCharts)) +} + +func getUserStats() []*arn.PieChart { + screenSize := stats{} + pixelRatio := stats{} + browser := stats{} + country := stats{} + gender := stats{} + os := stats{} + notifications := stats{} + avatar := stats{} + ip := stats{} + pro := stats{} + + for info := range arn.StreamAnalytics() { + user, err := arn.GetUser(info.UserID) + arn.PanicOnError(err) + + if !user.IsActive() { + continue + } + + pixelRatio[fmt.Sprintf("%.0f", info.Screen.PixelRatio)]++ + + size := arn.ToString(info.Screen.Width) + " x " + arn.ToString(info.Screen.Height) + screenSize[size]++ + } + + for user := range arn.StreamUsers() { + if !user.IsActive() { + continue + } + + if user.Gender != "" && user.Gender != "other" { + gender[user.Gender]++ + } + + if user.Browser.Name != "" { + browser[user.Browser.Name]++ + } + + if user.Location.CountryName != "" { + country[user.Location.CountryName]++ + } + + if user.OS.Name != "" { + if strings.HasPrefix(user.OS.Name, "CrOS") { + user.OS.Name = "Chrome OS" + } + + os[user.OS.Name]++ + } + + if len(user.PushSubscriptions().Items) > 0 { + notifications["Enabled"]++ + } else { + notifications["Disabled"]++ + } + + if user.Avatar.Source == "" { + avatar["none"]++ + } else { + avatar[user.Avatar.Source]++ + } + + if arn.IsIPv6(user.IP) { + ip["IPv6"]++ + } else { + ip["IPv4"]++ + } + + if user.IsPro() { + pro["PRO accounts"]++ + } else { + pro["Free accounts"]++ + } + } + + return []*arn.PieChart{ + arn.NewPieChart("OS", os), + arn.NewPieChart("Screen size", screenSize), + arn.NewPieChart("Browser", browser), + arn.NewPieChart("Country", country), + arn.NewPieChart("Avatar", avatar), + arn.NewPieChart("Notifications", notifications), + arn.NewPieChart("Gender", gender), + arn.NewPieChart("Pixel ratio", pixelRatio), + arn.NewPieChart("IP version", ip), + arn.NewPieChart("PRO accounts", pro), + } +} diff --git a/pages/statistics/statistics.pixy b/pages/statistics/statistics.pixy new file mode 100644 index 00000000..e4697f06 --- /dev/null +++ b/pages/statistics/statistics.pixy @@ -0,0 +1,30 @@ +component Statistics(pieCharts []*arn.PieChart) + h1.page-title Statistics + + StatisticsHeader + + .statistics + each pie in pieCharts + .widget + h3.widget-title= pie.Title + PieChart(pie.Slices) + + .footer + p Data is collected for statistical purposes only. We respect user privacy and we will never display or sell critical data to 3rd party services. + +component StatisticsHeader + .tabs + a.tab.action(href="/statistics", data-action="diff", data-trigger="click") + Icon("user") + span.tab-text Users + + a.tab.action(href="/statistics/anime", data-action="diff", data-trigger="click") + Icon("tv") + span.tab-text Anime + +component PieChart(slices []*arn.PieChartSlice) + svg.pie-chart(viewBox="-1.1 -1.1 2.2 2.2") + each slice in slices + g + title= slice.Title + path.slice(d=utils.SVGSlicePath(slice.From, slice.To), fill=slice.Color) \ No newline at end of file diff --git a/pages/statistics/statistics.scarlet b/pages/statistics/statistics.scarlet new file mode 100644 index 00000000..c14c60f6 --- /dev/null +++ b/pages/statistics/statistics.scarlet @@ -0,0 +1,17 @@ +.statistics + horizontal-wrap + justify-content space-around + text-align center + + .widget + max-width 300px + +.pie-chart + transform rotate(-90deg) + +.slice + color black + default-transition + transform scale(1) + :hover + transform scale(1.05) \ No newline at end of file diff --git a/pages/threads/threads.go b/pages/threads/threads.go index 92d96248..435e1056 100644 --- a/pages/threads/threads.go +++ b/pages/threads/threads.go @@ -1,7 +1,7 @@ package threads import ( - "strings" + "net/http" "github.com/aerogo/aero" "github.com/animenotifier/arn" @@ -12,28 +12,25 @@ import ( // Get thread. func Get(ctx *aero.Context) string { id := ctx.Get("id") - thread, err := arn.GetThread(id) user := utils.GetUser(ctx) + // Fetch thread + thread, err := arn.GetThread(id) + if err != nil { - return ctx.Error(404, "Thread not found", err) + return ctx.Error(http.StatusNotFound, "Thread not found", err) } - replies, filterErr := arn.FilterPosts(func(post *arn.Post) bool { - post.Text = strings.Replace(post.Text, "http://", "https://", -1) - return post.ThreadID == thread.ID - }) + // Fetch posts + postObjects := arn.DB.GetMany("Post", thread.Posts) + posts := make([]*arn.Post, len(postObjects), len(postObjects)) - arn.SortPostsLatestLast(replies) - - // Benchmark - // for i := 0; i < 7; i++ { - // replies = append(replies, replies...) - // } - - if filterErr != nil { - return ctx.Error(500, "Error fetching thread replies", err) + for i, obj := range postObjects { + posts[i] = obj.(*arn.Post) } - return ctx.HTML(components.Thread(thread, replies, user)) + // Sort posts + arn.SortPostsLatestLast(posts) + + return ctx.HTML(components.Thread(thread, posts, user)) } diff --git a/pages/threads/threads.pixy b/pages/threads/threads.pixy index 6459af81..09120799 100644 --- a/pages/threads/threads.pixy +++ b/pages/threads/threads.pixy @@ -1,12 +1,12 @@ component Thread(thread *arn.Thread, posts []*arn.Post, user *arn.User) - h2.thread-title= thread.Title + h1.thread-title= thread.Title - .thread + #thread.thread(data-id=thread.ID) .posts - Postable(thread.ToPostable(), thread.Author().ID) + Postable(thread.ToPostable(), user, thread.Author().ID) each post in posts - Postable(post.ToPostable(), thread.Author().ID) + Postable(post.ToPostable(), user, thread.Author().ID) // Reply if user != nil @@ -15,4 +15,9 @@ component Thread(thread *arn.Thread, posts []*arn.Post, user *arn.User) Avatar(user) .post-content - textarea(id="new-reply", placeholder="Reply...") \ No newline at end of file + textarea#new-reply(placeholder="Reply...", aria-label="Reply") + + .buttons + button.action(data-action="forumReply", data-trigger="click") + Icon("mail-reply") + span Reply \ No newline at end of file diff --git a/pages/threads/threads.scarlet b/pages/threads/threads.scarlet index f6bf9857..14c349d2 100644 --- a/pages/threads/threads.scarlet +++ b/pages/threads/threads.scarlet @@ -13,6 +13,14 @@ .post-author margin-bottom 0.25rem + + [data-highlight="true"] + .post-content + border 2px solid post-highlight-color + + [data-pro="true"] + .post-content + border 2px solid pro-color > 600px .post diff --git a/pages/users/osu.pixy b/pages/users/osu.pixy new file mode 100644 index 00000000..8e6805ba --- /dev/null +++ b/pages/users/osu.pixy @@ -0,0 +1,24 @@ +component OsuRankingList(users []*arn.User) + h1.page-title osu! ranking list + + UsersTabs + + table.osu-ranking-list + thead + tr.mountable + th # + th Player + th Name + th.osu-ranking-pp Performance + th.osu-ranking-accuracy Accuracy + tbody + for index, user := range users + tr.osu-ranking.mountable + td= toString(index + 1) + "." + td + Avatar(user) + td + a.ajax(href=user.Link())= user.Nick + td.osu-ranking-pp= toString(int(user.Accounts.Osu.PP + 0.5)) + " pp" + td.osu-ranking-accuracy= fmt.Sprintf("%.1f", user.Accounts.Osu.Accuracy) + "%" + \ No newline at end of file diff --git a/pages/users/osu.scarlet b/pages/users/osu.scarlet new file mode 100644 index 00000000..fbb47b8c --- /dev/null +++ b/pages/users/osu.scarlet @@ -0,0 +1,12 @@ +.osu-ranking-list + max-width 400px + +.osu-ranking + width 100% + + td + vertical-align middle + +.osu-ranking-pp, +.osu-ranking-accuracy + text-align right \ No newline at end of file diff --git a/pages/users/users.go b/pages/users/users.go index a81e9f39..cdc3d706 100644 --- a/pages/users/users.go +++ b/pages/users/users.go @@ -1,20 +1,63 @@ package users import ( - "net/http" + "sort" "github.com/aerogo/aero" "github.com/animenotifier/arn" "github.com/animenotifier/notify.moe/components" ) -// Get ... -func Get(ctx *aero.Context) string { - users, err := arn.GetActiveUsersCached() +// Active ... +func Active(ctx *aero.Context) string { + users := arn.FilterUsers(func(user *arn.User) bool { + return user.IsActive() && user.HasAvatar() + }) - if err != nil { - return ctx.Error(http.StatusInternalServerError, "Could not fetch user data", err) - } + sort.Slice(users, func(i, j int) bool { + return len(users[i].AnimeList().Watching().Items) > len(users[j].AnimeList().Watching().Items) + }) + + // arn.SortUsersLastSeen(users) + + return ctx.HTML(components.Users(users)) +} + +// Osu ... +func Osu(ctx *aero.Context) string { + users := arn.FilterUsers(func(user *arn.User) bool { + return user.IsActive() && user.HasAvatar() && user.Accounts.Osu.PP > 0 + }) + + // Sort by pp + sort.Slice(users, func(i, j int) bool { + return users[i].Accounts.Osu.PP > users[j].Accounts.Osu.PP + }) + + if len(users) > 50 { + users = users[:50] + } + + return ctx.HTML(components.OsuRankingList(users)) +} + +// Staff ... +func Staff(ctx *aero.Context) string { + users := arn.FilterUsers(func(user *arn.User) bool { + return user.IsActive() && user.HasAvatar() && user.Role != "" + }) + + sort.Slice(users, func(i, j int) bool { + if users[i].Role == "" { + return false + } + + if users[j].Role == "" { + return true + } + + return users[i].Role == "admin" + }) return ctx.HTML(components.Users(users)) } diff --git a/pages/users/users.pixy b/pages/users/users.pixy index 4284669e..4a12d8f5 100644 --- a/pages/users/users.pixy +++ b/pages/users/users.pixy @@ -1,6 +1,15 @@ component Users(users []*arn.User) - h2.page-title Users + h1.page-title Users + + UsersTabs .user-avatars each user in users - Avatar(user) \ No newline at end of file + .mountable + Avatar(user) + +component UsersTabs + .tabs + Tab("Active", "users", "/users") + Tab("Osu", "gamepad", "/users/osu") + Tab("Staff", "user-secret", "/users/staff") \ No newline at end of file diff --git a/pages/users/users.scarlet b/pages/users/users.scarlet index cb7c18ef..141ac59d 100644 --- a/pages/users/users.scarlet +++ b/pages/users/users.scarlet @@ -4,4 +4,7 @@ border-radius 3px .user-image - margin 0.4rem \ No newline at end of file + margin 0.4rem + +.user + display flex \ No newline at end of file diff --git a/pages/webdev/webdev.pixy b/pages/webdev/webdev.pixy deleted file mode 100644 index 04d9f32d..00000000 --- a/pages/webdev/webdev.pixy +++ /dev/null @@ -1,13 +0,0 @@ -component WebDev - h2.page-title WebDev - - .light-button-group - a.light-button(href="https://developers.google.com/speed/pagespeed/insights/?url=https://notify.moe/&tab=desktop", target="_blank", rel="noopener") - Icon("external-link") - span Google PageSpeed - a.light-button(href="https://observatory.mozilla.org/analyze.html?host=notify.moe", target="_blank", rel="noopener") - Icon("external-link") - span Mozilla Observatory - a.light-button(href="https://html5.validator.nu/?doc=https://notify.moe", target="_blank", rel="noopener") - Icon("external-link") - span HTML5 Validator \ No newline at end of file diff --git a/patches/add-anime-lists/main.go b/patches/add-anime-lists/main.go deleted file mode 100644 index 51eb9275..00000000 --- a/patches/add-anime-lists/main.go +++ /dev/null @@ -1,39 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/animenotifier/arn" - "github.com/fatih/color" -) - -func main() { - color.Yellow("Adding empty anime lists to users who don't have one") - - // Get a stream of all users - allUsers, err := arn.AllUsers() - - if err != nil { - panic(err) - } - - // Iterate over the stream - for user := range allUsers { - exists, err := arn.DB.Exists("AnimeList", user.ID) - - if err == nil && !exists { - fmt.Println(user.Nick) - - err := arn.DB.Set("AnimeList", user.ID, &arn.AnimeList{ - UserID: user.ID, - Items: make([]*arn.AnimeListItem, 0), - }) - - if err != nil { - color.Red(err.Error()) - } - } - } - - color.Green("Finished.") -} diff --git a/patches/add-item/add-item.go b/patches/add-item/add-item.go new file mode 100644 index 00000000..85ad81d5 --- /dev/null +++ b/patches/add-item/add-item.go @@ -0,0 +1,44 @@ +package main + +import ( + "flag" + + "github.com/animenotifier/arn" + "github.com/fatih/color" +) + +var nick string +var itemID string +var quantity int + +func init() { + flag.StringVar(&nick, "nick", "", "Name of the user.") + flag.StringVar(&itemID, "item", "", "ID of the item.") + flag.IntVar(&quantity, "q", 1, "Item quantity.") + flag.Parse() +} + +func main() { + defer arn.Node.Close() + + if nick == "" || itemID == "" { + color.Red("Missing parameters") + return + } + + user, err := arn.GetUserByNick(nick) + arn.PanicOnError(err) + + item, err := arn.GetItem(itemID) + arn.PanicOnError(err) + + if item == nil { + color.Red("Unknown item") + return + } + + // Add to user inventory + inventory := user.Inventory() + inventory.AddItem(itemID, uint(quantity)) + inventory.Save() +} diff --git a/patches/add-last-seen/main.go b/patches/add-last-seen/main.go deleted file mode 100644 index ca8fcc3c..00000000 --- a/patches/add-last-seen/main.go +++ /dev/null @@ -1,29 +0,0 @@ -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/add-mal-connections/add-mal-connections.go b/patches/add-mal-connections/add-mal-connections.go new file mode 100644 index 00000000..2f3a8057 --- /dev/null +++ b/patches/add-mal-connections/add-mal-connections.go @@ -0,0 +1,37 @@ +package main + +import ( + "strconv" + + "github.com/animenotifier/arn" + "github.com/fatih/color" +) + +func main() { + defer arn.Node.Close() + + for anime := range arn.StreamAnime() { + malID := anime.GetMapping("myanimelist/anime") + + if malID == "" { + continue + } + + // Assure the string represents a number + malNum, _ := strconv.Atoi(malID) + normalizedID := strconv.Itoa(malNum) + + if malID != normalizedID { + color.Red("%s does not match %d", malID, normalizedID) + continue + } + + // Save + arn.DB.Set("MyAnimeListToAnime", malID, &arn.MyAnimeListToAnime{ + AnimeID: anime.ID, + ServiceID: malID, + Edited: arn.DateTimeUTC(), + EditedBy: "", + }) + } +} diff --git a/patches/add-shop-items/add-shop-items.go b/patches/add-shop-items/add-shop-items.go new file mode 100644 index 00000000..89dd101e --- /dev/null +++ b/patches/add-shop-items/add-shop-items.go @@ -0,0 +1,118 @@ +package main + +import "github.com/animenotifier/arn" + +var items = []*arn.Item{ + &arn.Item{ + ID: "pro-account-3", + Name: "PRO Account (1 season)", + Price: 900, + Description: `PRO status for 1 anime season (3 months). + +1 month equals 300 gems. + +Includes: + +* Chrome extension for quick list access +* Special highlight on the forums +* Access to the VIP channel on Discord +* PRO star on your profile +* High priority for your personal suggestions +* Early access to new features`, + Icon: "star", + Rarity: arn.ItemRaritySuperior, + Order: 1, + Consumable: true, + }, + &arn.Item{ + ID: "pro-account-6", + Name: "PRO Account (2 seasons)", + Price: 1600, + Description: `PRO status for 2 anime seasons (6 months). + +11% less monthly costs compared to 1 season. + +Includes: + +* Chrome extension for quick list access +* Special highlight on the forums +* Access to the VIP channel on Discord +* PRO star on your profile +* High priority for your personal suggestions +* Early access to new features`, + Icon: "star", + Rarity: arn.ItemRarityRare, + Order: 2, + Consumable: true, + }, + &arn.Item{ + ID: "pro-account-12", + Name: "PRO Account (4 seasons)", + Price: 3000, + Description: `PRO status for 4 anime seasons (12 months). + +16% less monthly costs compared to 1 season. + +Includes: + +* Chrome extension for quick list access +* Special highlight on the forums +* Access to the VIP channel on Discord +* PRO star on your profile +* High priority for your personal suggestions +* Early access to new features`, + Icon: "star", + Rarity: arn.ItemRarityUnique, + Order: 3, + Consumable: true, + }, + &arn.Item{ + ID: "pro-account-24", + Name: "PRO Account (8 seasons)", + Price: 5900, + Description: `PRO status for 8 anime seasons (24 months). + +18% less monthly costs compared to 1 season. + +Includes: + +* Chrome extension for quick list access +* Special highlight on the forums +* Access to the VIP channel on Discord +* PRO star on your profile +* High priority for your personal suggestions +* Early access to new features`, + Icon: "star", + Rarity: arn.ItemRarityLegendary, + Order: 4, + Consumable: true, + }, + &arn.Item{ + ID: "anime-support-ticket", + Name: "Anime Support Ticket", + Price: 100, + Description: `Support the makers of your favourite anime by using an anime support ticket. +Anime Notifier uses 15% of the money to handle the transaction fees while the remaining 85% go directly +to the studios involved in the creation of your favourite anime. + +*This feature is work in progress.*`, + Icon: "ticket", + Rarity: arn.ItemRarityRare, + Order: 5, + Consumable: false, + }, +} + +func main() { + for _, item := range items { + item.Save() + } +} + +//- ShopItem("PRO Account", "6 months", "1600", "star", strings.Replace(strings.Replace(proAccountMarkdown, "3 months", "6 months", 1), "1 anime season", "2 anime seasons", 1)) +//- ShopItem("PRO Account", "1 year", "3000", "star", strings.Replace(strings.Replace(proAccountMarkdown, "3 months", "12 months", 1), "1 anime season", "4 anime seasons", 1)) +//- ShopItem("PRO Account", "2 years", "5900", "star", strings.Replace(strings.Replace(proAccountMarkdown, "3 months", "24 months", 1), "1 anime season", "8 anime seasons", 1)) +//- ShopItem("Anime Support Ticket", "", "100", "ticket", "Support the makers of your favourite anime by using an anime support ticket. Anime Notifier uses 8% of the money to handle the transaction fees while the remaining 92% go directly to the studios involved in the creation of your favourite anime.") +//- ShopItem("Artwork Support Ticket", "", "100", "ticket", "Support the makers of your favourite artwork by using an artwork support ticket. Anime Notifier uses 8% of the money to handle the transaction fees while the remaining 92% go directly to the creator.") +//- ShopItem("Soundtrack Support Ticket", "", "100", "ticket", "Support the makers of your favourite soundtrack by using a soundtrack support ticket. Anime Notifier uses 8% of the money to handle the transaction fees while the remaining 92% go directly to the creator.") +//- ShopItem("AMV Support Ticket", "", "100", "ticket", "Support the makers of your favourite AMV by using an AMV support ticket. Anime Notifier uses 8% of the money to handle the transaction fees while the remaining 92% go directly to the creator.") diff --git a/patches/clear-anime-ratings/clear-anime-ratings.go b/patches/clear-anime-ratings/clear-anime-ratings.go new file mode 100644 index 00000000..c847c56f --- /dev/null +++ b/patches/clear-anime-ratings/clear-anime-ratings.go @@ -0,0 +1,12 @@ +package main + +import "github.com/animenotifier/arn" + +func main() { + defer arn.Node.Close() + + for anime := range arn.StreamAnime() { + anime.Rating.Reset() + anime.Save() + } +} diff --git a/patches/clear-sessions/main.go b/patches/clear-sessions/clear-sessions.go similarity index 77% rename from patches/clear-sessions/main.go rename to patches/clear-sessions/clear-sessions.go index 2b68107d..e811932f 100644 --- a/patches/clear-sessions/main.go +++ b/patches/clear-sessions/clear-sessions.go @@ -6,7 +6,9 @@ import ( ) func main() { + defer arn.Node.Close() + color.Yellow("Deleting all sessions...") - arn.DB.DeleteTable("Session") + arn.DB.Clear("Session") color.Green("Finished.") } diff --git a/patches/delete-anilist-mappings/delete-custom-anime.go b/patches/delete-anilist-mappings/delete-custom-anime.go new file mode 100644 index 00000000..b9fa6232 --- /dev/null +++ b/patches/delete-anilist-mappings/delete-custom-anime.go @@ -0,0 +1,16 @@ +package main + +import ( + "github.com/animenotifier/arn" +) + +func main() { + defer arn.Node.Close() + + for anime := range arn.StreamAnime() { + providerID := anime.GetMapping("anilist/anime") + arn.DB.Delete("AniListToAnime", providerID) + anime.RemoveMapping("anilist/anime", providerID) + anime.Save() + } +} diff --git a/patches/delete-balance/delete-balance.go b/patches/delete-balance/delete-balance.go new file mode 100644 index 00000000..0dfa6e0b --- /dev/null +++ b/patches/delete-balance/delete-balance.go @@ -0,0 +1,35 @@ +package main + +import ( + "flag" + + "github.com/animenotifier/arn" + "github.com/fatih/color" +) + +// Shell parameters +var confirmed bool + +// Shell flags +func init() { + flag.BoolVar(&confirmed, "confirm", false, "Confirm that you really want to execute this.") + flag.Parse() +} + +func main() { + if !confirmed { + color.Green("Please run this command with -confirm option if you really want to reset the balance of all users.") + return + } + + color.Yellow("Resetting balance of all users to 0") + defer arn.Node.Close() + + // Iterate over the stream + for user := range arn.StreamUsers() { + user.Balance = 0 + user.Save() + } + + color.Green("Finished.") +} diff --git a/patches/delete-private-data/delete-private-data.go b/patches/delete-private-data/delete-private-data.go new file mode 100644 index 00000000..7520cdf1 --- /dev/null +++ b/patches/delete-private-data/delete-private-data.go @@ -0,0 +1,38 @@ +package main + +import ( + "github.com/animenotifier/arn" + "github.com/fatih/color" +) + +func main() { + color.Yellow("Deleting private user data") + defer arn.Node.Close() + + arn.DB.Clear("EmailToUser") + arn.DB.Clear("GoogleToUser") + + // Iterate over the stream + count := 0 + + for user := range arn.StreamUsers() { + count++ + println(count, user.Nick) + + // Delete private data + user.Email = "" + user.Gender = "" + user.FirstName = "" + user.LastName = "" + user.IP = "" + user.Accounts.Facebook.ID = "" + user.Accounts.Google.ID = "" + user.AgeRange = arn.UserAgeRange{} + user.Location = arn.UserLocation{} + + // Save in DB + user.Save() + } + + color.Green("Finished.") +} diff --git a/patches/delete-private-data/main.go b/patches/delete-private-data/main.go deleted file mode 100644 index 51a134a0..00000000 --- a/patches/delete-private-data/main.go +++ /dev/null @@ -1,40 +0,0 @@ -package main - -// This patch is disabled because it would be dangerous to run it accidentally. - -func main() { - // color.Yellow("Deleting private user data") - - // // Get a stream of all users - // allUsers, err := arn.AllUsers() - - // if err != nil { - // panic(err) - // } - - // arn.DB.DeleteTable("EmailToUser") - // arn.DB.DeleteTable("GoogleToUser") - - // // Iterate over the stream - // count := 0 - // for user := range allUsers { - // count++ - // println(count, user.Nick) - - // // Delete private data - // user.Email = "" - // user.Gender = "" - // user.FirstName = "" - // user.LastName = "" - // user.IP = "" - // user.Accounts.Facebook.ID = "" - // user.Accounts.Google.ID = "" - // user.AgeRange = arn.UserAgeRange{} - // user.Location = arn.UserLocation{} - - // // Save in DB - // user.Save() - // } - - // color.Green("Finished.") -} diff --git a/patches/delete-pro/delete-pro.go b/patches/delete-pro/delete-pro.go new file mode 100644 index 00000000..1072d682 --- /dev/null +++ b/patches/delete-pro/delete-pro.go @@ -0,0 +1,34 @@ +package main + +import ( + "flag" + + "github.com/animenotifier/arn" + "github.com/fatih/color" +) + +// Shell parameters +var confirmed bool + +// Shell flags +func init() { + flag.BoolVar(&confirmed, "confirm", false, "Confirm that you really want to execute this.") + flag.Parse() +} + +func main() { + if !confirmed { + color.Green("Please run this command with -confirm option if you really want to delete all pro subscriptions.") + return + } + + color.Yellow("Deleting all pro subscriptions") + defer arn.Node.Close() + + for user := range arn.StreamUsers() { + user.ProExpires = "" + user.Save() + } + + color.Green("Finished.") +} diff --git a/patches/export-aero-db/export-aero-db.go b/patches/export-aero-db/export-aero-db.go new file mode 100644 index 00000000..cb464a0d --- /dev/null +++ b/patches/export-aero-db/export-aero-db.go @@ -0,0 +1,275 @@ +package main + +func main() {} + +// import ( +// "time" + +// "github.com/aerogo/nano" +// "github.com/animenotifier/arn" +// "github.com/fatih/color" +// ) + +// func main() { +// arn.DB.SetScanPriority("high") + +// aeroDB := nano.New(5000).Namespace("arn", arn.DBTypes...) +// defer aeroDB.Close() + +// for typeName := range arn.DB.Types() { +// count := 0 + +// switch typeName { +// case "Anime": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.Anime) { +// aeroDB.Set(typeName, obj.ID, obj) +// count++ +// } + +// case "AnimeEpisodes": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.AnimeEpisodes) { +// aeroDB.Set(typeName, obj.AnimeID, obj) +// count++ +// } + +// case "AnimeList": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.AnimeList) { +// aeroDB.Set(typeName, obj.UserID, obj) +// count++ +// } + +// case "AnimeCharacters": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.AnimeCharacters) { +// aeroDB.Set(typeName, obj.AnimeID, obj) +// count++ +// } + +// case "AnimeRelations": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.AnimeRelations) { +// aeroDB.Set(typeName, obj.AnimeID, obj) +// count++ +// } + +// case "Character": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.Character) { +// aeroDB.Set(typeName, obj.ID, obj) +// count++ +// } + +// case "Purchase": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.Purchase) { +// aeroDB.Set(typeName, obj.ID, obj) +// count++ +// } + +// case "PushSubscriptions": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.PushSubscriptions) { +// aeroDB.Set(typeName, obj.UserID, obj) +// count++ +// } + +// case "User": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.User) { +// aeroDB.Set(typeName, obj.ID, obj) +// count++ +// } + +// case "Post": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.Post) { +// aeroDB.Set(typeName, obj.ID, obj) +// count++ +// } + +// case "Thread": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.Thread) { +// aeroDB.Set(typeName, obj.ID, obj) +// count++ +// } + +// case "Analytics": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.Analytics) { +// aeroDB.Set(typeName, obj.UserID, obj) +// count++ +// } + +// case "SoundTrack": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.SoundTrack) { +// aeroDB.Set(typeName, obj.ID, obj) +// count++ +// } + +// case "Item": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.Item) { +// aeroDB.Set(typeName, obj.ID, obj) +// count++ +// } + +// case "Inventory": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.Inventory) { +// aeroDB.Set(typeName, obj.UserID, obj) +// count++ +// } + +// case "Settings": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.Settings) { +// aeroDB.Set(typeName, obj.UserID, obj) +// count++ +// } + +// case "UserFollows": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.UserFollows) { +// aeroDB.Set(typeName, obj.UserID, obj) +// count++ +// } + +// case "PayPalPayment": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.PayPalPayment) { +// aeroDB.Set(typeName, obj.ID, obj) +// count++ +// } + +// case "AniListToAnime": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.AniListToAnime) { +// aeroDB.Set(typeName, obj.ServiceID, obj) +// count++ +// } + +// case "MyAnimeListToAnime": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.MyAnimeListToAnime) { +// aeroDB.Set(typeName, obj.ServiceID, obj) +// count++ +// } + +// case "SearchIndex": +// anime, _ := arn.DB.Get(typeName, "Anime") +// aeroDB.Set(typeName, "Anime", anime) + +// users, _ := arn.DB.Get(typeName, "User") +// aeroDB.Set(typeName, "User", users) + +// posts, _ := arn.DB.Get(typeName, "Post") +// aeroDB.Set(typeName, "Post", posts) + +// threads, _ := arn.DB.Get(typeName, "Thread") +// aeroDB.Set(typeName, "Thread", threads) + +// count += 4 + +// case "DraftIndex": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.DraftIndex) { +// aeroDB.Set(typeName, obj.UserID, obj) +// count++ +// } + +// case "EmailToUser": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.EmailToUser) { +// if obj.Email == "" { +// continue +// } + +// aeroDB.Set(typeName, obj.Email, obj) +// count++ +// } + +// case "FacebookToUser": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.FacebookToUser) { +// if obj.ID == "" { +// continue +// } + +// aeroDB.Set(typeName, obj.ID, obj) +// count++ +// } + +// case "GoogleToUser": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.GoogleToUser) { +// if obj.ID == "" { +// continue +// } + +// aeroDB.Set(typeName, obj.ID, obj) +// count++ +// } + +// case "TwitterToUser": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.TwitterToUser) { +// if obj.ID == "" { +// continue +// } + +// aeroDB.Set(typeName, obj.ID, obj) +// count++ +// } + +// case "NickToUser": +// channel, _ := arn.DB.All(typeName) + +// for obj := range channel.(chan *arn.NickToUser) { +// if obj.Nick == "" { +// continue +// } + +// aeroDB.Set(typeName, obj.Nick, obj) +// count++ +// } + +// default: +// color.Yellow("Skipping %s", typeName) +// continue +// } + +// color.Green("Export %d %s", count, typeName) +// } + +// time.Sleep(1 * time.Second) +// } diff --git a/patches/fix-airing-dates/fix-airing-dates.go b/patches/fix-airing-dates/fix-airing-dates.go new file mode 100644 index 00000000..87b12547 --- /dev/null +++ b/patches/fix-airing-dates/fix-airing-dates.go @@ -0,0 +1,46 @@ +package main + +import ( + "fmt" + "time" + + "github.com/animenotifier/arn" + "github.com/fatih/color" +) + +func main() { + defer arn.Node.Close() + + now := time.Now() + futureThreshold := 8 * 7 * 24 * time.Hour + + for anime := range arn.StreamAnime() { + modified := false + + // Try to find incorrect airing dates + for _, episode := range anime.Episodes().Items { + if episode.AiringDate.Start == "" { + continue + } + + startTime, err := time.Parse(time.RFC3339, episode.AiringDate.Start) + + if err == nil && startTime.Sub(now) < futureThreshold { + continue + } + + // Definitely wrong airing date on this episode + fmt.Printf("%s | %s | Ep %d | %s\n", anime.ID, color.YellowString(anime.Title.Canonical), episode.Number, episode.AiringDate.Start) + + // Delete the wrong airing date + episode.AiringDate.Start = "" + episode.AiringDate.End = "" + + modified = true + } + + if modified == true { + anime.Episodes().Save() + } + } +} diff --git a/patches/fix-anime-list-item-status/fix-anime-list-item-status.go b/patches/fix-anime-list-item-status/fix-anime-list-item-status.go new file mode 100644 index 00000000..7f9323d8 --- /dev/null +++ b/patches/fix-anime-list-item-status/fix-anime-list-item-status.go @@ -0,0 +1,33 @@ +package main + +import ( + "fmt" + + "github.com/animenotifier/arn" + "github.com/fatih/color" +) + +func main() { + color.Yellow("Setting list item status to correct value") + defer arn.Node.Close() + + // Iterate over the stream + for animeList := range arn.StreamAnimeLists() { + fmt.Println(animeList.User().Nick) + + for _, item := range animeList.Items { + if item.Status == arn.AnimeListStatusPlanned && item.Episodes > 0 { + item.Status = arn.AnimeListStatusWatching + } + + if item.Anime().Status == "finished" && item.Anime().EpisodeCount != 0 && item.Episodes >= item.Anime().EpisodeCount { + item.Status = arn.AnimeListStatusCompleted + item.Episodes = item.Anime().EpisodeCount + } + } + + animeList.Save() + } + + color.Green("Finished.") +} diff --git a/patches/import-anilist-user/import-anilist-user.go b/patches/import-anilist-user/import-anilist-user.go new file mode 100644 index 00000000..0c63685c --- /dev/null +++ b/patches/import-anilist-user/import-anilist-user.go @@ -0,0 +1,43 @@ +package main + +import ( + "fmt" + + "github.com/animenotifier/anilist" + "github.com/animenotifier/arn" + "github.com/fatih/color" +) + +var userName = "Akyoto" +var allAnime []*arn.Anime + +func init() { + allAnime, _ = arn.AllAnime() +} + +func main() { + arn.PanicOnError(anilist.Authorize()) + println(anilist.AccessToken) + + user, _ := arn.GetUserByNick(userName) + animeList, err := anilist.GetAnimeList(user.Accounts.AniList.Nick) + arn.PanicOnError(err) + + importList(animeList.Lists.Watching) + importList(animeList.Lists.Completed) +} + +func importList(animeListItems []*anilist.AnimeListItem) { + imported := []*arn.Anime{} + + for _, item := range animeListItems { + anime := arn.FindAniListAnime(item.Anime, allAnime) + + if anime != nil { + fmt.Println(item.Anime.TitleRomaji, "=>", anime.Title.Romaji) + imported = append(imported, anime) + } + } + + color.Green("%d / %d", len(imported), len(animeListItems)) +} diff --git a/patches/import-anilist-user/import-old/import-old-matches.go b/patches/import-anilist-user/import-old/import-old-matches.go new file mode 100644 index 00000000..de9f5629 --- /dev/null +++ b/patches/import-anilist-user/import-old/import-old-matches.go @@ -0,0 +1,73 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "strconv" + + "github.com/animenotifier/arn" +) + +// OldMatch ... +type OldMatch struct { + ID int `json:"id"` + ServiceID int `json:"providerId"` + Title string `json:"title"` + ServiceTitle string `json:"providerTitle"` + Similarity float64 `json:"similarity"` + Edited string `json:"edited"` + EditedBy string `json:"editedBy"` +} + +func main() { + matches := []OldMatch{} + data, _ := ioutil.ReadFile("MatchKitsu.json") + json.Unmarshal(data, &matches) + + for _, match := range matches { + // Custom anime in 3.0 + if match.ID >= 1000000 { + continue + } + + // New match type + newMatch := &arn.AniListToAnime{ + AnimeID: strconv.Itoa(match.ServiceID), + ServiceID: strconv.Itoa(match.ID), + Similarity: match.Similarity, + Edited: match.Edited, + EditedBy: match.EditedBy, + } + + // Get anime + anime, err := arn.GetAnime(newMatch.AnimeID) + + if err != nil { + continue + } + + if anime.GetMapping("anilist/anime") != "" { + continue + } + + anime.Mappings = append(anime.Mappings, &arn.Mapping{ + Service: "anilist/anime", + ServiceID: newMatch.ServiceID, + Created: newMatch.Edited, + CreatedBy: newMatch.EditedBy, + }) + + // Save + fmt.Println(anime.Title.Canonical) + arn.PanicOnError(anime.Save()) + arn.PanicOnError(arn.DB.Set("AniListToAnime", newMatch.ServiceID, newMatch)) + } +} + +// AnilistToAnime +/* +AnimeID +ServiceID + +*/ diff --git a/patches/import-anilist/import-anilist.go b/patches/import-anilist/import-anilist.go new file mode 100644 index 00000000..1bd427ce --- /dev/null +++ b/patches/import-anilist/import-anilist.go @@ -0,0 +1,34 @@ +package main + +import ( + "github.com/animenotifier/anilist" + "github.com/animenotifier/arn" + "github.com/fatih/color" +) + +func main() { + defer arn.Node.Close() + + arn.PanicOnError(anilist.Authorize()) + color.Green(anilist.AccessToken) + + allAnime, err := arn.AllAnime() + arn.PanicOnError(err) + + count := 0 + + for aniListAnime := range anilist.StreamAnime() { + println(aniListAnime.TitleRomaji) + + anime := arn.FindAniListAnime(aniListAnime, allAnime) + + if anime != nil { + color.Green("%s %s", anime.ID, aniListAnime.TitleRomaji) + count++ + } else { + color.Red("Not found") + } + } + + color.Green("%d anime are connected with AniList", count) +} diff --git a/patches/post-texts/post-texts.go b/patches/post-texts/post-texts.go new file mode 100644 index 00000000..4c5fb314 --- /dev/null +++ b/patches/post-texts/post-texts.go @@ -0,0 +1,27 @@ +package main + +import ( + "github.com/animenotifier/arn" + "github.com/animenotifier/arn/autocorrect" + "github.com/fatih/color" +) + +func main() { + defer arn.Node.Close() + + // Iterate over the stream + for post := range arn.StreamPosts() { + // Fix text + color.Yellow(post.Text) + post.Text = autocorrect.FixPostText(post.Text) + color.Green(post.Text) + + // Tags + if post.Tags == nil { + post.Tags = []string{} + } + + // Save + post.Save() + } +} diff --git a/patches/reset-inventories/reset-inventories.go b/patches/reset-inventories/reset-inventories.go new file mode 100644 index 00000000..413bbb66 --- /dev/null +++ b/patches/reset-inventories/reset-inventories.go @@ -0,0 +1,38 @@ +package main + +import ( + "flag" + "fmt" + + "github.com/animenotifier/arn" + "github.com/fatih/color" +) + +// Shell parameters +var confirmed bool + +// Shell flags +func init() { + flag.BoolVar(&confirmed, "confirm", false, "Confirm that you really want to execute this.") + flag.Parse() +} + +func main() { + if !confirmed { + color.Green("Please run this command with -confirm option.") + return + } + + color.Yellow("Resetting all inventories") + defer arn.Node.Close() + + // Iterate over the stream + for user := range arn.StreamUsers() { + fmt.Println(user.Nick) + + inventory := arn.NewInventory(user.ID) + inventory.Save() + } + + color.Green("Finished.") +} diff --git a/patches/show-season/show-season.go b/patches/show-season/show-season.go new file mode 100644 index 00000000..0f079c68 --- /dev/null +++ b/patches/show-season/show-season.go @@ -0,0 +1,17 @@ +package main + +import ( + "fmt" + + "github.com/animenotifier/arn" +) + +func main() { + for anime := range arn.StreamAnime() { + if anime.NSFW == 1 || anime.Status != "current" || anime.StartDate == "" || anime.StartDate < "2017-09" || anime.StartDate > "2017-10-17" { + continue + } + + fmt.Printf("* [%s](/anime/%s)\n", anime.Title.Canonical, anime.ID) + } +} diff --git a/patches/thread-posts/thread-posts.go b/patches/thread-posts/thread-posts.go new file mode 100644 index 00000000..166d414b --- /dev/null +++ b/patches/thread-posts/thread-posts.go @@ -0,0 +1,32 @@ +package main + +import ( + "github.com/animenotifier/arn" +) + +func main() { + defer arn.Node.Close() + + // Get a stream of all posts + threadToPosts := make(map[string][]string) + + // Iterate over the stream + for post := range arn.StreamPosts() { + _, found := threadToPosts[post.ThreadID] + + if !found { + threadToPosts[post.ThreadID] = []string{post.ID} + } else { + threadToPosts[post.ThreadID] = append(threadToPosts[post.ThreadID], post.ID) + } + } + + // Save new post ID lists + for threadID, posts := range threadToPosts { + thread, err := arn.GetThread(threadID) + arn.PanicOnError(err) + + thread.Posts = posts + thread.Save() + } +} diff --git a/patches/user-references/main.go b/patches/user-references/user-references.go similarity index 51% rename from patches/user-references/main.go rename to patches/user-references/user-references.go index 46ad2777..f5aab5f7 100644 --- a/patches/user-references/main.go +++ b/patches/user-references/user-references.go @@ -7,21 +7,17 @@ import ( func main() { color.Yellow("Updating user references") + defer arn.Node.Close() - arn.DB.DeleteTable("NickToUser") - arn.DB.DeleteTable("EmailToUser") - arn.DB.DeleteTable("GoogleToUser") - - // Get a stream of all users - allUsers, err := arn.AllUsers() - - if err != nil { - panic(err) - } + arn.DB.Clear("NickToUser") + arn.DB.Clear("EmailToUser") + arn.DB.Clear("GoogleToUser") + arn.DB.Clear("FacebookToUser") // Iterate over the stream count := 0 - for user := range allUsers { + + for user := range arn.StreamUsers() { count++ println(count, user.Nick) @@ -32,10 +28,11 @@ func main() { } if user.Accounts.Google.ID != "" { - arn.DB.Set("GoogleToUser", user.Accounts.Google.ID, &arn.GoogleToUser{ - ID: user.Accounts.Google.ID, - UserID: user.ID, - }) + user.ConnectGoogle(user.Accounts.Google.ID) + } + + if user.Accounts.Facebook.ID != "" { + user.ConnectFacebook(user.Accounts.Facebook.ID) } } diff --git a/profiler.go b/profiler.go new file mode 100644 index 00000000..b77e04a3 --- /dev/null +++ b/profiler.go @@ -0,0 +1,16 @@ +package main + +func init() { + // Uncomment these if you want to enable live profiling via /debug/pprof + + // app.Router.HandlerFunc("GET", "/debug/pprof/", http.HandlerFunc(pprof.Index)) + // app.Router.HandlerFunc("GET", "/debug/pprof/cmdline", http.HandlerFunc(pprof.Cmdline)) + // app.Router.HandlerFunc("GET", "/debug/pprof/profile", http.HandlerFunc(pprof.Profile)) + // app.Router.HandlerFunc("GET", "/debug/pprof/symbol", http.HandlerFunc(pprof.Symbol)) + // app.Router.HandlerFunc("GET", "/debug/pprof/trace", http.HandlerFunc(pprof.Trace)) + + // app.Router.Handler("GET", "/debug/pprof/goroutine", pprof.Handler("goroutine")) + // app.Router.Handler("GET", "/debug/pprof/heap", pprof.Handler("heap")) + // app.Router.Handler("GET", "/debug/pprof/threadcreate", pprof.Handler("threadcreate")) + // app.Router.Handler("GET", "/debug/pprof/block", pprof.Handler("block")) +} diff --git a/rewrite.go b/rewrite.go index a171a56b..3cf40ce4 100644 --- a/rewrite.go +++ b/rewrite.go @@ -6,22 +6,43 @@ import ( "github.com/aerogo/aero" ) -func init() { - plusRoute := "/+" - plusRouteAjax := "/_/+" +// rewrite will rewrite certain routes +func rewrite(ctx *aero.RewriteContext) { + requestURI := ctx.URI() - // This will rewrite /+UserName requests to /user/UserName - app.Rewrite(func(ctx *aero.RewriteContext) { - requestURI := ctx.URI() + // User profiles + if strings.HasPrefix(requestURI, "/+") { + newURI := "/user/" + userName := requestURI[2:] + ctx.SetURI(newURI + userName) + return + } - if strings.HasPrefix(requestURI, plusRoute) { - newURI := "/user/" - userName := requestURI[2:] - ctx.SetURI(newURI + userName) - } else if strings.HasPrefix(requestURI, plusRouteAjax) { - newURI := "/_/user/" - userName := requestURI[4:] - ctx.SetURI(newURI + userName) - } - }) + if strings.HasPrefix(requestURI, "/_/+") { + newURI := "/_/user/" + userName := requestURI[4:] + ctx.SetURI(newURI + userName) + return + } + + // Search + if strings.HasPrefix(requestURI, "/search/") { + searchTerm := requestURI[len("/search/"):] + ctx.Request.URL.RawQuery = "q=" + searchTerm + ctx.SetURI("/search") + return + } + + if strings.HasPrefix(requestURI, "/_/search/") { + searchTerm := requestURI[len("/_/search/"):] + ctx.Request.URL.RawQuery = "q=" + searchTerm + ctx.SetURI("/_/search") + return + } + + // Analytics + if requestURI == "/dark-flame-master" { + ctx.SetURI("/api/new/analytics") + return + } } diff --git a/scripts/APIObject.ts b/scripts/APIObject.ts deleted file mode 100644 index 481cb4de..00000000 --- a/scripts/APIObject.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Save new data from an input field -// export function save(arn: AnimeNotifier, input: HTMLInputElement | HTMLTextAreaElement) { - // let apiObject: HTMLElement - // let parent = input as HTMLElement - - // while(parent = parent.parentElement) { - // if(parent.classList.contains("api-object")) { - // apiObject = parent - // break - // } - // } - - // if(!apiObject) { - // throw "API object not found" - // } - - // let request = apiObject["api-fetch"] - - // request.then(obj => { - // obj[input.id] = input.value - // console.log(obj) - // }) -// } - -// updateAPIObjects() { -// for(let element of findAll(".api-object")) { -// let apiObject = element - -// apiObject["api-fetch"] = fetch(element.dataset.api).then(response => response.json()) -// } -// } \ No newline at end of file diff --git a/scripts/Actions.ts b/scripts/Actions.ts new file mode 100644 index 00000000..b5e4969a --- /dev/null +++ b/scripts/Actions.ts @@ -0,0 +1,15 @@ +export * from "./Actions/AnimeList" +export * from "./Actions/Diff" +export * from "./Actions/FollowUser" +export * from "./Actions/Forum" +export * from "./Actions/InfiniteScroller" +export * from "./Actions/Install" +export * from "./Actions/Like" +export * from "./Actions/Notifications" +export * from "./Actions/Object" +export * from "./Actions/Publish" +export * from "./Actions/Search" +export * from "./Actions/Serialization" +export * from "./Actions/Shop" +export * from "./Actions/SideBar" +export * from "./Actions/StatusMessage" \ No newline at end of file diff --git a/scripts/Actions/AnimeList.ts b/scripts/Actions/AnimeList.ts new file mode 100644 index 00000000..06530483 --- /dev/null +++ b/scripts/Actions/AnimeList.ts @@ -0,0 +1,25 @@ +import { AnimeNotifier } from "../AnimeNotifier" + +// Add anime to collection +export function addAnimeToCollection(arn: AnimeNotifier, button: HTMLElement) { + button.innerText = "Adding..." + + let {animeId} = button.dataset + let apiEndpoint = arn.findAPIEndpoint(button) + + arn.post(apiEndpoint + "/add/" + animeId, "") + .then(() => arn.reloadContent()) + .catch(err => arn.statusMessage.showError(err)) +} + +// Remove anime from collection +export function removeAnimeFromCollection(arn: AnimeNotifier, button: HTMLElement) { + button.innerText = "Removing..." + + let {animeId, nick} = button.dataset + let apiEndpoint = arn.findAPIEndpoint(button) + + arn.post(apiEndpoint + "/remove/" + animeId, "") + .then(() => arn.app.load("/animelist/" + (arn.app.find("Status") as HTMLSelectElement).value)) + .catch(err => arn.statusMessage.showError(err)) +} \ No newline at end of file diff --git a/scripts/Actions/Diff.ts b/scripts/Actions/Diff.ts new file mode 100644 index 00000000..b3213edc --- /dev/null +++ b/scripts/Actions/Diff.ts @@ -0,0 +1,16 @@ +import { AnimeNotifier } from "../AnimeNotifier" + +// Load +export function load(arn: AnimeNotifier, element: HTMLElement) { + let url = element.dataset.url || (element as HTMLAnchorElement).getAttribute("href") + arn.app.load(url) +} + +// Diff +export function diff(arn: AnimeNotifier, element: HTMLElement) { + let url = element.dataset.url || (element as HTMLAnchorElement).getAttribute("href") + + arn.diff(url) + .then(() => arn.scrollTo(element)) + .catch(console.error) +} \ No newline at end of file diff --git a/scripts/Actions/FollowUser.ts b/scripts/Actions/FollowUser.ts new file mode 100644 index 00000000..096016a5 --- /dev/null +++ b/scripts/Actions/FollowUser.ts @@ -0,0 +1,17 @@ +import { AnimeNotifier } from "../AnimeNotifier" + +// Follow user +export function followUser(arn: AnimeNotifier, elem: HTMLElement) { + return arn.post(elem.dataset.api, "") + .then(() => arn.reloadContent()) + .then(() => arn.statusMessage.showInfo("You are now following " + arn.app.find("nick").innerText + ".")) + .catch(err => arn.statusMessage.showError(err)) +} + +// Unfollow user +export function unfollowUser(arn: AnimeNotifier, elem: HTMLElement) { + return arn.post(elem.dataset.api, "") + .then(() => arn.reloadContent()) + .then(() => arn.statusMessage.showInfo("You stopped following " + arn.app.find("nick").innerText + ".")) + .catch(err => arn.statusMessage.showError(err)) +} \ No newline at end of file diff --git a/scripts/Actions/Forum.ts b/scripts/Actions/Forum.ts new file mode 100644 index 00000000..7bc14bce --- /dev/null +++ b/scripts/Actions/Forum.ts @@ -0,0 +1,78 @@ +import { AnimeNotifier } from "../AnimeNotifier" + +// Edit post +export function editPost(arn: AnimeNotifier, element: HTMLElement) { + let postId = element.dataset.id + + let render = arn.app.find("render-" + postId) + let toolbar = arn.app.find("toolbar-" + postId) + let title = arn.app.find("title-" + postId) + let source = arn.app.find("source-" + postId) + let edit = arn.app.find("edit-toolbar-" + postId) + + render.classList.toggle("hidden") + toolbar.classList.toggle("hidden") + source.classList.toggle("hidden") + edit.classList.toggle("hidden") + + if(title) { + title.classList.toggle("hidden") + } +} + +// Save post +export function savePost(arn: AnimeNotifier, element: HTMLElement) { + let postId = element.dataset.id + let source = arn.app.find("source-" + postId) as HTMLTextAreaElement + let title = arn.app.find("title-" + postId) as HTMLInputElement + let text = source.value + + let updates: any = { + Text: text, + } + + // Add title for threads only + if(title) { + updates.Title = title.value + } + + let apiEndpoint = arn.findAPIEndpoint(element) + + arn.post(apiEndpoint, updates) + .then(() => arn.reloadContent()) + .catch(err => arn.statusMessage.showError(err)) +} + +// Forum reply +export function forumReply(arn: AnimeNotifier) { + let textarea = arn.app.find("new-reply") as HTMLTextAreaElement + let thread = arn.app.find("thread") + + let post = { + text: textarea.value, + threadId: thread.dataset.id, + tags: [] + } + + arn.post("/api/new/post", post) + .then(() => arn.reloadContent()) + .then(() => textarea.value = "") + .catch(err => arn.statusMessage.showError(err)) +} + +// Create thread +export function createThread(arn: AnimeNotifier) { + let title = arn.app.find("title") as HTMLInputElement + let text = arn.app.find("text") as HTMLTextAreaElement + let category = arn.app.find("tag") as HTMLInputElement + + let thread = { + title: title.value, + text: text.value, + tags: [category.value] + } + + arn.post("/api/new/thread", thread) + .then(() => arn.app.load("/forum/" + thread.tags[0])) + .catch(err => arn.statusMessage.showError(err)) +} \ No newline at end of file diff --git a/scripts/Actions/InfiniteScroller.ts b/scripts/Actions/InfiniteScroller.ts new file mode 100644 index 00000000..213ced6e --- /dev/null +++ b/scripts/Actions/InfiniteScroller.ts @@ -0,0 +1,49 @@ +import { AnimeNotifier } from "../AnimeNotifier" + +// Load more +export function loadMore(arn: AnimeNotifier, button: HTMLButtonElement) { + // Prevent firing this event multiple times + if(arn.isLoading || button.disabled) { + return + } + + arn.loading(true) + button.disabled = true + + let target = arn.app.find("load-more-target") + let index = button.dataset.index + + fetch("/_" + arn.app.currentPath + "/from/" + index) + .then(response => { + let newIndex = response.headers.get("X-LoadMore-Index") + + // End of data? + if(newIndex === "-1") { + button.classList.add("hidden") + } else { + button.dataset.index = newIndex + } + + return response + }) + .then(response => response.text()) + .then(body => { + let tmp = document.createElement(target.tagName) + tmp.innerHTML = body + + let children = [...tmp.childNodes] + + window.requestAnimationFrame(() => { + for(let child of children) { + target.appendChild(child) + } + + arn.app.emit("DOMContentLoaded") + }) + }) + .catch(err => arn.statusMessage.showError(err)) + .then(() => { + arn.loading(false) + button.disabled = false + }) +} \ No newline at end of file diff --git a/scripts/Actions/Install.ts b/scripts/Actions/Install.ts new file mode 100644 index 00000000..8903d1ef --- /dev/null +++ b/scripts/Actions/Install.ts @@ -0,0 +1,12 @@ +import { AnimeNotifier } from "../AnimeNotifier" + +// Chrome extension installation +export function installExtension(arn: AnimeNotifier, button: HTMLElement) { + let browser: any = window["chrome"] + browser.webstore.install() +} + +// Desktop app installation +export function installApp() { + alert("Open your browser menu > 'More tools' > 'Add to desktop' and enable 'Open as window'.") +} \ No newline at end of file diff --git a/scripts/Actions/Like.ts b/scripts/Actions/Like.ts new file mode 100644 index 00000000..22783139 --- /dev/null +++ b/scripts/Actions/Like.ts @@ -0,0 +1,19 @@ +import { AnimeNotifier } from "../AnimeNotifier" + +// like +export function like(arn: AnimeNotifier, element: HTMLElement) { + let apiEndpoint = arn.findAPIEndpoint(element) + + arn.post(apiEndpoint + "/like", null) + .then(() => arn.reloadContent()) + .catch(err => arn.statusMessage.showError(err)) +} + +// unlike +export function unlike(arn: AnimeNotifier, element: HTMLElement) { + let apiEndpoint = arn.findAPIEndpoint(element) + + arn.post(apiEndpoint + "/unlike", null) + .then(() => arn.reloadContent()) + .catch(err => arn.statusMessage.showError(err)) +} \ No newline at end of file diff --git a/scripts/Actions/Notifications.ts b/scripts/Actions/Notifications.ts new file mode 100644 index 00000000..10557525 --- /dev/null +++ b/scripts/Actions/Notifications.ts @@ -0,0 +1,20 @@ +import { AnimeNotifier } from "../AnimeNotifier" + +// Enable notifications +export async function enableNotifications(arn: AnimeNotifier, button: HTMLElement) { + await arn.pushManager.subscribe(arn.user.dataset.id) + arn.updatePushUI() +} + +// Disable notifications +export async function disableNotifications(arn: AnimeNotifier, button: HTMLElement) { + await arn.pushManager.unsubscribe(arn.user.dataset.id) + arn.updatePushUI() +} + +// Test notification +export function testNotification(arn: AnimeNotifier) { + fetch("/api/test/notification", { + credentials: "same-origin" + }) +} \ No newline at end of file diff --git a/scripts/Actions/Object.ts b/scripts/Actions/Object.ts new file mode 100644 index 00000000..7dac7d59 --- /dev/null +++ b/scripts/Actions/Object.ts @@ -0,0 +1,27 @@ +import { AnimeNotifier } from "../AnimeNotifier" + +// New +export function newObject(arn: AnimeNotifier, button: HTMLButtonElement) { + let dataType = button.dataset.type + + arn.post(`/api/new/${dataType}`, "") + .then(response => response.json()) + .then(obj => arn.app.load(`/${dataType}/${obj.id}/edit`)) + .catch(err => arn.statusMessage.showError(err)) +} + +// Delete +export function deleteObject(arn: AnimeNotifier, button: HTMLButtonElement) { + let confirmType = button.dataset.confirmType + let returnPath = button.dataset.returnPath + + if(!confirm(`Are you sure you want to delete this ${confirmType}?`)) { + return + } + + let endpoint = arn.findAPIEndpoint(button) + + arn.post(endpoint + "/delete", "") + .then(() => arn.app.load(returnPath)) + .catch(err => arn.statusMessage.showError(err)) +} \ No newline at end of file diff --git a/scripts/Actions/Publish.ts b/scripts/Actions/Publish.ts new file mode 100644 index 00000000..3e49721b --- /dev/null +++ b/scripts/Actions/Publish.ts @@ -0,0 +1,19 @@ +import { AnimeNotifier } from "../AnimeNotifier" + +// Publish +export function publish(arn: AnimeNotifier, button: HTMLButtonElement) { + let endpoint = arn.findAPIEndpoint(button) + + arn.post(endpoint + "/publish", "") + .then(() => arn.app.load(arn.app.currentPath.replace("/edit", ""))) + .catch(err => arn.statusMessage.showError(err)) +} + +// Unpublish +export function unpublish(arn: AnimeNotifier, button: HTMLButtonElement) { + let endpoint = arn.findAPIEndpoint(button) + + arn.post(endpoint + "/unpublish", "") + .then(() => arn.reloadContent()) + .catch(err => arn.statusMessage.showError(err)) +} \ No newline at end of file diff --git a/scripts/Actions/Search.ts b/scripts/Actions/Search.ts new file mode 100644 index 00000000..0be2792f --- /dev/null +++ b/scripts/Actions/Search.ts @@ -0,0 +1,95 @@ +import { AnimeNotifier } from "../AnimeNotifier" + +// Search +export function search(arn: AnimeNotifier, search: HTMLInputElement, e: KeyboardEvent) { + if(e.ctrlKey || e.altKey) { + return + } + + let term = search.value + + if(!term || term.length < 2) { + arn.app.content.innerHTML = "Please enter at least 2 characters to start searching." + return + } + + arn.diff("/search/" + term) +} + +// Search database +export function searchDB(arn: AnimeNotifier, input: HTMLInputElement, e: KeyboardEvent) { + if(e.ctrlKey || e.altKey) { + return + } + + let dataType = (arn.app.find("data-type") as HTMLInputElement).value || "+" + let field = (arn.app.find("field") as HTMLInputElement).value || "+" + let fieldValue = (arn.app.find("field-value") as HTMLInputElement).value || "+" + let records = arn.app.find("records") + + arn.loading(true) + + fetch(`/api/select/${dataType}/where/${field}/is/${fieldValue}`) + .then(response => { + if(response.status !== 200) { + throw response + } + + return response + }) + .then(response => response.json()) + .then(data => { + records.innerHTML = "" + let count = 0 + + if(data.results.length === 0) { + records.innerText = "No results." + return + } + + for(let record of data.results) { + count++ + + let container = document.createElement("div") + container.classList.add("record") + + let id = document.createElement("div") + id.innerText = record.id + id.classList.add("record-id") + container.appendChild(id) + + let link = document.createElement("a") + link.classList.add("record-view") + link.innerText = "Open " + dataType.toLowerCase() + + if(dataType === "User") { + link.href = "/+" + record.nick + } else { + link.href = "/" + dataType.toLowerCase() + "/" + record.id + } + + link.target = "_blank" + container.appendChild(link) + + let apiLink = document.createElement("a") + apiLink.classList.add("record-view-api") + apiLink.innerText = "JSON data" + apiLink.href = "/api/" + dataType.toLowerCase() + "/" + record.id + apiLink.target = "_blank" + container.appendChild(apiLink) + + let recordCount = document.createElement("div") + recordCount.innerText = count + "/" + data.results.length + recordCount.classList.add("record-count") + container.appendChild(recordCount) + + records.appendChild(container) + } + }) + .catch(response => { + response.text().then(text => { + arn.statusMessage.showError(text) + }) + }) + .then(() => arn.loading(false)) +} \ No newline at end of file diff --git a/scripts/Actions/Serialization.ts b/scripts/Actions/Serialization.ts new file mode 100644 index 00000000..c575cb4f --- /dev/null +++ b/scripts/Actions/Serialization.ts @@ -0,0 +1,80 @@ +import { AnimeNotifier } from "../AnimeNotifier" + +// Save new data from an input field +export function save(arn: AnimeNotifier, input: HTMLElement) { + let obj = {} + let isContentEditable = input.isContentEditable + let value = isContentEditable ? input.innerText : (input as HTMLInputElement).value + + if(value === undefined) { + return + } + + if((input as HTMLInputElement).type === "number" || input.dataset.type === "number") { + if(input.getAttribute("step") === "1" || input.dataset.step === "1") { + obj[input.dataset.field] = parseInt(value) + } else { + obj[input.dataset.field] = parseFloat(value) + } + } else { + obj[input.dataset.field] = value + } + + if(isContentEditable) { + input.contentEditable = "false" + } else { + (input as HTMLInputElement).disabled = true + } + + let apiEndpoint = arn.findAPIEndpoint(input) + + arn.post(apiEndpoint, obj) + .catch(err => arn.statusMessage.showError(err)) + .then(() => { + if(isContentEditable) { + input.contentEditable = "true" + } else { + (input as HTMLInputElement).disabled = false + } + + return arn.reloadContent() + }) +} + +// Append new element to array +export function arrayAppend(arn: AnimeNotifier, element: HTMLElement) { + let field = element.dataset.field + let object = element.dataset.object || "" + let apiEndpoint = arn.findAPIEndpoint(element) + + arn.post(apiEndpoint + "/field/" + field + "/append", object) + .then(() => arn.reloadContent()) + .catch(err => arn.statusMessage.showError(err)) +} + +// Remove element from array +export function arrayRemove(arn: AnimeNotifier, element: HTMLElement) { + if(!confirm("Are you sure you want to remove this element?")) { + return + } + + let field = element.dataset.field + let index = element.dataset.index + let apiEndpoint = arn.findAPIEndpoint(element) + + arn.post(apiEndpoint + "/field/" + field + "/remove/" + index, "") + .then(() => arn.reloadContent()) + .catch(err => arn.statusMessage.showError(err)) +} + +// Increase episode +export function increaseEpisode(arn: AnimeNotifier, element: HTMLElement) { + if(arn.isLoading) { + return + } + + let prev = element.previousSibling as HTMLElement + let episodes = parseInt(prev.innerText) + prev.innerText = String(episodes + 1) + save(arn, prev) +} \ No newline at end of file diff --git a/scripts/Actions/Shop.ts b/scripts/Actions/Shop.ts new file mode 100644 index 00000000..5f7a8cf1 --- /dev/null +++ b/scripts/Actions/Shop.ts @@ -0,0 +1,64 @@ +import { AnimeNotifier } from "../AnimeNotifier" + +// Charge up +export function chargeUp(arn: AnimeNotifier, button: HTMLElement) { + let amount = button.dataset.amount + + arn.loading(true) + arn.statusMessage.showInfo("Creating PayPal transaction... This might take a few seconds.") + + fetch("/api/paypal/payment/create", { + method: "POST", + body: amount, + credentials: "same-origin" + }) + .then(response => response.json()) + .then(payment => { + if(!payment || !payment.links) { + throw "Error creating PayPal payment" + } + + console.log(payment) + let link = payment.links.find(link => link.rel === "approval_url") + + if(!link) { + throw "Error finding PayPal payment link" + } + + arn.statusMessage.showInfo("Redirecting to PayPal...", 5000) + + let url = link.href + window.location.href = url + }) + .catch(err => arn.statusMessage.showError(err)) + .then(() => arn.loading(false)) +} + +// Buy item +export function buyItem(arn: AnimeNotifier, button: HTMLElement) { + let itemId = button.dataset.itemId + let itemName = button.dataset.itemName + let price = button.dataset.price + + if(!confirm(`Would you like to buy ${itemName} for ${price} gems?`)) { + return + } + + arn.loading(true) + + fetch(`/api/shop/buy/${itemId}/1`, { + method: "POST", + credentials: "same-origin" + }) + .then(response => response.text()) + .then(body => { + if(body !== "ok") { + throw body + } + + return arn.reloadContent() + }) + .then(() => arn.statusMessage.showInfo(`You bought ${itemName} for ${price} gems. Check out your inventory to confirm the purchase.`, 4000)) + .catch(err => arn.statusMessage.showError(err)) + .then(() => arn.loading(false)) +} \ No newline at end of file diff --git a/scripts/Actions/SideBar.ts b/scripts/Actions/SideBar.ts new file mode 100644 index 00000000..7547b701 --- /dev/null +++ b/scripts/Actions/SideBar.ts @@ -0,0 +1,6 @@ +import { AnimeNotifier } from "../AnimeNotifier" + +// Toggle sidebar +export function toggleSidebar(arn: AnimeNotifier) { + arn.app.find("sidebar").classList.toggle("sidebar-visible") +} \ No newline at end of file diff --git a/scripts/Actions/StatusMessage.ts b/scripts/Actions/StatusMessage.ts new file mode 100644 index 00000000..a5f6f014 --- /dev/null +++ b/scripts/Actions/StatusMessage.ts @@ -0,0 +1,6 @@ +import { AnimeNotifier } from "../AnimeNotifier" + +// Close status message +export function closeStatusMessage(arn: AnimeNotifier) { + arn.statusMessage.close() +} \ No newline at end of file diff --git a/scripts/Analytics.ts b/scripts/Analytics.ts new file mode 100644 index 00000000..f4c91fab --- /dev/null +++ b/scripts/Analytics.ts @@ -0,0 +1,26 @@ +export class Analytics { + push() { + let analytics = { + general: { + timezoneOffset: new Date().getTimezoneOffset() + }, + screen: { + width: screen.width, + height: screen.height, + availableWidth: screen.availWidth, + availableHeight: screen.availHeight, + pixelRatio: window.devicePixelRatio + }, + system: { + cpuCount: navigator.hardwareConcurrency, + platform: navigator.platform + } + } + + fetch("/dark-flame-master", { + method: "POST", + credentials: "same-origin", + body: JSON.stringify(analytics) + }) + } +} \ No newline at end of file diff --git a/scripts/AnimeNotifier.ts b/scripts/AnimeNotifier.ts index f371b015..79b42b42 100644 --- a/scripts/AnimeNotifier.ts +++ b/scripts/AnimeNotifier.ts @@ -1,14 +1,55 @@ import { Application } from "./Application" import { Diff } from "./Diff" -import { findAll } from "./utils" -import * as actions from "./actions" +import { MutationQueue } from "./MutationQueue" +import { StatusMessage } from "./StatusMessage" +import { PushManager } from "./PushManager" +import { TouchController } from "./TouchController" +import { Analytics } from "./Analytics" +import { SideBar } from "./SideBar" +import { InfiniteScroller } from "./InfiniteScroller" +import { ServiceWorkerManager } from "./ServiceWorkerManager" +import { displayAiringDate, displayDate } from "./DateView" +import { findAll, delay, canUseWebP, swapElements } from "./Utils" +import * as actions from "./Actions" export class AnimeNotifier { app: Application + analytics: Analytics + user: HTMLElement + title: string + webpEnabled: boolean + contentLoadedActions: Promise + statusMessage: StatusMessage visibilityObserver: IntersectionObserver + pushManager: PushManager + serviceWorkerManager: ServiceWorkerManager + touchController: TouchController + sideBar: SideBar + infiniteScroller: InfiniteScroller + mainPageLoaded: boolean + isLoading: boolean + lastReloadContentPath: string + + elementFound: MutationQueue + elementNotFound: MutationQueue + unmount: MutationQueue constructor(app: Application) { this.app = app + this.user = null + this.title = "Anime Notifier" + this.isLoading = true + + this.elementFound = new MutationQueue(elem => elem.classList.add("element-found")) + this.elementNotFound = new MutationQueue(elem => elem.classList.add("element-not-found")) + this.unmount = new MutationQueue(elem => elem.classList.remove("mounted")) + + // These classes will never be removed on DOM diffs + Diff.persistentClasses.add("mounted") + Diff.persistentClasses.add("element-found") + + // Never remove src property on diffs + Diff.persistentAttributes.add("src") if("IntersectionObserver" in window) { // Enable lazy load @@ -35,6 +76,28 @@ export class AnimeNotifier { } } + init() { + // App init + this.app.init() + + // Event listeners + document.addEventListener("readystatechange", this.onReadyStateChange.bind(this)) + document.addEventListener("DOMContentLoaded", this.onContentLoaded.bind(this)) + document.addEventListener("keydown", this.onKeyDown.bind(this), false) + window.addEventListener("popstate", this.onPopState.bind(this)) + + // Idle + this.requestIdleCallback(this.onIdle.bind(this)) + } + + requestIdleCallback(func: Function) { + if("requestIdleCallback" in window) { + window["requestIdleCallback"](func) + } else { + func() + } + } + onReadyStateChange() { if(document.readyState !== "interactive") { return @@ -44,84 +107,390 @@ export class AnimeNotifier { } run() { + // Check for WebP support + this.webpEnabled = canUseWebP() + + // Initiate the elements we need + this.user = this.app.find("user") this.app.content = this.app.find("content") this.app.loading = this.app.find("loading") - this.app.run() + + // Status message + this.statusMessage = new StatusMessage( + this.app.find("status-message"), + this.app.find("status-message-text") + ) + + // Push manager + this.pushManager = new PushManager() + + // Service worker + this.serviceWorkerManager = new ServiceWorkerManager(this, "/service-worker") + + // Analytics + this.analytics = new Analytics() + + // Sidebar control + this.sideBar = new SideBar(this.app.find("sidebar")) + + // Infinite scrolling + this.infiniteScroller = new InfiniteScroller(this.app.content.parentElement, 100) + + // Loading + this.loading(false) } onContentLoaded() { + // Stop watching all the objects from the previous page. this.visibilityObserver.disconnect() - // Update each of these asynchronously - Promise.resolve().then(() => this.mountMountables()) - Promise.resolve().then(() => this.assignActions()) - Promise.resolve().then(() => this.lazyLoadImages()) + this.contentLoadedActions = Promise.all([ + Promise.resolve().then(() => this.mountMountables()), + Promise.resolve().then(() => this.lazyLoad()), + Promise.resolve().then(() => this.displayLocalDates()), + Promise.resolve().then(() => this.setSelectBoxValue()), + Promise.resolve().then(() => this.assignActions()), + Promise.resolve().then(() => this.updatePushUI()), + Promise.resolve().then(() => this.dragAndDrop()), + Promise.resolve().then(() => this.countUp()) + ]) + + // Apply page title + let headers = document.getElementsByTagName("h1") + + if(this.app.currentPath === "/" || headers.length === 0) { + if(document.title !== this.title) { + document.title = this.title + } + } else { + document.title = headers[0].innerText + } } - reloadContent() { - return fetch("/_" + this.app.currentPath, { - credentials: "same-origin" + onIdle() { + // Service worker + this.serviceWorkerManager.register() + + // Analytics + if(this.user) { + this.analytics.push() + } + + // Offline message + if(navigator.onLine === false) { + this.statusMessage.showError("You are viewing an offline version of the site now.") + } + } + + dragAndDrop() { + for(let element of findAll("inventory-slot")) { + // Skip elements that have their event listeners attached already + if(element["listeners-attached"]) { + continue + } + + element.addEventListener("dragstart", e => { + if(!element.draggable) { + return + } + + e.dataTransfer.setData("text", element.dataset.index) + }, false) + + element.addEventListener("dblclick", e => { + if(!element.draggable) { + return + } + + let itemName = element.title + + if(element.dataset.consumable !== "true") { + return this.statusMessage.showError(itemName + " is not a consumable item.") + } + + let apiEndpoint = this.findAPIEndpoint(element) + + this.post(apiEndpoint + "/use/" + element.dataset.index, "") + .then(() => this.reloadContent()) + .then(() => this.statusMessage.showInfo(`You used ${itemName}.`)) + .catch(err => this.statusMessage.showError(err)) + }, false) + + element.addEventListener("dragenter", e => { + element.classList.add("drag-enter") + }, false) + + element.addEventListener("dragleave", e => { + element.classList.remove("drag-enter") + }, false) + + element.addEventListener("dragover", e => { + e.preventDefault() + }, false) + + element.addEventListener("drop", e => { + let toElement = e.toElement as HTMLElement + toElement.classList.remove("drag-enter") + + e.stopPropagation() + e.preventDefault() + + let inventory = e.toElement.parentElement + let fromIndex = e.dataTransfer.getData("text") + + if(!fromIndex) { + return + } + + let fromElement = inventory.childNodes[fromIndex] as HTMLElement + + let toIndex = toElement.dataset.index + + if(fromElement === toElement || fromIndex === toIndex) { + return + } + + // Swap in database + let apiEndpoint = this.findAPIEndpoint(inventory) + + this.post(apiEndpoint + "/swap/" + fromIndex + "/" + toIndex, "") + .catch(err => this.statusMessage.showError(err)) + + // Swap in UI + swapElements(fromElement, toElement) + + fromElement.dataset.index = toIndex + toElement.dataset.index = fromIndex + }, false) + + // Prevent re-attaching the same listeners + element["listeners-attached"] = true + } + } + + async updatePushUI() { + if(!this.pushManager.pushSupported || !this.app.currentPath.includes("/settings/notifications")) { + return + } + + let subscription = await this.pushManager.subscription() + + if(subscription) { + this.app.find("enable-notifications").style.display = "none" + this.app.find("disable-notifications").style.display = "flex" + } else { + this.app.find("enable-notifications").style.display = "flex" + this.app.find("disable-notifications").style.display = "none" + } + } + + countUp() { + for(let element of findAll("count-up")) { + let final = parseInt(element.innerText) + let duration = 2000.0 + let start = Date.now() + + element.innerText = "0" + + let callback = () => { + let progress = (Date.now() - start) / duration + + if(progress > 1) { + progress = 1 + } + + element.innerText = String(Math.round(progress * final)) + + if(progress < 1) { + window.requestAnimationFrame(callback) + } + } + + window.requestAnimationFrame(callback) + } + } + + setSelectBoxValue() { + for(let element of document.getElementsByTagName("select")) { + element.value = element.getAttribute("value") + } + } + + displayLocalDates() { + const now = new Date() + + for(let element of findAll("utc-airing-date")) { + displayAiringDate(element, now) + } + + for(let element of findAll("utc-date")) { + displayDate(element, now) + } + } + + reloadContent(cached?: boolean) { + // console.log("reload content", "/_" + this.app.currentPath) + + let headers = new Headers() + + if(!cached) { + headers.append("X-Reload", "true") + } else { + headers.append("X-CacheOnly", "true") + } + + let path = this.app.currentPath + this.lastReloadContentPath = path + + return fetch("/_" + path, { + credentials: "same-origin", + headers + }) + .then(response => { + if(this.app.currentPath !== path) { + return Promise.reject("old request") + } + + return Promise.resolve(response) }) .then(response => response.text()) .then(html => Diff.innerHTML(this.app.content, html)) .then(() => this.app.emit("DOMContentLoaded")) } - loading(isLoading: boolean) { - if(isLoading) { + reloadPage() { + console.log("reload page", this.app.currentPath) + + let path = this.app.currentPath + this.lastReloadContentPath = path + + return fetch(path, { + credentials: "same-origin" + }) + .then(response => { + if(this.app.currentPath !== path) { + return Promise.reject("old request") + } + + return Promise.resolve(response) + }) + .then(response => response.text()) + .then(html => { + Diff.root(document.documentElement, html) + }) + .then(() => this.app.emit("DOMContentLoaded")) + .then(() => this.loading(false)) // Because our loading element gets reset due to full page diff + } + + loading(newState: boolean) { + this.isLoading = newState + + if(this.isLoading) { + document.documentElement.style.cursor = "progress" this.app.loading.classList.remove(this.app.fadeOutClass) } else { + document.documentElement.style.cursor = "auto" this.app.loading.classList.add(this.app.fadeOutClass) } } assignActions() { for(let element of findAll("action")) { - if(element["action assigned"]) { + let actionTrigger = element.dataset.trigger + let actionName = element.dataset.action + + if(!actionTrigger || !actionName) { continue } - let actionName = element.dataset.action + let oldAction = element["action assigned"] - element.addEventListener(element.dataset.trigger, e => { + if(oldAction) { + if(oldAction.trigger === actionTrigger && oldAction.action === actionName) { + continue + } + + element.removeEventListener(oldAction.trigger, oldAction.handler) + } + + if(!(actionName in actions)) { + this.statusMessage.showError(`Action '${actionName}' has not been defined`) + continue + } + + let actionHandler = e => { actions[actionName](this, element, e) e.stopPropagation() e.preventDefault() - }) + } + + element.addEventListener(actionTrigger, actionHandler) // 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 + element["action assigned"] = { + trigger: actionTrigger, + action: actionName, + handler: actionHandler + } } } - lazyLoadImages() { + lazyLoad() { for(let element of findAll("lazy")) { - this.lazyLoadImage(element as HTMLImageElement) + switch(element.tagName) { + case "IMG": + this.lazyLoadImage(element as HTMLImageElement) + break + + case "IFRAME": + this.lazyLoadIFrame(element as HTMLIFrameElement) + break + } } } - lazyLoadImage(img: HTMLImageElement) { + lazyLoadImage(element: HTMLImageElement) { // Once the image becomes visible, load it - img["became visible"] = () => { - img.src = img.dataset.src + element["became visible"] = () => { + // Replace URL with WebP if supported + if(this.webpEnabled && element.dataset.webp) { + let dot = element.dataset.src.lastIndexOf(".") + element.src = element.dataset.src.substring(0, dot) + ".webp" + } else { + element.src = element.dataset.src + } - if(img.naturalWidth === 0) { - img.onload = function() { - this.classList.add("image-found") + if(element.naturalWidth === 0) { + element.onload = () => { + this.elementFound.queue(element) } - img.onerror = function() { - this.classList.add("image-not-found") + element.onerror = () => { + this.elementNotFound.queue(element) } } else { - img.classList.add("image-found") + this.elementFound.queue(element) } } - this.visibilityObserver.observe(img) + this.visibilityObserver.observe(element) + } + + lazyLoadIFrame(element: HTMLIFrameElement) { + // Once the iframe becomes visible, load it + element["became visible"] = () => { + // If the source is already set correctly, don't set it again to avoid iframe flickering. + if(element.src !== element.dataset.src && element.src !== (window.location.protocol + element.dataset.src)) { + element.src = element.dataset.src + } + + this.elementFound.queue(element) + } + + this.visibilityObserver.observe(element) } mountMountables() { @@ -130,27 +499,202 @@ export class AnimeNotifier { unmountMountables() { for(let element of findAll("mountable")) { - element.classList.remove("mounted") + if(element.classList.contains("never-unmount")) { + continue + } + + this.unmount.queue(element) } } modifyDelayed(className: string, func: (element: HTMLElement) => void) { - const delay = 20 - const maxDelay = 500 + const maxDelay = 1000 + const delay = 18 let time = 0 + let start = Date.now() + let maxTime = start + maxDelay - for(let element of findAll(className)) { - time += delay + let mountableTypes = new Map() + let mountableTypeMutations = new Map>() - if(time > maxDelay) { - func(element) + let collection = document.getElementsByClassName(className) + + if(collection.length === 0) { + return + } + + // let delay = Math.min(maxDelay / collection.length, 20) + + for(let i = 0; i < collection.length; i++) { + let element = collection.item(i) as HTMLElement + let type = element.dataset.mountableType || "general" + + if(mountableTypes.has(type)) { + time = mountableTypes.get(type) + delay + mountableTypes.set(type, time) } else { - setTimeout(() => { - window.requestAnimationFrame(() => func(element)) - }, time) + time = start + mountableTypes.set(type, time) + mountableTypeMutations.set(type, []) + } + + if(time > maxTime) { + time = maxTime + } + + mountableTypeMutations.get(type).push({ + element, + time + }) + } + + for(let mountableType of mountableTypeMutations.keys()) { + let mutations = mountableTypeMutations.get(mountableType) + let mutationIndex = 0 + + let updateBatch = () => { + let now = Date.now() + + for(; mutationIndex < mutations.length; mutationIndex++) { + let mutation = mutations[mutationIndex] + + if(mutation.time > now) { + break + } + + func(mutation.element) + } + + if(mutationIndex < mutations.length) { + window.requestAnimationFrame(updateBatch) + } + } + + window.requestAnimationFrame(updateBatch) + } + } + + diff(url: string) { + if(url === this.app.currentPath) { + return Promise.reject(null) + } + + let path = "/_" + url + + let request = fetch(path, { + credentials: "same-origin" + }) + .then(response => { + return response.text() + }) + + history.pushState(url, null, url) + this.app.currentPath = url + this.app.markActiveLinks() + this.unmountMountables() + this.loading(true) + + // Delay by transition-speed + return delay(200).then(() => request) + .then(html => this.app.setContent(html, true)) + .then(() => this.app.emit("DOMContentLoaded")) + .then(() => this.loading(false)) + .catch(console.error) + } + + post(url: string, body: any) { + if(this.isLoading) { + return Promise.resolve(null) + } + + if(typeof body !== "string") { + body = JSON.stringify(body) + } + + this.loading(true) + + return fetch(url, { + method: "POST", + body, + credentials: "same-origin" + }) + .then(response => { + this.loading(false) + + if(response.status === 200) { + return Promise.resolve(response) + } + + return response.text().then(err => { + throw err + }) + }) + .catch(err => { + this.loading(false) + throw err + }) + } + + scrollTo(target: HTMLElement) { + const duration = 250.0 + const steps = 60 + const interval = duration / steps + const fullSin = Math.PI / 2 + const contentPadding = 24 + + let scrollHandle: number + let oldScroll = this.app.content.parentElement.scrollTop + let newScroll = 0 + let finalScroll = Math.max(target.offsetTop - contentPadding, 0) + let scrollDistance = finalScroll - oldScroll + + if(scrollDistance > 0 && scrollDistance < 4) { + return + } + + let timeStart = Date.now() + let timeEnd = timeStart + duration + + let scroll = () => { + let time = Date.now() + let progress = (time - timeStart) / duration + + if(progress > 1.0) { + progress = 1.0 + } + + newScroll = oldScroll + scrollDistance * Math.sin(progress * fullSin) + this.app.content.parentElement.scrollTop = newScroll + + if(time < timeEnd && newScroll != finalScroll) { + window.requestAnimationFrame(scroll) } } + + window.requestAnimationFrame(scroll) + } + + findAPIEndpoint(element: HTMLElement) { + if(element.dataset.api !== undefined) { + return element.dataset.api + } + + let apiObject: HTMLElement + let parent = element + + while(parent = parent.parentElement) { + if(parent.dataset.api !== undefined) { + apiObject = parent + break + } + } + + if(!apiObject) { + throw "API object not found" + } + + return apiObject.dataset.api } onPopState(e: PopStateEvent) { @@ -166,15 +710,32 @@ export class AnimeNotifier { } onKeyDown(e: KeyboardEvent) { + let activeElement = document.activeElement + // Ignore hotkeys on input elements - switch(document.activeElement.tagName) { + switch(activeElement.tagName) { case "INPUT": case "TEXTAREA": return } + // Ignore hotkeys on contentEditable elements + if(activeElement.getAttribute("contenteditable") === "true") { + // Disallow Enter key in contenteditables and make it blur the element instead + if(e.keyCode == 13) { + if("blur" in activeElement) { + activeElement["blur"]() + } + + e.preventDefault() + e.stopPropagation() + } + + return + } + // F = Search - if(e.keyCode == 70) { + if(e.keyCode == 70 && !e.ctrlKey) { let search = this.app.find("search") as HTMLInputElement search.focus() @@ -182,16 +743,16 @@ export class AnimeNotifier { e.preventDefault() e.stopPropagation() + return + } + + // Ctrl + , = Settings + if(e.ctrlKey && e.keyCode == 188) { + this.app.load("/settings") + + e.preventDefault() + e.stopPropagation() + return } } - - // onResize(e: UIEvent) { - // let hasScrollbar = this.app.content.clientHeight === this.app.content.scrollHeight - - // if(hasScrollbar) { - // this.app.content.classList.add("has-scrollbar") - // } else { - // this.app.content.classList.remove("has-scrollbar") - // } - // } } \ No newline at end of file diff --git a/scripts/Application.ts b/scripts/Application.ts index a11f3f06..2b8c395d 100644 --- a/scripts/Application.ts +++ b/scripts/Application.ts @@ -23,10 +23,11 @@ export class Application { this.fadeOutClass = "fade-out" } - run() { - this.ajaxify() - this.markActiveLinks() - this.loading.classList.add(this.fadeOutClass) + init() { + document.addEventListener("DOMContentLoaded", () => { + this.ajaxify() + this.markActiveLinks() + }) } find(id: string): HTMLElement { @@ -92,6 +93,7 @@ export class Application { request.then(html => { // Set content this.setContent(html, false) + this.scrollToTop() // Fade animations this.content.classList.remove(this.fadeOutClass) @@ -117,10 +119,6 @@ export class Application { } else { this.content.innerHTML = html } - - this.ajaxify(this.content) - this.markActiveLinks(this.content) - this.scrollToTop() } markActiveLinks(element?: HTMLElement) { @@ -149,7 +147,7 @@ export class Application { for(let i = 0; i < links.length; i++) { let link = links[i] as HTMLElement - link.classList.remove(this.ajaxClass) + // link.classList.remove(this.ajaxClass) let self = this link.onclick = function(e) { @@ -160,7 +158,10 @@ export class Application { let url = this.getAttribute("href") e.preventDefault() - e.stopPropagation() + + // if(this.dataset.bubble !== "true") { + // e.stopPropagation() + // } if(!url || url === self.currentPath) return diff --git a/scripts/DateView.ts b/scripts/DateView.ts new file mode 100644 index 00000000..21abfe11 --- /dev/null +++ b/scripts/DateView.ts @@ -0,0 +1,121 @@ +import { plural } from "./Utils" + +const oneSecond = 1000 +const oneMinute = 60 * oneSecond +const oneHour = 60 * oneMinute +const oneDay = 24 * oneHour +const oneWeek = 7 * oneDay +const oneMonth = 30 * oneDay +const oneYear = 365.25 * oneDay + +export var monthNames = [ + "January", "February", "March", + "April", "May", "June", "July", + "August", "September", "October", + "November", "December" +] + +export var dayNames = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday" +] + +function getRemainingTime(remaining: number): string { + let remainingAbs = Math.abs(remaining) + + if(remainingAbs >= oneYear) { + return plural(Math.round(remaining / oneYear), "year") + } + + if(remainingAbs >= oneMonth) { + return plural(Math.round(remaining / oneMonth), "month") + } + + if(remainingAbs >= oneWeek) { + return plural(Math.round(remaining / oneWeek), "week") + } + + if(remainingAbs >= oneDay) { + return plural(Math.round(remaining / oneDay), "day") + } + + if(remainingAbs >= oneHour) { + return plural(Math.round(remaining / oneHour), " hour") + } + + if(remainingAbs >= oneMinute) { + return plural(Math.round(remaining / oneMinute), " minute") + } + + if(remainingAbs >= oneSecond) { + return plural(Math.round(remaining / oneSecond), " second") + } + + return "Just now" +} + +export function displayAiringDate(element: HTMLElement, now: Date) { + if(element.dataset.startDate === "") { + element.innerText = "" + return + } + + let startDate = new Date(element.dataset.startDate) + let endDate = new Date(element.dataset.endDate) + + let h = startDate.getHours() + let m = startDate.getMinutes() + let startTime = (h <= 9 ? "0" + h : h) + ":" + (m <= 9 ? "0" + m : m) + + h = endDate.getHours() + m = endDate.getMinutes() + let endTime = (h <= 9 ? "0" + h : h) + ":" + (m <= 9 ? "0" + m : m) + + let airingVerb = "will be airing" + + let remaining = startDate.getTime() - now.getTime() + let remainingString = getRemainingTime(remaining) + + // Add "ago" if the date is in the past + if(remainingString.startsWith("-")) { + remainingString = remainingString.substring(1) + " ago" + } + + element.innerText = remainingString + + if(remaining < 0) { + airingVerb = "aired" + } + + element.title = "Episode " + element.dataset.episodeNumber + " " + airingVerb + " " + dayNames[startDate.getDay()] + " from " + startTime + " - " + endTime +} + +export function displayDate(element: HTMLElement, now: Date) { + if(element.dataset.date === "") { + element.innerText = "" + return + } + + let startDate = new Date(element.dataset.date) + + let h = startDate.getHours() + let m = startDate.getMinutes() + let startTime = (h <= 9 ? "0" + h : h) + ":" + (m <= 9 ? "0" + m : m) + + let remaining = startDate.getTime() - now.getTime() + let remainingString = getRemainingTime(remaining) + + // Add "ago" if the date is in the past + if(remainingString.startsWith("-")) { + remainingString = remainingString.substring(1) + " ago" + } + + element.innerText = remainingString + + element.title = dayNames[startDate.getDay()] + " " + startTime +} \ No newline at end of file diff --git a/scripts/Diff.ts b/scripts/Diff.ts index ea3c2620..de3bdcfc 100644 --- a/scripts/Diff.ts +++ b/scripts/Diff.ts @@ -1,4 +1,32 @@ export class Diff { + static persistentClasses = new Set() + static persistentAttributes = new Set() + + // Reuse container for diffs to avoid memory allocation + static container: HTMLElement + static rootContainer: HTMLElement + + // innerHTML will diff the element with the given HTML string and apply DOM mutations. + static innerHTML(aRoot: HTMLElement, html: string) { + if(!Diff.container) { + Diff.container = document.createElement("main") + } + + Diff.container.innerHTML = html + Diff.childNodes(aRoot, Diff.container) + } + + // root will diff the document root element with the given HTML string and apply DOM mutations. + static root(aRoot: HTMLElement, html: string) { + if(!Diff.rootContainer) { + Diff.rootContainer = document.createElement("html") + } + + Diff.rootContainer.innerHTML = html.replace("", "") + Diff.childNodes(aRoot.getElementsByTagName("body")[0], Diff.rootContainer.getElementsByTagName("body")[0]) + } + + // childNodes diffs the child nodes of 2 given elements and applies DOM mutations. static childNodes(aRoot: Node, bRoot: Node) { let aChild = [...aRoot.childNodes] let bChild = [...bRoot.childNodes] @@ -7,6 +35,7 @@ export class Diff { for(let i = 0; i < numNodes; i++) { let a = aChild[i] + // Remove nodes at the end of a that do not exist in b if(i >= bChild.length) { aRoot.removeChild(a) continue @@ -14,36 +43,38 @@ export class Diff { let b = bChild[i] + // If a doesn't have that many nodes, simply append at the end of a if(i >= aChild.length) { aRoot.appendChild(b) continue } + // If it's a completely different HTML tag or node type, replace it if(a.nodeName !== b.nodeName || a.nodeType !== b.nodeType) { aRoot.replaceChild(b, a) continue } + // Text node: + // We don't need to check for b to be a text node as well because + // we eliminated different node types in the previous condition. if(a.nodeType === Node.TEXT_NODE) { a.textContent = b.textContent continue } + // HTML element: if(a.nodeType === Node.ELEMENT_NODE) { let elemA = a as HTMLElement let elemB = b as HTMLElement - if(elemA.tagName === "IFRAME") { - continue - } - let removeAttributes: Attr[] = [] for(let x = 0; x < elemA.attributes.length; x++) { let attrib = elemA.attributes[x] if(attrib.specified) { - if(!elemB.hasAttribute(attrib.name)) { + if(!elemB.hasAttribute(attrib.name) && !Diff.persistentAttributes.has(attrib.name)) { removeAttributes.push(attrib) } } @@ -56,9 +87,40 @@ export class Diff { for(let x = 0; x < elemB.attributes.length; x++) { let attrib = elemB.attributes[x] - if(attrib.specified) { - elemA.setAttribute(attrib.name, elemB.getAttribute(attrib.name)) + if(!attrib.specified) { + continue } + + // If the attribute value is exactly the same, skip this attribute. + if(elemA.getAttribute(attrib.name) === attrib.value) { + continue + } + + if(attrib.name === "class") { + let classesA = elemA.classList + let classesB = elemB.classList + let removeClasses: string[] = [] + + for(let className of classesA) { + if(!classesB.contains(className) && !Diff.persistentClasses.has(className)) { + removeClasses.push(className) + } + } + + for(let className of removeClasses) { + classesA.remove(className) + } + + for(let className of classesB) { + if(!classesA.contains(className)) { + classesA.add(className) + } + } + + continue + } + + elemA.setAttribute(attrib.name, attrib.value) } // Special case: Apply state of input elements @@ -70,11 +132,4 @@ export class Diff { Diff.childNodes(a, b) } } - - 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/InfiniteScroller.ts b/scripts/InfiniteScroller.ts new file mode 100644 index 00000000..c2e387c5 --- /dev/null +++ b/scripts/InfiniteScroller.ts @@ -0,0 +1,25 @@ +export class InfiniteScroller { + container: HTMLElement + threshold: number + + constructor(container, threshold) { + this.container = container + this.threshold = threshold + + this.container.addEventListener("scroll", e => { + if(this.container.scrollTop + this.container.clientHeight >= this.container.scrollHeight - threshold) { + this.loadMore() + } + }) + } + + loadMore() { + let button = document.getElementById("load-more-button") + + if(!button) { + return + } + + button.click() + } +} \ No newline at end of file diff --git a/scripts/MutationQueue.ts b/scripts/MutationQueue.ts new file mode 100644 index 00000000..b353ab59 --- /dev/null +++ b/scripts/MutationQueue.ts @@ -0,0 +1,29 @@ +export class MutationQueue { + elements: Array + mutation: (elem: HTMLElement) => void + + constructor(mutation: (elem: HTMLElement) => void) { + this.mutation = mutation + this.elements = [] + } + + queue(elem: HTMLElement) { + this.elements.push(elem) + + if(this.elements.length === 1) { + window.requestAnimationFrame(() => this.mutateAll()) + } + } + + mutateAll() { + for(let i = 0; i < this.elements.length; i++) { + this.mutation(this.elements[i]) + } + + this.clear() + } + + clear() { + this.elements.length = 0 + } +} \ No newline at end of file diff --git a/scripts/PushManager.ts b/scripts/PushManager.ts new file mode 100644 index 00000000..0f4195f8 --- /dev/null +++ b/scripts/PushManager.ts @@ -0,0 +1,121 @@ +export class PushManager { + pushSupported: boolean + + constructor() { + this.pushSupported = ("serviceWorker" in navigator) && ("PushManager" in window) + } + + async subscription(): Promise { + if(!this.pushSupported) { + return Promise.resolve(null) + } + + let registration = await navigator.serviceWorker.ready + let subscription = await registration.pushManager.getSubscription() + + if(subscription) { + return Promise.resolve(subscription) + } + + return Promise.resolve(null) + } + + async subscribe(userId: string) { + if(!this.pushSupported) { + return + } + + let registration = await navigator.serviceWorker.ready + let subscription = await registration.pushManager.getSubscription() + + if(!subscription) { + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array("BAwPKVCWQ2_nc7SIGltYfWZhMpW54BSkbwelpa8eYMbqSitmCAGm3xRBdRiq1Wt-hUsE7x59GCcaJxqQtF2hZPw") + }) + + this.subscribeOnServer(subscription, userId) + } else { + console.log("Using existing subscription", subscription) + } + } + + async unsubscribe(userId: string) { + if(!this.pushSupported) { + return + } + + let registration = await navigator.serviceWorker.ready + let subscription = await registration.pushManager.getSubscription() + + if(!subscription) { + console.error("Subscription does not exist") + return + } + + await subscription.unsubscribe() + + this.unsubscribeOnServer(subscription, userId) + } + + subscribeOnServer(subscription: PushSubscription, userId: string) { + console.log("Send subscription to server...") + + let rawKey = subscription.getKey("p256dh") + let key = rawKey ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawKey))) : "" + + let rawSecret = subscription.getKey("auth") + let secret = rawSecret ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawSecret))) : "" + + let endpoint = subscription.endpoint + + let pushSubscription = { + endpoint, + p256dh: key, + auth: secret, + platform: navigator.platform, + userAgent: navigator.userAgent, + screen: { + width: window.screen.width, + height: window.screen.height + } + } + + return fetch("/api/pushsubscriptions/" + userId + "/add", { + method: "POST", + credentials: "same-origin", + body: JSON.stringify(pushSubscription) + }) + } + + unsubscribeOnServer(subscription: PushSubscription, userId: string) { + console.log("Send unsubscription to server...") + console.log(subscription) + + let pushSubscription = { + endpoint: subscription.endpoint + } + + return fetch("/api/pushsubscriptions/" + userId + "/remove", { + method: "POST", + credentials: "same-origin", + body: JSON.stringify(pushSubscription) + }) + } +} + +function urlBase64ToUint8Array(base64String) { + const padding = "=".repeat((4 - base64String.length % 4) % 4) + const base64 = (base64String + padding) + .replace(/\-/g, "+") + .replace(/_/g, "/") + + const rawData = window.atob(base64) + const outputArray = new Uint8Array(rawData.length) + + for(let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i) + } + + return outputArray +} \ No newline at end of file diff --git a/scripts/ServiceWorkerManager.ts b/scripts/ServiceWorkerManager.ts new file mode 100644 index 00000000..a7d9d60c --- /dev/null +++ b/scripts/ServiceWorkerManager.ts @@ -0,0 +1,96 @@ +import { AnimeNotifier } from "./AnimeNotifier" + +export class ServiceWorkerManager { + arn: AnimeNotifier + uri: string + + constructor(arn: AnimeNotifier, uri: string) { + this.arn = arn + this.uri = uri + } + + register() { + if(!("serviceWorker" in navigator)) { + return + } + + console.log("register service worker") + + navigator.serviceWorker.register(this.uri).then(registration => { + // registration.update() + }) + + navigator.serviceWorker.addEventListener("message", evt => { + this.onMessage(evt) + }) + + // This will send a message to the service worker that the DOM has been loaded + let sendContentLoadedEvent = () => { + if(!navigator.serviceWorker.controller) { + return + } + + // A reloadContent call should never trigger another reload + if(this.arn.app.currentPath === this.arn.lastReloadContentPath) { + console.log("reload finished.") + this.arn.lastReloadContentPath = "" + return + } + + let message = { + type: "loaded", + url: "" + } + + // If mainPageLoaded is set, it means every single request is now an AJAX request for the /_/ prefixed page + if(this.arn.mainPageLoaded) { + message.url = window.location.origin + "/_" + window.location.pathname + } else { + this.arn.mainPageLoaded = true + message.url = window.location.href + } + + console.log("checking for updates:", message.url) + + this.postMessage(message) + } + + // For future loaded events + document.addEventListener("DOMContentLoaded", sendContentLoadedEvent) + + // If the page is loaded already, send the loaded event right now. + if(document.readyState !== "loading") { + sendContentLoadedEvent() + } + } + + postMessage(message: any) { + navigator.serviceWorker.controller.postMessage(JSON.stringify(message)) + } + + onMessage(evt: ServiceWorkerMessageEvent) { + let message = JSON.parse(evt.data) + + switch(message.type) { + case "new content": + if(message.url.includes("/_/")) { + // Content reload + this.arn.contentLoadedActions.then(() => { + this.arn.reloadContent(true) + }) + } else { + // Full page reload + this.arn.contentLoadedActions.then(() => { + this.arn.reloadPage() + }) + } + + break + + case "reload page": + console.log("service worker instructed to reload page...disobeying in test mode") + // location.reload(true) + break + } + } +} \ No newline at end of file diff --git a/scripts/SideBar.ts b/scripts/SideBar.ts new file mode 100644 index 00000000..aa3e7d02 --- /dev/null +++ b/scripts/SideBar.ts @@ -0,0 +1,29 @@ +import { TouchController } from "./TouchController" + +export class SideBar { + element: HTMLElement + touchController: TouchController + + constructor(element) { + this.element = element + + document.body.addEventListener("click", e => { + if(document.activeElement.id === "search") + return; + + this.hide() + }) + + this.touchController = new TouchController() + this.touchController.leftSwipe = () => this.hide() + this.touchController.rightSwipe = () => this.show() + } + + show() { + this.element.classList.add("sidebar-visible") + } + + hide() { + this.element.classList.remove("sidebar-visible") + } +} \ No newline at end of file diff --git a/scripts/StatusMessage.ts b/scripts/StatusMessage.ts new file mode 100644 index 00000000..adc81497 --- /dev/null +++ b/scripts/StatusMessage.ts @@ -0,0 +1,49 @@ +import { delay } from "./Utils" + +export class StatusMessage { + container: HTMLElement + text: HTMLElement + + constructor(container: HTMLElement, text: HTMLElement) { + this.container = container + this.text = text + } + + show(message: string, duration: number) { + let messageId = String(Date.now()) + + this.text.innerText = message + + this.container.classList.remove("fade-out") + this.container.dataset.messageId = messageId + + delay(duration || 4000).then(() => { + if(this.container.dataset.messageId !== messageId) { + return + } + + this.close() + }) + } + + clearStyle() { + this.container.classList.remove("info-message") + this.container.classList.remove("error-message") + } + + showError(message: string, duration?: number) { + this.clearStyle() + this.show(message, duration || 4000) + this.container.classList.add("error-message") + } + + showInfo(message: string, duration?: number) { + this.clearStyle() + this.show(message, duration || 2000) + this.container.classList.add("info-message") + } + + close() { + this.container.classList.add("fade-out") + } +} \ No newline at end of file diff --git a/scripts/TouchController.ts b/scripts/TouchController.ts new file mode 100644 index 00000000..28805da9 --- /dev/null +++ b/scripts/TouchController.ts @@ -0,0 +1,53 @@ +export class TouchController { + x: number + y: number + + threshold: number + + leftSwipe: Function + rightSwipe: Function + upSwipe: Function + downSwipe: Function + + constructor() { + document.addEventListener("touchstart", evt => this.handleTouchStart(evt), false) + document.addEventListener("touchmove", evt => this.handleTouchMove(evt), false) + + this.downSwipe = this.upSwipe = this.rightSwipe = this.leftSwipe = () => null + this.threshold = 3 + } + + handleTouchStart(evt) { + this.x = evt.touches[0].clientX + this.y = evt.touches[0].clientY + } + + handleTouchMove(evt) { + if(!this.x || !this.y) { + return + } + + let xUp = evt.touches[0].clientX + let yUp = evt.touches[0].clientY + + let xDiff = this.x - xUp + let yDiff = this.y - yUp + + if(Math.abs(xDiff) > Math.abs(yDiff)) { + if(xDiff > this.threshold) { + this.leftSwipe() + } else if(xDiff < -this.threshold) { + this.rightSwipe() + } + } else { + if(yDiff > this.threshold) { + this.upSwipe() + } else if(yDiff < -this.threshold) { + this.downSwipe() + } + } + + this.x = undefined + this.y = undefined + } +} \ No newline at end of file diff --git a/scripts/Utils.ts b/scripts/Utils.ts new file mode 100644 index 00000000..cc164528 --- /dev/null +++ b/scripts/Utils.ts @@ -0,0 +1,50 @@ +export function* findAll(className: string): IterableIterator { + let elements = document.getElementsByClassName(className) + + for(let i = 0; i < elements.length; ++i) { + yield elements[i] as HTMLElement + } +} + +export function delay(millis: number, value?: T): Promise { + return new Promise(resolve => setTimeout(() => resolve(value), millis)) +} + +export function plural(count: number, singular: string): string { + return (count === 1 || count === -1) ? (count + " " + singular) : (count + " " + singular + "s") +} + +export function canUseWebP(): boolean { + let canvas = document.createElement("canvas") + + if(!!(canvas.getContext && canvas.getContext("2d"))) { + // WebP representation possible + return canvas.toDataURL("image/webp").indexOf("data:image/webp") === 0 + } else { + // In very old browsers (IE 8) canvas is not supported + return false + } +} + +export function swapElements(a: Node, b: Node) { + let parent = b.parentNode + let bNext = b.nextSibling + + // Special case for when a is the next sibling of b + if(bNext === a) { + // Just put a before b + parent.insertBefore(a, b) + } else { + // Insert b right before a + a.parentNode.insertBefore(b, a) + + // Now insert a where b was + if(bNext) { + // If there was an element after b, then insert a right before that + parent.insertBefore(a, bNext) + } else { + // Otherwise just append it as the last child + parent.appendChild(a) + } + } +} \ No newline at end of file diff --git a/scripts/actions.ts b/scripts/actions.ts deleted file mode 100644 index 52a9d68a..00000000 --- a/scripts/actions.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { Application } from "./Application" -import { AnimeNotifier } from "./AnimeNotifier" -import { Diff } from "./Diff" -import { delay, findAll } from "./utils" - -// Save new data from an input field -export function save(arn: AnimeNotifier, input: HTMLInputElement | HTMLTextAreaElement) { - arn.loading(true) - - let obj = {} - let value = input.value - - if(input.type === "number") { - if(input.getAttribute("step") === "1") { - obj[input.id] = parseInt(value) - } else { - obj[input.id] = parseFloat(value) - } - } else { - obj[input.id] = value - } - - // console.log(input.type, input.dataset.api, obj, JSON.stringify(obj)) - - let apiObject: HTMLElement - let parent = input as HTMLElement - - while(parent = parent.parentElement) { - if(parent.dataset.api !== undefined) { - apiObject = parent - break - } - } - - if(!apiObject) { - throw "API object not found" - } - - input.disabled = true - - fetch(apiObject.dataset.api, { - method: "POST", - body: JSON.stringify(obj), - credentials: "same-origin" - }) - .then(response => response.text()) - .then(body => { - if(body !== "ok") { - throw body - } - }) - .catch(console.error) - .then(() => { - arn.loading(false) - input.disabled = false - - return arn.reloadContent() - }) -} - -// Diff -export function diff(arn: AnimeNotifier, element: HTMLElement) { - let url = element.dataset.url || (element as HTMLAnchorElement).getAttribute("href") - let request = fetch("/_" + url).then(response => response.text()) - - history.pushState(url, null, url) - arn.app.currentPath = url - arn.app.markActiveLinks() - arn.loading(true) - arn.unmountMountables() - - // for(let element of findAll("mountable")) { - // element.classList.remove("mountable") - // } - - delay(300).then(() => { - request - .then(html => arn.app.setContent(html, true)) - .then(() => arn.app.markActiveLinks()) - // .then(() => { - // for(let element of findAll("mountable")) { - // element.classList.remove("mountable") - // } - // }) - .then(() => arn.app.emit("DOMContentLoaded")) - .then(() => arn.loading(false)) - .catch(console.error) - }) -} - -// Search -export function search(arn: AnimeNotifier, search: HTMLInputElement, e: KeyboardEvent) { - if(e.ctrlKey || e.altKey) { - return - } - - let term = search.value - - if(!window.location.pathname.startsWith("/search/")) { - history.pushState("search", null, "/search/" + term) - } else { - history.replaceState("search", null, "/search/" + term) - } - - if(!term || term.length < 2) { - arn.app.content.innerHTML = "Please enter at least 2 characters to start searching." - return - } - - var results = arn.app.find("results") - - if(!results) { - results = document.createElement("div") - results.id = "results" - arn.app.content.innerHTML = "" - arn.app.content.appendChild(results) - } - - arn.app.get("/_/search/" + encodeURI(term)) - .then(html => { - if(!search.value) { - return - } - - Diff.innerHTML(results, html) - arn.app.emit("DOMContentLoaded") - }) -} - -// Add anime to collection -export function addAnimeToCollection(arn: AnimeNotifier, button: HTMLElement) { - button.innerText = "Adding..." - arn.loading(true) - - let {animeId, userId, userNick} = button.dataset - - fetch("/api/animelist/" + userId + "/add", { - method: "POST", - body: animeId, - credentials: "same-origin" - }) - .then(response => response.text()) - .then(body => { - if(body !== "ok") { - throw body - } - - return arn.reloadContent() - }) - .catch(console.error) - .then(() => arn.loading(false)) -} - -// Remove anime from collection -export function removeAnimeFromCollection(arn: AnimeNotifier, button: HTMLElement) { - button.innerText = "Removing..." - arn.loading(true) - - let {animeId, userId, userNick} = button.dataset - - fetch("/api/animelist/" + userId + "/remove", { - method: "POST", - body: animeId, - credentials: "same-origin" - }) - .then(response => response.text()) - .then(body => { - if(body !== "ok") { - throw body - } - - return arn.app.load("/+" + userNick + "/animelist") - }) - .catch(console.error) - .then(() => arn.loading(false)) -} - -// Chrome extension installation -export function installExtension(arn: AnimeNotifier, button: HTMLElement) { - let browser: any = window["chrome"] - browser.webstore.install() -} \ No newline at end of file diff --git a/scripts/main.ts b/scripts/main.ts index 05af48fb..4f1ab54e 100644 --- a/scripts/main.ts +++ b/scripts/main.ts @@ -4,7 +4,7 @@ import { AnimeNotifier } from "./AnimeNotifier" let app = new Application() let arn = new AnimeNotifier(app) -document.addEventListener("readystatechange", arn.onReadyStateChange.bind(arn)) -document.addEventListener("DOMContentLoaded", arn.onContentLoaded.bind(arn)) -document.addEventListener("keydown", arn.onKeyDown.bind(arn), false) -window.addEventListener("popstate", arn.onPopState.bind(arn)) \ No newline at end of file +arn.init() + +// For debugging purposes +window["arn"] = arn \ No newline at end of file diff --git a/scripts/utils.ts b/scripts/utils.ts deleted file mode 100644 index b97017d3..00000000 --- a/scripts/utils.ts +++ /dev/null @@ -1,14 +0,0 @@ -export function* findAll(className: string) { - // getElementsByClassName failed for some reason. - // TODO: Test getElementsByClassName again. - // let elements = document.querySelectorAll("." + className) - let elements = document.getElementsByClassName(className) - - for(let i = 0; i < elements.length; ++i) { - yield elements[i] as HTMLElement - } -} - -export function delay(millis: number, value?: T): Promise { - return new Promise(resolve => setTimeout(() => resolve(value), millis)) -} \ No newline at end of file diff --git a/styles/base.scarlet b/styles/base.scarlet index bf3fea68..8326f56d 100644 --- a/styles/base.scarlet +++ b/styles/base.scarlet @@ -1,14 +1,15 @@ html height 100% + font-family "Ubuntu", "Trebuchet MS", sans-serif + font-size 100% body - font-family "Ubuntu", "Trebuchet MS", sans-serif - font-size 1.05rem tab-size 4 overflow hidden height 100% color text-color background-color bg-color + noise-strong a color link-color @@ -17,6 +18,7 @@ a :hover color link-hover-color + text-shadow link-hover-text-shadow text-decoration none :active @@ -25,14 +27,14 @@ a // &.active // color link-active-color -strong - font-weight bold - -em - font-style italic - img backface-visibility hidden +.hidden + display none !important + +.text-center + text-align center + .spacer flex 1 \ No newline at end of file diff --git a/styles/content.scarlet b/styles/content.scarlet index b881cd03..47665a25 100644 --- a/styles/content.scarlet +++ b/styles/content.scarlet @@ -2,5 +2,4 @@ vertical padding content-padding padding-top content-padding-top - line-height 1.7em - position relative \ No newline at end of file + line-height content-line-height \ No newline at end of file diff --git a/styles/embedded.scarlet b/styles/embedded.scarlet index 07a9d427..c0b8aef4 100644 --- a/styles/embedded.scarlet +++ b/styles/embedded.scarlet @@ -1,13 +1,27 @@ +remove-margin = 1.1rem + .embedded // Put navigation to the bottom of the screen flex-direction column-reverse !important - .extension-navigation - display inline-block + // .extension-navigation + // display inline-block .anime-list - max-width 500px - margin -1.1rem + // max-width 500px + margin-left calc(remove-margin * -1) + margin-top calc(remove-margin * -1) + width calc(100% + remove-margin * 2) + + // td + // padding 0.4rem 0.8rem + + .anime-list-owner + display none + + .anime-list-item-image, + .anime-list-item-image-container + width 27px #navigation font-size 0.9rem \ No newline at end of file diff --git a/styles/forum.scarlet b/styles/forum.scarlet index 75c239fa..1ad0affd 100644 --- a/styles/forum.scarlet +++ b/styles/forum.scarlet @@ -1,6 +1,7 @@ // .forum-header // text-align left // margin-bottom 1rem +post-content-padding-y = 0.75rem .thread-link vertical @@ -19,26 +20,30 @@ .post-content ui-element flex-grow 1 - padding 0.8rem 1rem + padding 0.75rem 1rem position relative + h1, h2, h3 + font-weight bold + text-align left + line-height 1.5em + margin 1rem 0 + h1 font-size 1.5rem - line-height 1.5em h2 font-size 1.3rem - line-height 1.5em - font-weight normal h3 font-size 1.1rem - line-height 1.5em - font-weight normal :hover .post-toolbar opacity 1 + + .post-date + opacity 0.25 .thread-content horizontal @@ -48,6 +53,8 @@ .thread-icons, .thread-reply-count + horizontal + align-items center opacity 0.5 text-align right font-size 0.9rem @@ -63,10 +70,6 @@ .thread-link-title color text-color -.forum-tags - // justify-content flex-start !important - margin-bottom 1rem - .post-author horizontal justify-content center @@ -96,16 +99,34 @@ content "+" .post-permalink - color blue !important - -.post-delete - color rgb(255, 32, 12) !important + color post-permalink-color .post-like - color green !important + color post-like-color .post-unlike - color rgb(255, 32, 12) !important + color post-unlike-color + +.post-save + // + +.post-edit-interface + vertical + +.post-title-input + margin-bottom post-content-padding-y + +.post-text-input + min-height 200px + +.post-date + position absolute + right -1rem + top 0.25rem + white-space nowrap + opacity 0 + transform translateX(100%) + default-transition // Old diff --git a/styles/headers.scarlet b/styles/headers.scarlet index e16bfae9..8dd648f6 100644 --- a/styles/headers.scarlet +++ b/styles/headers.scarlet @@ -1,11 +1,8 @@ -h1 - font-size 3em - -h2 +h1, h2 font-size 2em font-weight bold text-align center - line-height 1.2em + line-height 1.3em h3 font-size 1.5em @@ -13,7 +10,7 @@ h3 text-align left margin-top 0.6em -h2, h3 +h1, h2, h3 a color text-color diff --git a/styles/icons.scarlet b/styles/icons.scarlet index dd1085d2..9cfeed76 100644 --- a/styles/icons.scarlet +++ b/styles/icons.scarlet @@ -10,4 +10,7 @@ width 1em height 1em min-width 1em - fill currentColor \ No newline at end of file + fill currentColor + +.icon-headphones + transform translateY(2px) \ No newline at end of file diff --git a/styles/images.scarlet b/styles/images.scarlet index bf3ea806..8bc0fc41 100644 --- a/styles/images.scarlet +++ b/styles/images.scarlet @@ -2,10 +2,10 @@ visibility hidden opacity 0 -.image-found +.element-found visibility visible opacity 1 !important -.image-not-found +.element-not-found visibility hidden opacity 0 !important \ No newline at end of file diff --git a/styles/include/config.scarlet b/styles/include/config.scarlet index 08c869ec..8a74749f 100644 --- a/styles/include/config.scarlet +++ b/styles/include/config.scarlet @@ -1,30 +1,48 @@ // Colors text-color = rgb(60, 60, 60) -main-color = rgb(215, 38, 15) -link-color = main-color +bg-color = rgb(246, 246, 246) +main-color = rgb(248, 165, 130) +link-color = rgb(215, 38, 15) link-hover-color = rgb(242, 60, 30) link-active-color = link-hover-color -post-highlight-color = rgba(248, 165, 130, 0.7) -bg-color = rgb(246, 246, 246) +pro-color = hsla(0, 100%, 73%, 0.87) +anime-alternative-title-color = hsla(0, 0%, 0%, 0.5) + +theme-white = bg-color +theme-black = text-color +link-hover-text-shadow = none +tab-active-text-shadow = none // UI ui-border-color = rgba(0, 0, 0, 0.1) +ui-border = 1px solid ui-border-color ui-hover-border-color = rgba(0, 0, 0, 0.15) +ui-hover-border = 1px solid ui-hover-border-color ui-background = rgb(254, 254, 254) // ui-hover-background = rgb(254, 254, 254) // ui-background = linear-gradient(to bottom, rgba(0, 0, 0, 0.02) 0%, rgba(0, 0, 0, 0.037) 100%) // ui-hover-background = linear-gradient(to bottom, rgba(0, 0, 0, 0.01) 0%, rgba(0, 0, 0, 0.027) 100%) ui-disabled-color = rgb(224, 224, 224) +ui-element-border-radius = 3px // Input input-focus-border-color = rgb(248, 165, 130) // Button -button-hover-color = link-hover-color -forum-tag-hover-color = rgb(46, 85, 160) +button-hover-color = white +button-hover-background = link-hover-color +tab-background = rgba(0, 0, 0, 0.02) +tab-hover-background = bg-color +tab-active-color = white +tab-active-background = hsl(216, 68%, 42%) +// tab-active-background = rgb(46, 85, 160) + +sidebar-background = rgba(0, 0, 0, 0.03) +sidebar-opaque-background = ui-background // Forum forum-width = 830px +post-highlight-color = rgba(248, 165, 130, 0.7) // Avatar avatar-size = 50px @@ -33,11 +51,25 @@ avatar-size = 50px nav-color = text-color nav-link-color = rgba(255, 255, 255, 0.5) nav-link-hover-color = white -nav-link-hover-slide-color = rgb(248, 165, 130) +nav-link-hover-slide-color = main-color // nav-color = rgb(245, 245, 245) // nav-link-color = rgb(160, 160, 160) // nav-link-hover-color = rgb(80, 80, 80) +// Forum +post-like-color = green !important +post-unlike-color = rgb(255, 32, 12) !important +post-permalink-color = blue !important + +table-row-hover-background = hsla(0, 0%, 0%, 0.01) +anime-list-item-name-color = link-color + +// Tables +table-width-normal = 900px + +// Loading animation +loading-anim-color = nav-link-hover-slide-color + // Shadow shadow-light = 4px 4px 8px rgba(0, 0, 0, 0.05) shadow-medium = 6px 6px 12px rgba(0, 0, 0, 0.13) @@ -50,9 +82,12 @@ outline-shadow-heavy = 0 0 6px rgba(0, 0, 0, 0.6) // Distances content-padding = 1.6rem content-padding-top = 1.6rem +content-line-height = 1.7em hover-line-size = 3px -nav-height = 3.11rem +typography-margin = 0.4rem +// nav-height = 3.11rem // Timings fade-speed = 200ms -transition-speed = 290ms \ No newline at end of file +transition-speed = 150ms +mountable-transition-speed = 200ms diff --git a/styles/include/dark.scarlet b/styles/include/dark.scarlet new file mode 100644 index 00000000..b55c961d --- /dev/null +++ b/styles/include/dark.scarlet @@ -0,0 +1,37 @@ +// // Dark theme + +// // Main color +// hue = 45 +// saturation = 100% + +// // Main +// text-color = hsl(0, 0%, 90%) +// bg-color = hsl(0, 0%, 24%) +// link-color = hsl(hue, saturation, 66%) +// link-hover-color = hsl(hue, saturation, 76%) +// link-hover-text-shadow = 0 0 8px hsla(hue, saturation, 66%, 0.5) +// ui-background = hsla(0, 0%, 8%, 0.3) +// sidebar-background = rgba(0, 0, 0, 0.2) +// sidebar-opaque-background = ui-background +// table-row-hover-background = hsla(0, 0%, 100%, 0.01) + +// // White/black +// theme-white = bg-color +// theme-black = text-color + +// main-color = link-color +// link-active-color = link-hover-color +// button-hover-color = link-hover-color +// button-hover-background = hsla(0, 0%, 12%, 0.5) +// tab-background = hsla(0, 0%, 0%, 0.1) +// tab-hover-background = hsla(0, 0%, 0%, 0.2) +// tab-active-color = hsl(0, 0%, 95%) +// tab-active-background = hsla(0, 0%, 2%, 0.5) +// loading-anim-color = link-color +// anime-alternative-title-color = hsla(0, 0%, 100%, 0.5) +// anime-list-item-name-color = text-color + +// // Forum +// post-like-color = link-color +// post-unlike-color = link-color +// post-permalink-color = link-color \ No newline at end of file diff --git a/styles/include/mixins.scarlet b/styles/include/mixins.scarlet index 35770a34..02dc0861 100644 --- a/styles/include/mixins.scarlet +++ b/styles/include/mixins.scarlet @@ -9,6 +9,10 @@ mixin horizontal-wrap display flex flex-flow row wrap +mixin horizontal-wrap-center + horizontal-wrap + justify-content center + mixin vertical display flex flex-direction column @@ -17,10 +21,20 @@ mixin vertical-wrap display flex flex-flow column wrap +mixin vertical-wrap-center + horizontal-wrap + align-items center + +mixin noise-light + background-image url("/images/elements/noise-light.png") + +mixin noise-strong + background-image url("/images/elements/noise-strong.png") + mixin ui-element border 1px solid ui-border-color background ui-background - border-radius 3px + border-radius ui-element-border-radius default-transition :hover border-color ui-hover-border-color @@ -36,6 +50,29 @@ mixin clip-long-text white-space nowrap text-overflow ellipsis +mixin anime-mini-item + margin 0.25rem + +mixin anime-mini-item-image + width 55px !important + height 78px !important + border-radius 2px + filter none + transition filter transition-speed ease, opacity transition-speed ease + + :hover + filter saturate(1.3) + +mixin bg-dark-up + background-color transparent + :hover + background-color rgba(0, 0, 0, 0.015) + +mixin bg-light-up + background-color transparent + :hover + background-color rgba(255, 255, 255, 0.015) + mixin light-up filter brightness(0.4) saturate(1) :hover diff --git a/styles/input.scarlet b/styles/input.scarlet index 54afac5e..601f6a0b 100644 --- a/styles/input.scarlet +++ b/styles/input.scarlet @@ -1,83 +1,58 @@ mixin input-focus :focus - color black - border 1px solid input-focus-border-color + border 1px solid input-focus-border-color !important // TODO: Replace with alpha(main-color, 20%) function - box-shadow 0 0 6px rgba(248, 165, 130, 0.2) + box-shadow 0 0 6px rgba(248, 165, 130, 0.2) !important -input, textarea, button, select +input, textarea, button, .button, select + ui-element font-family inherit font-size 1em - padding 0.4em 0.8em - border-radius 3px + line-height 1.25em color text-color -input, textarea - border ui-border - background white - box-shadow none - width 100% +input, textarea, select input-focus - + width 100% + :disabled ui-disabled -input - default-transition +input, select + padding 0.5rem 1rem +input :active transform translateY(3px) -// We need this to have a selector with a higher priority than .widget-element:focus -input.widget-element, -textarea.widget-element - input-focus - -textarea - height 10rem - button, .button - ui-element horizontal - font-size 1rem - line-height 1rem - padding 0.75rem 1rem + line-height 1.5em + padding 0.5rem 1rem color link-color + align-items center :hover, &.active cursor pointer - color white - background-color button-hover-color + color button-hover-color + background button-hover-background :active transform translateY(3px) - - // box-shadow 0 0 2px white, 0 -2px 5px rgba(0, 0, 0, 0.08) inset - // :active - // background-color black - // color white - // :focus - // color rgb(0, 0, 0) - // // box-shadow 0 0 6px alpha(mainColor, 20%) - // border 1px solid main-color -// select -// ui-element -// font-size 1rem -// padding 0.5em 1em +select + appearance none + -webkit-appearance none + -moz-appearance none label width 100% padding 0.5rem 0 text-align left -// input[type="submit"]:hover, -// button:hover -// cursor pointer -// text-decoration none - -// button[disabled] -// opacity 0.5 -// :hover -// cursor not-allowed \ No newline at end of file +textarea + padding 0.4em 0.8em + line-height 1.5em + height 10rem + transition none \ No newline at end of file diff --git a/styles/layout.scarlet b/styles/layout.scarlet index d1765c53..34fd2637 100644 --- a/styles/layout.scarlet +++ b/styles/layout.scarlet @@ -6,4 +6,9 @@ flex 1 overflow-x hidden overflow-y scroll - // will-change transform \ No newline at end of file + position relative + // will-change transform + +#columns + horizontal + height 100% \ No newline at end of file diff --git a/styles/light-button.scarlet b/styles/light-button.scarlet index 23308078..dbcc2bb8 100644 --- a/styles/light-button.scarlet +++ b/styles/light-button.scarlet @@ -10,5 +10,5 @@ font-size 0.9rem :hover - color white !important + color theme-white !important background-color link-hover-color \ No newline at end of file diff --git a/styles/list.scarlet b/styles/list.scarlet new file mode 100644 index 00000000..646b4bc2 --- /dev/null +++ b/styles/list.scarlet @@ -0,0 +1,4 @@ +li + list-style-type disc + list-style-position outside + margin-left 2rem \ No newline at end of file diff --git a/styles/loading.scarlet b/styles/loading.scarlet index 22b532b5..8169c95b 100644 --- a/styles/loading.scarlet +++ b/styles/loading.scarlet @@ -17,7 +17,7 @@ loading-anim-size = 24px .sk-cube width 33.3% height 33.3% - background-color main-color + background-color loading-anim-color opacity 0.7 border-radius 100% animation sk-pulse loading-anim-duration infinite linear diff --git a/styles/mountable.scarlet b/styles/mountable.scarlet index 6fb00d36..78becc67 100644 --- a/styles/mountable.scarlet +++ b/styles/mountable.scarlet @@ -1,5 +1,3 @@ -mountable-transition-speed = 400ms - .mountable opacity 0 transform translateY(0.85rem) diff --git a/styles/navigation.scarlet b/styles/navigation.scarlet index d22110d4..992962d5 100644 --- a/styles/navigation.scarlet +++ b/styles/navigation.scarlet @@ -1,6 +1,5 @@ #navigation horizontal - padding 0 content-padding overflow hidden background-color nav-color justify-content center @@ -43,57 +42,70 @@ display none #search - display none - border-radius 0 background transparent - border none - - color nav-link-hover-color + border none !important + box-shadow none !important font-size 1em - min-width 0 + padding 0 + width 0 + flex-grow 1 - ::placeholder - color nav-link-color - - :focus - border none - box-shadow none - -.extra-navigation - display none - -.extension-navigation - display none - -> 330px - .navigation-button, #search - font-size 1.3em - -> 930px - .navigation-button, #search - font-size 1.2em +// #search +// flex 1 +// border-radius 0 +// background transparent +// border none - #navigation - justify-content flex-start +// color nav-link-hover-color +// font-size 1em +// min-width 0 - #search - display block - flex 1 +// ::placeholder +// color nav-link-color + +// :focus +// border none +// box-shadow none + +// .extra-navigation +// display none + +// .extension-navigation +// display none + +// > 330px +// .navigation-button, #search +// font-size 1.3em + +// > 550px +// #navigation +// padding 0 content-padding + +// > 930px +// .navigation-button, #search +// font-size 1.2em - .extra-navigation - display block - -< 400px height - #navigation - vertical - height 100% - padding content-padding 0 +// #navigation +// justify-content flex-start - #container - horizontal +// .extra-navigation +// display block - .extra-navigation - display none +// @media screen and (max-device-height: 500px) +// #navigation +// vertical +// height 100% +// padding content-padding 0 + +// #container +// horizontal - #search - display none \ No newline at end of file +// .extra-navigation +// display block + +// #sidebar-toggle, +// .hide-landscape +// display none !important + +// #search +// display none \ No newline at end of file diff --git a/styles/status-message.scarlet b/styles/status-message.scarlet new file mode 100644 index 00000000..69b84660 --- /dev/null +++ b/styles/status-message.scarlet @@ -0,0 +1,25 @@ +#status-message + horizontal + position fixed + bottom 0 + left 0 + width 100% + padding calc(content-padding / 2) content-padding + pointer-events none + z-index 1000 + +#status-message-text + flex 1 + text-align center + +.status-message-action + color white !important + pointer-events auto !important + +.error-message + color white + background-color hsl(0, 75%, 50%) + +.info-message + color white + background tab-active-background \ No newline at end of file diff --git a/styles/table.scarlet b/styles/table.scarlet index 3ee0073b..51a79bc5 100644 --- a/styles/table.scarlet +++ b/styles/table.scarlet @@ -1,5 +1,7 @@ table width 100% + max-width table-width-normal + margin 0 auto tr border-bottom-width 1px @@ -19,5 +21,7 @@ th tbody tr + background-color transparent + :hover - background-color rgba(0, 0, 0, 0.015) \ No newline at end of file + background-color table-row-hover-background \ No newline at end of file diff --git a/styles/tabs.scarlet b/styles/tabs.scarlet new file mode 100644 index 00000000..63f496d8 --- /dev/null +++ b/styles/tabs.scarlet @@ -0,0 +1,56 @@ +.tab + color text-color + padding 0.5rem 1rem + background-color tab-background + border ui-border + border-left none + white-space nowrap + + :hover + color text-color + background-color tab-hover-background + text-shadow none + + :active + transform none + + &.active + background-color tab-active-background + color tab-active-color + text-shadow tab-active-text-shadow + + :first-child + border-left ui-border + border-top-left-radius ui-element-border-radius + border-bottom-left-radius ui-element-border-radius + + :last-child + border-top-right-radius ui-element-border-radius + border-bottom-right-radius ui-element-border-radius + + // color text-color !important + // :hover + // color white !important + // &.active + // color white !important + // background-color link-hover-color + +< 920px + .tab + .icon + margin-right 0 + + .tab-text + display none + +.tabs + horizontal + justify-content center + // margin-left calc(content-padding * -1) + // margin-top calc(content-padding * -1) + // margin-right calc(content-padding * -2) + margin-bottom content-padding + // background-color rgba(0, 0, 0, 0.02) + // justify-content flex-start !important + // margin-bottom 1rem + // margin-top -0.6rem \ No newline at end of file diff --git a/styles/tags.scarlet b/styles/tags.scarlet new file mode 100644 index 00000000..7ddebfea --- /dev/null +++ b/styles/tags.scarlet @@ -0,0 +1,26 @@ +mixin tag-dimensions + padding 0.4rem 0.8rem + margin 0.4rem + height 40px + +.tags + horizontal-wrap + +.tag + ui-element + tag-dimensions + margin-right 0 + +.tag-edit + border-right none + border-top-right-radius 0 + border-bottom-right-radius 0 + +.tag-remove + tag-dimensions + margin-left 0 + border-top-left-radius 0 + border-bottom-left-radius 0 + +.tag-add + tag-dimensions \ No newline at end of file diff --git a/styles/typography.scarlet b/styles/typography.scarlet index 16633b0f..2b546a2d 100644 --- a/styles/typography.scarlet +++ b/styles/typography.scarlet @@ -1,16 +1,42 @@ p, h1, h2, h3, h4, h5, h6 - margin 0.4rem 0 + margin typography-margin 0 :first-child - margin-top 0 + margin-top 0 !important :last-child - margin-bottom 0 + margin-bottom 0 !important -h2 +h1, h2 margin-top content-padding margin-bottom content-padding +strong + font-weight bold + +em + font-style italic + +hr + border none + border-bottom 1px solid text-color + opacity 0.1 + margin-bottom content-padding + p > img max-width 100% - border-radius 3px \ No newline at end of file + border-radius 3px + display inherit + margin 0 auto + +.furigana + opacity 0.25 + transition opacity transition-speed ease, transform transition-speed ease + transform translateY(0) + +.japanese + color text-color + :hover + .furigana + opacity 1 + transform translateY(-2px) \ No newline at end of file diff --git a/styles/user.scarlet b/styles/user.scarlet index 5a39867f..0991cb2d 100644 --- a/styles/user.scarlet +++ b/styles/user.scarlet @@ -3,6 +3,7 @@ width avatar-size height avatar-size border-radius 100% + box-shadow outline-shadow-medium object-fit cover default-transition :hover diff --git a/styles/video.scarlet b/styles/video.scarlet index b6070365..5db2e220 100644 --- a/styles/video.scarlet +++ b/styles/video.scarlet @@ -1,6 +1,16 @@ +video-padding = 56.25% + .video-container + position relative width 100% + height 0 + padding-bottom video-padding + border-radius ui-element-border-radius + overflow hidden .video + position absolute width 100% - height calc(100vh - nav-height) \ No newline at end of file + height 100% + left 0 + top 0 \ No newline at end of file diff --git a/styles/widgets.scarlet b/styles/widgets.scarlet index f14acd84..95de3cee 100644 --- a/styles/widgets.scarlet +++ b/styles/widgets.scarlet @@ -1,33 +1,58 @@ +// .widgets +// display grid +// grid-template-columns 1fr + +// > 810px +// .widgets +// grid-template-columns repeat(2, 1fr) +// grid-gap content-padding + +// > 1240px +// .widgets +// grid-template-columns repeat(3, 1fr) + +// > 1640px +// .widgets +// grid-template-columns repeat(4, 1fr) + .widgets horizontal-wrap - justify-content space-around + justify-content center .widget vertical - align-items center width 100% - padding 0.25rem - max-width 600px + margin calc(content-padding / 2) + overflow hidden -.widget-element +.widget-section + vertical + width 100% + +.widget-title + text-align left + padding-bottom 0.5rem + border-bottom 1px solid rgba(0, 0, 0, 0.1) + // We need !important here to overwrite the h3:first-child rule + margin 1rem 0 !important + +.widget-ui-element vertical-wrap ui-element - transition border transition-speed ease, background transition-speed ease, transform transition-speed ease, transform color ease + transition border transition-speed ease, background transition-speed ease, transform transition-speed ease, color transition-speed ease margin-bottom 1rem padding 0.5rem 1rem width 100% - max-width 700px + // max-width 700px -.widget-element-text +.widget-ui-element-text horizontal clip-long-text justify-content flex-start align-items center width 100% -.widget-input - vertical +.widget-form width 100% - -.widget-title - // \ No newline at end of file + max-width 650px + margin 0 auto \ No newline at end of file diff --git a/sw/index.d.ts b/sw/index.d.ts new file mode 100644 index 00000000..2278ea56 --- /dev/null +++ b/sw/index.d.ts @@ -0,0 +1,4 @@ +type NotificationEvent = any; +type InstallEvent = any; +type FetchEvent = any; +type PushEvent = any; \ No newline at end of file diff --git a/sw/service-worker.ts b/sw/service-worker.ts new file mode 100644 index 00000000..e87756cc --- /dev/null +++ b/sw/service-worker.ts @@ -0,0 +1,356 @@ +// pack:ignore + +const RELOADS = new Map>() +const ETAGS = new Map() +const CACHEREFRESH = new Map>() +const EXCLUDECACHE = new Set([ + // API requests + "/api/", + + // PayPal stuff + "/paypal/", + + // List imports + "/import/", + + // Infinite scrolling + "/from/", + + // Chrome extension + "chrome-extension", + + // Authorization paths /auth/ and /logout are not listed here because they are handled in a special way. +]) + +class MyCache { + version: string + + constructor(version: string) { + this.version = version + } + + store(request: RequestInfo, response: Response) { + return caches.open(this.version).then(cache => { + return cache.put(request, response) + }) + } +} + +class MyServiceWorker { + cache: MyCache + currentCSP: string + + constructor() { + this.cache = new MyCache("v-3") + this.currentCSP = "" + + self.addEventListener("install", (evt: InstallEvent) => evt.waitUntil(this.onInstall(evt))) + self.addEventListener("activate", (evt: any) => evt.waitUntil(this.onActivate(evt))) + self.addEventListener("fetch", (evt: FetchEvent) => evt.waitUntil(this.onRequest(evt))) + self.addEventListener("message", (evt: any) => evt.waitUntil(this.onMessage(evt))) + self.addEventListener("push", (evt: PushEvent) => evt.waitUntil(this.onPush(evt))) + self.addEventListener("pushsubscriptionchange", (evt: any) => evt.waitUntil(this.onPushSubscriptionChange(evt))) + self.addEventListener("notificationclick", (evt: NotificationEvent) => evt.waitUntil(this.onNotificationClick(evt))) + } + + onInstall(evt: InstallEvent) { + console.log("service worker install") + + return (self as any).skipWaiting().then(() => { + return this.installCache() + }) + } + + onActivate(evt: any) { + console.log("service worker activate") + + // Only keep current version of the cache and delete old caches + let cacheWhitelist = [this.cache.version] + + let deleteOldCache = caches.keys().then(keyList => { + return Promise.all(keyList.map(key => { + if(cacheWhitelist.indexOf(key) === -1) { + return caches.delete(key) + } + })) + }) + + // Immediate claim helps us gain control over a new client immediately + let immediateClaim = (self as any).clients.claim() + + return Promise.all([ + deleteOldCache, + immediateClaim + ]) + } + + onRequest(evt: FetchEvent) { + let request = evt.request as Request + + // If it's not a GET request, fetch it normally + if(request.method !== "GET") { + return evt.respondWith(fetch(request)) + } + + // Clear cache on authentication and fetch it normally + if(request.url.includes("/auth/") || request.url.includes("/logout")) { + return evt.respondWith(caches.delete(this.cache.version).then(() => fetch(request))) + } + + // Exclude certain URLs from being cached + for(let pattern of EXCLUDECACHE.keys()) { + if(request.url.includes(pattern)) { + return evt.respondWith(fetch(request)) + } + } + + // If the request included the header "X-CacheOnly", return a cache-only response. + // This is used in reloads to avoid generating a 2nd request after a cache refresh. + if(request.headers.get("X-CacheOnly") === "true") { + return evt.respondWith(this.fromCache(request)) + } + + // Start fetching the request + let refresh = fetch(request).then(response => { + let clone = response.clone() + + // Save the new version of the resource in the cache + let cacheRefresh = this.cache.store(request, clone) + + CACHEREFRESH.set(request.url, cacheRefresh) + + return response + }) + + // Save in map + RELOADS.set(request.url, refresh) + + // Forced reload + let servedETag = undefined + + let onResponse = response => { + servedETag = response.headers.get("ETag") + ETAGS.set(request.url, servedETag) + return response + } + + if(request.headers.get("X-Reload") === "true") { + return evt.respondWith(refresh.then(onResponse)) + } + + // Try to serve cache first and fall back to network response + let networkOrCache = this.fromCache(request).then(onResponse).catch(error => { + // console.log("Cache MISS:", request.url) + return refresh + }) + + return evt.respondWith(networkOrCache) + } + + onMessage(evt: any) { + let message = JSON.parse(evt.data) + + switch(message.type) { + case "loaded": + this.onDOMContentLoaded(evt, message.url) + break + } + } + + // onDOMContentLoaded is called when the client sent this service worker + // a message that the page has been loaded. + onDOMContentLoaded(evt: any, url: string) { + let refresh = RELOADS.get(url) + let servedETag = ETAGS.get(url) + + // If the user requests a sub-page we should prefetch the full page, too. + if(url.includes("/_/")) { + this.prefetchFullPage(url) + } + + if(!refresh || !servedETag) { + return Promise.resolve() + } + + return refresh.then((response: Response) => { + // When the actual network request was used by the client, response.bodyUsed is set. + // In that case the client is already up to date and we don"t need to tell the client to do a refresh. + if(response.bodyUsed) { + return + } + + // Get the ETag of the cached response we sent to the client earlier. + let eTag = response.headers.get("ETag") + + // Update ETag + ETAGS.set(url, eTag) + + // Get CSP + let oldCSP = this.currentCSP + let csp = response.headers.get("Content-Security-Policy") + + // If the CSP and therefore the sha-1 hash of the CSS changed, we need to do a reload. + if(csp != oldCSP) { + this.currentCSP = csp + + if(oldCSP !== "") { + return this.forceClientReloadPage(url, evt.source) + } + } + + // If the ETag changed, we need to do a reload. + if(eTag !== servedETag) { + return this.forceClientReloadContent(url, evt.source) + } + + // Do nothing + return Promise.resolve() + }) + } + + prefetchFullPage(url: string) { + let fullPage = new Request(url.replace("/_/", "/")) + + let fullPageRefresh = fetch(fullPage, { + credentials: "same-origin" + }).then(response => { + // Save the new version of the resource in the cache + let cacheRefresh = caches.open(this.cache.version).then(cache => { + return cache.put(fullPage, response) + }) + + CACHEREFRESH.set(fullPage.url, cacheRefresh) + return response + }) + + // Save in map + RELOADS.set(fullPage.url, fullPageRefresh) + } + + onPush(evt: PushEvent) { + var payload = evt.data ? evt.data.json() : {} + + return (self as any).registration.showNotification(payload.title, { + body: payload.message, + icon: payload.icon, + image: payload.image, + data: payload.link, + badge: "https://notify.moe/brand/64.png" + }) + } + + onPushSubscriptionChange(evt: any) { + return (self as any).registration.pushManager.subscribe(evt.oldSubscription.options) + .then(async subscription => { + console.log("send subscription to server...") + + let rawKey = subscription.getKey("p256dh") + let key = rawKey ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawKey))) : "" + + let rawSecret = subscription.getKey("auth") + let secret = rawSecret ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawSecret))) : "" + + let endpoint = subscription.endpoint + + let pushSubscription = { + endpoint, + p256dh: key, + auth: secret, + platform: navigator.platform, + userAgent: navigator.userAgent, + screen: { + width: window.screen.width, + height: window.screen.height + } + } + + let user = await fetch("/api/me").then(response => response.json()) + + return fetch("/api/pushsubscriptions/" + user.id + "/add", { + method: "POST", + credentials: "same-origin", + body: JSON.stringify(pushSubscription) + }) + }) + } + + onNotificationClick(evt: NotificationEvent) { + let notification = evt.notification + notification.close() + + return (self as any).clients.matchAll().then(function(clientList) { + // If we have a link, use that link to open a new window. + let url = notification.data + + if(url) { + return (self as any).clients.openWindow(url) + } + + // If there is at least one client, focus it. + if(clientList.length > 0) { + return clientList[0].focus() + } + + // Otherwise open a new window + return (self as any).clients.openWindow("https://notify.moe") + }) + } + + forceClientReloadContent(url: string, eventSource: any) { + let message = { + type: "new content", + url + } + + this.postMessageAfterPromise(message, CACHEREFRESH.get(url), eventSource) + } + + forceClientReloadPage(url: string, eventSource: any) { + let message = { + type: "reload page", + url + } + + this.postMessageAfterPromise(message, RELOADS.get(url.replace("/_/", "/")), eventSource) + } + + postMessageAfterPromise(message: any, promise: Promise, eventSource: any) { + if(!promise) { + console.log("forcing reload, cache refresh null") + return eventSource.postMessage(JSON.stringify(message)) + } + + return promise.then(() => { + console.log("forcing reload after cache refresh") + eventSource.postMessage(JSON.stringify(message)) + }) + } + + installCache() { + // TODO: Implement a solution that caches resources with credentials: "same-origin" + return Promise.resolve() + + // return caches.open(this.cache.version).then(cache => { + // return cache.addAll([ + // "./", + // "./scripts", + // "https://fonts.gstatic.com/s/ubuntu/v11/4iCs6KVjbNBYlgoKfw7z.ttf" + // ]) + // }) + } + + fromCache(request) { + return caches.open(this.cache.version).then(cache => { + return cache.match(request).then(matching => { + if(matching) { + // console.log("Cache HIT:", request.url) + return Promise.resolve(matching) + } + + return Promise.reject("no-match") + }) + }) + } +} + +const serviceWorker = new MyServiceWorker() diff --git a/tests.go b/tests.go index 36624929..ff45fb35 100644 --- a/tests.go +++ b/tests.go @@ -1,6 +1,6 @@ package main -var tests = map[string][]string{ +var routeTests = map[string][]string{ // User "/user/:nick": []string{ "/+Akyoto", @@ -14,12 +14,44 @@ var tests = map[string][]string{ "/+Akyoto/posts", }, + "/user/:nick/soundtracks": []string{ + "/+Akyoto/soundtracks", + }, + + "/user/:nick/followers": []string{ + "/+Akyoto/followers", + }, + + "/user/:nick/stats": []string{ + "/+Akyoto/stats", + }, + "/user/:nick/animelist": []string{ "/+Akyoto/animelist", }, - "/user/:nick/animelist/:id": []string{ - "/+Akyoto/animelist/7929", + "/user/:nick/animelist/anime/:id": []string{ + "/+Akyoto/animelist/anime/7929", + }, + + "/user/:nick/animelist/watching": []string{ + "/+Akyoto/animelist/watching", + }, + + "/user/:nick/animelist/completed": []string{ + "/+Akyoto/animelist/completed", + }, + + "/user/:nick/animelist/planned": []string{ + "/+Akyoto/animelist/planned", + }, + + "/user/:nick/animelist/hold": []string{ + "/+Akyoto/animelist/hold", + }, + + "/user/:nick/animelist/dropped": []string{ + "/+Akyoto/animelist/dropped", }, // Pages @@ -27,12 +59,24 @@ var tests = map[string][]string{ "/anime/1", }, - "/threads/:id": []string{ - "/threads/HJgS7c2K", + "/anime/:id/characters": []string{ + "/anime/1/characters", }, - "/posts/:id": []string{ - "/posts/B1RzshnK", + "/anime/:id/episodes": []string{ + "/anime/1/episodes", + }, + + "/anime/:id/tracks": []string{ + "/anime/1/tracks", + }, + + "/thread/:id": []string{ + "/thread/HJgS7c2K", + }, + + "/post/:id": []string{ + "/post/B1RzshnK", }, "/forum/:tag": []string{ @@ -43,6 +87,26 @@ var tests = map[string][]string{ "/search/Dragon Ball", }, + "/soundtrack/:id": []string{ + "/soundtrack/h0ac8sKkg", + }, + + "/soundtrack/:id/edit": []string{ + "/soundtrack/h0ac8sKkg/edit", + }, + + "/soundtracks/from/:index": []string{ + "/soundtracks/from/12", + }, + + "/character/:id": []string{ + "/character/6556", + }, + + "/compare/animelist/:nick-1/:nick-2": []string{ + "/compare/animelist/Akyoto/Scott", + }, + // API "/api/anime/:id": []string{ "/api/anime/1", @@ -60,14 +124,6 @@ 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", }, @@ -84,6 +140,10 @@ var tests = map[string][]string{ "/api/googletouser/106530160120373282283", }, + "/api/facebooktouser/:id": []string{ + "/api/facebooktouser/10207576239700188", + }, + "/api/nicktouser/:id": []string{ "/api/nicktouser/Akyoto", }, @@ -92,6 +152,42 @@ var tests = map[string][]string{ "/api/searchindex/Anime", }, + "/api/analytics/:id": []string{ + "/api/analytics/4J6qpK1ve", + }, + + "/api/soundtrack/:id": []string{ + "/api/soundtrack/h0ac8sKkg", + }, + + "/api/userfollows/:id": []string{ + "/api/userfollows/4J6qpK1ve", + }, + + "/api/anilisttoanime/:id": []string{ + "/api/anilisttoanime/527", + }, + + "/api/animecharacters/:id": []string{ + "/api/animecharacters/323", + }, + + "/api/animeepisodes/:id": []string{ + "/api/animeepisodes/323", + }, + + "/api/character/:id": []string{ + "/api/character/6556", + }, + + "/api/pushsubscriptions/:id": []string{ + "/api/pushsubscriptions/4J6qpK1ve", + }, + + "/api/myanimelisttoanime/:id": []string{ + "/api/myanimelisttoanime/527", + }, + // Images "/images/avatars/large/:file": []string{ "/images/avatars/large/4J6qpK1ve.webp", @@ -117,17 +213,53 @@ var tests = map[string][]string{ "/images/elements/no-avatar.svg", }, - // Disable - "/auth/google": nil, - "/auth/google/callback": nil, - "/user": nil, - "/settings": nil, - "/extension/embed": nil, -} + // Extra tests for higher coverage + "/_/+Akyoto": []string{ + "/_/+Akyoto", + }, -func init() { - // Specify test routes - for route, examples := range tests { - app.Test(route, examples) - } + "/_/search/dragon": []string{ + "/_/search/dragon", + }, + + // Disable these tests because they require authorization + "/auth/google": nil, + "/auth/google/callback": nil, + "/auth/facebook": nil, + "/auth/facebook/callback": nil, + "/dashboard": nil, + "/import": nil, + "/import/anilist/animelist": nil, + "/import/anilist/animelist/finish": nil, + "/import/myanimelist/animelist": nil, + "/import/myanimelist/animelist/finish": nil, + "/import/kitsu/animelist": nil, + "/import/kitsu/animelist/finish": nil, + "/api/test/notification": nil, + "/api/paypal/payment/create": nil, + "/api/userfollows/:id/get/:item": nil, + "/api/userfollows/:id/get/:item/:property": nil, + "/api/pushsubscriptions/:id/get/:item": nil, + "/api/pushsubscriptions/:id/get/:item/:property": nil, + "/paypal/success": nil, + "/paypal/cancel": nil, + "/anime/:id/edit": nil, + "/new/thread": nil, + "/admin/purchases": nil, + "/editor/anilist": nil, + "/editor/shoboi": nil, + "/dark-flame-master": nil, + "/user": nil, + "/settings": nil, + "/settings/accounts": nil, + "/settings/notifications": nil, + "/settings/apps": nil, + "/settings/avatar": nil, + "/settings/formatting": nil, + "/settings/pro": nil, + "/shop": nil, + "/shop/history": nil, + "/charge": nil, + "/inventory": nil, + "/extension/embed": nil, } diff --git a/utils/allowembed.go b/utils/AllowEmbed.go similarity index 64% rename from utils/allowembed.go rename to utils/AllowEmbed.go index 16761b8c..0beac966 100644 --- a/utils/allowembed.go +++ b/utils/AllowEmbed.go @@ -5,6 +5,6 @@ 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") + // ctx.SetResponseHeader("X-Frame-Options", "ALLOW-FROM chrome-extension://hjfcooigdelogjmniiahfiilcefdlpha/options.html") return response } diff --git a/utils/Comparison.go b/utils/Comparison.go new file mode 100644 index 00000000..4e3bd4a5 --- /dev/null +++ b/utils/Comparison.go @@ -0,0 +1,10 @@ +package utils + +import "github.com/animenotifier/arn" + +// Comparison of an anime between 2 users. +type Comparison struct { + Anime *arn.Anime + ItemA *arn.AnimeListItem + ItemB *arn.AnimeListItem +} diff --git a/utils/EmptyImage.go b/utils/EmptyImage.go new file mode 100644 index 00000000..d35ff481 --- /dev/null +++ b/utils/EmptyImage.go @@ -0,0 +1,7 @@ +package utils + +// EmptyImage returns the smallest possible 1x1 pixel image encoded in Base64. +func EmptyImage() string { + return "" + // return "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" +} diff --git a/utils/FormatRating.go b/utils/FormatRating.go new file mode 100644 index 00000000..8b5651d2 --- /dev/null +++ b/utils/FormatRating.go @@ -0,0 +1,8 @@ +package utils + +import "fmt" + +// FormatRating formats the rating number. +func FormatRating(rating float64) string { + return fmt.Sprintf("%.1f", rating) +} diff --git a/utils/container.go b/utils/GetContainerClass.go similarity index 100% rename from utils/container.go rename to utils/GetContainerClass.go diff --git a/utils/GetUser.go b/utils/GetUser.go new file mode 100644 index 00000000..74d39297 --- /dev/null +++ b/utils/GetUser.go @@ -0,0 +1,28 @@ +package utils + +import ( + "github.com/aerogo/aero" + "github.com/animenotifier/arn" +) + +// GetUser returns the logged in user for the given context. +func GetUser(ctx *aero.Context) *arn.User { + return arn.GetUserFromContext(ctx) +} + +// SameUser returns "true" or "false" depending on if the users are the same. +func SameUser(a *arn.User, b *arn.User) string { + if a == nil { + return "false" + } + + if b == nil { + return "false" + } + + if a.ID == b.ID { + return "true" + } + + return "false" +} diff --git a/utils/icons.go b/utils/Icon.go similarity index 73% rename from utils/icons.go rename to utils/Icon.go index f830531e..dfc6ea25 100644 --- a/utils/icons.go +++ b/utils/Icon.go @@ -11,9 +11,9 @@ func init() { files, _ := ioutil.ReadDir("images/icons/") for _, file := range files { - name := strings.Replace(file.Name(), ".svg", "", 1) + name := strings.TrimSuffix(file.Name(), ".svg") data, _ := ioutil.ReadFile("images/icons/" + name + ".svg") - svgIcons[name] = strings.Replace(string(data), " 0.5 { + largeArc = "1" + } + + return fmt.Sprintf("M %.3f %.3f A 1 1 0 %s 1 %.3f %.3f L 0 0", x1, y1, largeArc, x2, y2) +} diff --git a/utils/ToJSON.go b/utils/ToJSON.go new file mode 100644 index 00000000..614b4465 --- /dev/null +++ b/utils/ToJSON.go @@ -0,0 +1,9 @@ +package utils + +import "encoding/json" + +// ToJSON converts an object to a JSON string, ignoring errors. +func ToJSON(v interface{}) string { + str, _ := json.Marshal(v) + return string(str) +} diff --git a/utils/UserStats.go b/utils/UserStats.go new file mode 100644 index 00000000..d4e5c2d7 --- /dev/null +++ b/utils/UserStats.go @@ -0,0 +1,10 @@ +package utils + +import "time" +import "github.com/animenotifier/arn" + +// UserStats ... +type UserStats struct { + AnimeWatchingTime time.Duration + PieCharts []*arn.PieChart +} diff --git a/utils/editform/editform.go b/utils/editform/editform.go new file mode 100644 index 00000000..754292e4 --- /dev/null +++ b/utils/editform/editform.go @@ -0,0 +1,114 @@ +package editform + +import ( + "bytes" + "fmt" + "reflect" + "strconv" + "strings" + + "github.com/animenotifier/arn" + "github.com/animenotifier/notify.moe/components" + "github.com/animenotifier/notify.moe/utils" +) + +// Render ... +func Render(obj interface{}, title string, user *arn.User) string { + t := reflect.TypeOf(obj).Elem() + v := reflect.ValueOf(obj).Elem() + id := reflect.Indirect(v.FieldByName("ID")) + lowerCaseTypeName := strings.ToLower(t.Name()) + endpoint := `/api/` + lowerCaseTypeName + `/` + id.String() + + var b bytes.Buffer + + b.WriteString(`
`) + b.WriteString(`
`) + + b.WriteString(`

`) + b.WriteString(title) + b.WriteString(`

`) + + RenderObject(&b, obj, "") + + if user != nil { + b.WriteString(`
`) + b.WriteString(`
`) + + if user.Role == "editor" || user.Role == "admin" { + b.WriteString(``) + } + + b.WriteString(`
`) + } + + b.WriteString("
") + b.WriteString("
") + + return b.String() +} + +// RenderObject ... +func RenderObject(b *bytes.Buffer, obj interface{}, idPrefix string) { + t := reflect.TypeOf(obj).Elem() + v := reflect.ValueOf(obj).Elem() + + // Fields + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + RenderField(b, &v, field, idPrefix) + } +} + +// RenderField ... +func RenderField(b *bytes.Buffer, v *reflect.Value, field reflect.StructField, idPrefix string) { + if field.Anonymous || field.Tag.Get("editable") != "true" { + return + } + + fieldValue := reflect.Indirect(v.FieldByName(field.Name)) + + switch field.Type.String() { + case "string": + if field.Tag.Get("datalist") != "" { + dataList := field.Tag.Get("datalist") + values := arn.DataLists[dataList] + b.WriteString(components.InputSelection(idPrefix+field.Name, fieldValue.String(), field.Name, field.Tag.Get("tooltip"), values)) + } else if field.Tag.Get("type") == "textarea" { + b.WriteString(components.InputTextArea(idPrefix+field.Name, fieldValue.String(), field.Name, field.Tag.Get("tooltip"))) + } else { + b.WriteString(components.InputText(idPrefix+field.Name, fieldValue.String(), field.Name, field.Tag.Get("tooltip"))) + } + case "[]string": + b.WriteString(components.InputTags(idPrefix+field.Name, fieldValue.Interface().([]string), field.Name, field.Tag.Get("tooltip"))) + case "bool": + if field.Name == "IsDraft" { + return + } + case "[]*arn.ExternalMedia": + for sliceIndex := 0; sliceIndex < fieldValue.Len(); sliceIndex++ { + b.WriteString(`
`) + b.WriteString(`
` + strconv.Itoa(sliceIndex+1) + ". " + field.Name + `
`) + + arrayObj := fieldValue.Index(sliceIndex).Interface() + arrayIDPrefix := fmt.Sprintf("%s[%d].", field.Name, sliceIndex) + RenderObject(b, arrayObj, arrayIDPrefix) + + // Preview + b.WriteString(components.ExternalMedia(fieldValue.Index(sliceIndex).Interface().(*arn.ExternalMedia))) + + // Remove button + b.WriteString(`
`) + + b.WriteString(`
`) + } + + b.WriteString(`
`) + b.WriteString(``) + b.WriteString(`
`) + default: + panic("No edit form implementation for " + idPrefix + field.Name + " with type " + field.Type.String()) + } +} diff --git a/utils/user.go b/utils/user.go deleted file mode 100644 index 3e665f7e..00000000 --- a/utils/user.go +++ /dev/null @@ -1,11 +0,0 @@ -package utils - -import ( - "github.com/aerogo/aero" - "github.com/animenotifier/arn" -) - -// GetUser returns the logged in user for the given context. -func GetUser(ctx *aero.Context) *arn.User { - return arn.GetUserFromContext(ctx) -}