This commit is contained in:
depreciate 2019-02-26 01:26:31 +10:30
commit 164500d235
34 changed files with 2599 additions and 50 deletions

107
ccreate.go Normal file
View File

@ -0,0 +1,107 @@
package main
import (
"fmt"
"database/sql"
"strconv"
"strings"
"regexp"
"github.com/gin-gonic/gin"
)
func ccreate(c *gin.Context) {
ccreateResp(c)
}
func ccreateSubmit(c *gin.Context) {
if getContext(c).User.ID == 0 {
resp403(c)
return
}
// check registrations are enabled
if !ccreationEnabled() {
ccreateResp(c, errorMessage{T(c, "Sorry, it's not possible to create a clan at the moment. Please try again later.")})
return
}
// check username is valid by our criteria
username := strings.TrimSpace(c.PostForm("username"))
if !cnameRegex.MatchString(username) {
ccreateResp(c, errorMessage{T(c, "Your clans name must contain alphanumerical characters, spaces, or any of <code>_[]-</code>.")})
return
}
// check whether name already exists
if db.QueryRow("SELECT 1 FROM clans WHERE name = ?", c.PostForm("username")).
Scan(new(int)) != sql.ErrNoRows {
ccreateResp(c, errorMessage{T(c, "A clan with that name already exists!")})
return
}
// check whether tag already exists
if db.QueryRow("SELECT 1 FROM clans WHERE tag = ?", c.PostForm("tag")).
Scan(new(int)) != sql.ErrNoRows {
ccreateResp(c, errorMessage{T(c, "A clan with that tag already exists!")})
return
}
// recaptcha verify
tag := "0"
if c.PostForm("tag") != "" {
tag = c.PostForm("tag")
}
// The actual registration.
res, err := db.Exec(`INSERT INTO clans(name, description, icon, tag)
VALUES (?, ?, ?, ?);`,
username, c.PostForm("password"), c.PostForm("email"), tag)
if err != nil {
ccreateResp(c, errorMessage{T(c, "Whoops, an error slipped in. Clan might have been created, though. I don't know.")})
fmt.Println(err)
return
}
lid, _ := res.LastInsertId()
db.Exec("INSERT INTO `user_clans`(user, clan, perms) VALUES (?, ?, 8);", getContext(c).User.ID, lid)
addMessage(c, successMessage{T(c, "Clan created.")})
getSession(c).Save()
c.Redirect(302, "/c/"+strconv.Itoa(int(lid)))
}
func ccreateResp(c *gin.Context, messages ...message) {
resp(c, 200, "clans/create.html", &baseTemplateData{
TitleBar: "Create Clan",
KyutGrill: "register.jpg",
Scripts: []string{"https://www.google.com/recaptcha/api.js"},
Messages: messages,
FormData: normaliseURLValues(c.Request.PostForm),
})
}
func ccreationEnabled() bool {
var enabled bool
db.QueryRow("SELECT value_int FROM system_settings WHERE name = 'ccreation_enabled'").Scan(&enabled)
return enabled
}
// Check User In Query Is Same As User In Y Cookie
func ccin(s string, ss []string) bool {
for _, x := range ss {
if x == s {
return true
}
}
return false
}
var cnameRegex = regexp.MustCompile(`^[A-Za-z0-9 '_\[\]-]{2,15}$`)

302
clan.go Normal file
View File

@ -0,0 +1,302 @@
package main
import (
"database/sql"
"strconv"
"fmt"
"github.com/gin-gonic/gin"
"math/rand"
"time"
)
// TODO: replace with simple ResponseInfo containing userid
type clanData struct {
baseTemplateData
ClanID int
}
func leaveClan(c *gin.Context) {
i := c.Param("cid")
// login check
if getContext(c).User.ID == 0 {
resp403(c)
return
}
if db.QueryRow("SELECT 1 FROM user_clans WHERE user = ? AND clan = ? AND perms = 8", getContext(c).User.ID, i).
Scan(new(int)) == sql.ErrNoRows {
// check if a nigga the clan
if db.QueryRow("SELECT 1 FROM user_clans WHERE user = ? AND clan = ?", getContext(c).User.ID, i).
Scan(new(int)) == sql.ErrNoRows {
addMessage(c, errorMessage{T(c, "Unexpected Error...")})
return
}
// idk how the fuck this gonna work but fuck it
db.Exec("DELETE FROM user_clans WHERE user = ? AND clan = ?", getContext(c).User.ID, i)
addMessage(c, successMessage{T(c, "Left clan.")})
getSession(c).Save()
c.Redirect(302, "/c/"+i)
} else {
//check if user even in clan!!!
if db.QueryRow("SELECT 1 FROM user_clans WHERE user = ? AND clan = ?", getContext(c).User.ID, i).
Scan(new(int)) == sql.ErrNoRows {
addMessage(c, errorMessage{T(c, "Unexpected Error...")})
return
}
// delete invites
db.Exec("DELETE FROM clans_invites WHERE clan = ?", i)
// delete all members out of clan :c
db.Exec("DELETE FROM user_clans WHERE clan = ?", i)
// delete clan :c
db.Exec("DELETE FROM clans WHERE id = ?", i)
addMessage(c, successMessage{T(c, "Disbanded Clan.")})
getSession(c).Save()
c.Redirect(302, "/clans?mode=0")
}
}
func clanPage(c *gin.Context) {
var (
clanID int
clanName string
clanDescription string
clanIcon string
)
// ctx := getContext(c)
i := c.Param("cid")
if _, err := strconv.Atoi(i); err != nil {
err := db.QueryRow("SELECT id, name, description, icon FROM clans WHERE name = ? LIMIT 1", i).Scan(&clanID, &clanName, &clanDescription, &clanIcon)
if err != nil && err != sql.ErrNoRows {
c.Error(err)
}
} else {
err := db.QueryRow(`SELECT id, name, description, icon FROM clans WHERE id = ? LIMIT 1`, i).Scan(&clanID, &clanName, &clanDescription, &clanIcon)
switch {
case err == nil:
case err == sql.ErrNoRows:
err := db.QueryRow("SELECT id, name, description, icon FROM clans WHERE name = ? LIMIT 1", i).Scan(&clanID, &clanName, &clanDescription, &clanIcon)
if err != nil && err != sql.ErrNoRows {
c.Error(err)
}
default:
c.Error(err)
}
}
data := new(clanData)
data.ClanID = clanID
defer resp(c, 200, "clansample.html", data)
if data.ClanID == 0 {
data.TitleBar = "Clan not found"
data.Messages = append(data.Messages, warningMessage{T(c, "That clan could not be found.")})
return
}
if getContext(c).User.Privileges&1 > 0 {
if db.QueryRow("SELECT 1 FROM clans WHERE clan = ?", clanID).Scan(new(string)) != sql.ErrNoRows {
var bg string
db.QueryRow("SELECT background FROM clans WHERE id = ?", clanID).Scan(&bg)
data.KyutGrill = bg
data.KyutGrillAbsolute = true
}
}
data.TitleBar = T(c, "%s's Clan Page", clanName)
data.DisableHH = true
// data.Scripts = append(data.Scripts, "/static/profile.js")
}
func checkCount(rows *sql.Rows) (count int) {
for rows.Next() {
err:= rows.Scan(&count)
if err != nil {
panic(err)
}
}
return count
}
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
func randSeq(n int) string {
rand.Seed(time.Now().UnixNano()+int64(3))
b := make([]rune, n)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
func createInvite(c *gin.Context) {
ctx := getContext(c)
if string(c.PostForm("password")) == "" && string(c.PostForm("email")) == "" && string(c.PostForm("tag")) == "" && string(c.PostForm("bg")) == "" {
if ctx.User.ID == 0 {
resp403(c)
return
}
// big perms check lol ok
var perms int
db.QueryRow("SELECT perms FROM user_clans WHERE user = ? AND perms = 8 LIMIT 1", ctx.User.ID).Scan(&perms)
// delete old invite
var clan int
db.QueryRow("SELECT clan FROM user_clans WHERE user = ? AND perms = 8 LIMIT 1", ctx.User.ID).Scan(&clan)
if clan == 0 {
resp403(c)
return
}
db.Exec("DELETE FROM clans_invites WHERE clan = ?", clan)
var s string
s = randSeq(8)
db.Exec("INSERT INTO clans_invites(clan, invite) VALUES (?, ?)", clan, s)
} else {
// big perms check lol ok
var perms int
db.QueryRow("SELECT perms FROM user_clans WHERE user = ? AND perms = 8 LIMIT 1", ctx.User.ID).Scan(&perms)
// delete old invite
var clan int
db.QueryRow("SELECT clan FROM user_clans WHERE user = ? AND perms = 8 LIMIT 1", ctx.User.ID).Scan(&clan)
if clan == 0 {
resp403(c)
return
}
tag := "0"
if c.PostForm("tag") != "" {
tag = c.PostForm("tag")
}
if db.QueryRow("SELECT 1 FROM clans WHERE tag = ? AND id != ?", c.PostForm("tag"), clan).
Scan(new(int)) != sql.ErrNoRows {
resp403(c)
addMessage(c, errorMessage{T(c, "A clan with that tag already exists...")})
return
}
db.Exec("UPDATE clans SET description = ?, icon = ?, tag = ?, background = ? WHERE id = ?", c.PostForm("password"), c.PostForm("email"), tag, c.PostForm("bg"), clan)
}
addMessage(c, successMessage{T(c, "Success")})
getSession(c).Save()
c.Redirect(302, "/settings/clansettings")
}
func clanInvite(c *gin.Context) {
i := c.Param("inv")
res := resolveInvite(i)
s := strconv.Itoa(res)
if res != 0 {
// check if a nigga logged in
if getContext(c).User.ID == 0 {
resp403(c)
return
}
// restricted stuff
if getContext(c).User.Privileges & 1 != 1 {
resp403(c)
return
}
// check if clan even exists?
if db.QueryRow("SELECT 1 FROM clans WHERE id = ?", res).
Scan(new(int)) == sql.ErrNoRows {
addMessage(c, errorMessage{T(c, "Clan doesn't exist.")})
getSession(c).Save()
c.Redirect(302, "/c/"+s)
return
}
// check if a nigga in a clan already
if db.QueryRow("SELECT 1 FROM user_clans WHERE user = ?", getContext(c).User.ID).
Scan(new(int)) != sql.ErrNoRows {
addMessage(c, errorMessage{T(c, "Seems like you're already in a Clan")})
getSession(c).Save()
c.Redirect(302, "/c/"+s)
return
}
// idk how the fuck this gonna work but fuck it
var count int
var limit int
// members check
db.QueryRow("SELECT COUNT(*) FROM user_clans WHERE clan = ? ", res).Scan(&count)
db.QueryRow("SELECT mlimit FROM clans WHERE id = ? ", res).Scan(&limit)
if count >= limit {
addMessage(c, errorMessage{T(c, "Sorry, this clan is full.")})
getSession(c).Save()
c.Redirect(302, "/c/"+s)
return
}
// join
db.Exec("INSERT INTO `user_clans`(user, clan, perms) VALUES (?, ?, 1);", getContext(c).User.ID, res)
addMessage(c, successMessage{T(c, "Joined clan.")})
getSession(c).Save()
c.Redirect(302, "/c/"+s)
} else {
resp403(c)
addMessage(c, errorMessage{T(c, "nah nigga")})
}
}
func clanKick(c *gin.Context) {
if getContext(c).User.ID == 0 {
resp403(c)
return
}
if db.QueryRow("SELECT 1 FROM user_clans WHERE user = ? AND perms = 8", getContext(c).User.ID).
Scan(new(int)) == sql.ErrNoRows {
resp403(c)
return
}
member, err := strconv.ParseInt(c.PostForm("member"), 10, 32)
if err != nil {
fmt.Println(err)
}
if member == 0 {
resp403(c)
return
}
if db.QueryRow("SELECT 1 FROM user_clans WHERE user = ? AND perms = 1", member).
Scan(new(int)) == sql.ErrNoRows {
resp403(c)
return
}
db.Exec("DELETE FROM user_clans WHERE user = ?", member)
addMessage(c, successMessage{T(c, "Success.")})
getSession(c).Save()
c.Redirect(302, "/settings/clansettings")
}
func resolveInvite(c string)(int) {
var clanid int
row := db.QueryRow("SELECT clan FROM clans_invites WHERE invite = ?", c)
err := row.Scan(&clanid)
if err != nil {
fmt.Println(err)
}
fmt.Println(clanid)
return clanid
}

View File

@ -190,7 +190,7 @@ func blogRedirect(c *gin.Context) {
a := c.Param("url")
red := blogRedirectMap[a]
if red == "" {
red = "https://blog.ripple.moe"
red = "https://medium.com/@yozora"
}
c.Redirect(301, red)
}

14
main.go
View File

@ -278,7 +278,13 @@ func generateEngine() *gin.Engine {
r.GET("/register/verify", verifyAccount)
r.GET("/register/welcome", welcome)
r.GET("/clans/create", ccreate)
r.POST("/clans/create", ccreateSubmit)
r.GET("/c/:cid", clanPage)
r.GET("/u/:user", userProfile)
r.GET("/rx/u/:user", relaxProfile)
r.GET("/ap/u/:user", autoProfile)
r.GET("/b/:bid", beatmapInfo)
r.POST("/pwreset", passwordReset)
@ -303,6 +309,8 @@ func generateEngine() *gin.Engine {
r.POST("/settings/2fa/totp", totpSetup)
r.GET("/settings/discord/finish", discordFinish)
r.POST("/settings/profbackground/:type", profBackground)
r.POST("/settings/clansettings", createInvite)
r.POST("settings/clansettings/k", clanKick)
r.POST("/dev/tokens/create", createAPIToken)
r.POST("/dev/tokens/delete", deleteAPIToken)
@ -317,14 +325,12 @@ func generateEngine() *gin.Engine {
r.GET("/oauth/token", oauth.Token)
r.POST("/oauth/token", oauth.Token)
r.GET("/clans/invite/:inv", clanInvite)
r.GET("/donate/rates", btcconversions.GetRates)
r.Any("/blog/*url", blogRedirect)
r.GET("/help", func(c *gin.Context) {
c.Redirect(301, "https://support.ripple.moe")
})
loadSimplePages(r)
r.NoRoute(notFound)

View File

@ -73,3 +73,122 @@ func userProfile(c *gin.Context) {
data.DisableHH = true
data.Scripts = append(data.Scripts, "/static/profile.js")
}
func relaxProfile(c *gin.Context) {
var (
userID int
username string
privileges uint64
)
ctx := getContext(c)
u := c.Param("user")
if _, err := strconv.Atoi(u); err != nil {
err := db.QueryRow("SELECT id, username, privileges FROM users WHERE username = ? AND "+ctx.OnlyUserPublic()+" LIMIT 1", u).Scan(&userID, &username, &privileges)
if err != nil && err != sql.ErrNoRows {
c.Error(err)
}
} else {
err := db.QueryRow(`SELECT id, username, privileges FROM users WHERE id = ? AND `+ctx.OnlyUserPublic()+` LIMIT 1`, u).Scan(&userID, &username, &privileges)
switch {
case err == nil:
case err == sql.ErrNoRows:
err := db.QueryRow(`SELECT id, username, privileges FROM users WHERE username = ? AND `+ctx.OnlyUserPublic()+` LIMIT 1`, u).Scan(&userID, &username, &privileges)
if err != nil && err != sql.ErrNoRows {
c.Error(err)
}
default:
c.Error(err)
}
}
data := new(profileData)
data.UserID = userID
defer resp(c, 200, "profile_relax.html", data)
if data.UserID == 0 {
data.TitleBar = "User not found"
data.Messages = append(data.Messages, warningMessage{T(c, "That user could not be found.")})
return
}
if common.UserPrivileges(privileges)&common.UserPrivilegeDonor > 0 {
var profileBackground struct {
Type int
Value string
}
db.Get(&profileBackground, "SELECT type, value FROM profile_backgrounds WHERE uid = ?", data.UserID)
switch profileBackground.Type {
case 1:
data.KyutGrill = "/static/profbackgrounds/" + profileBackground.Value
data.KyutGrillAbsolute = true
case 2:
data.SolidColour = profileBackground.Value
}
}
data.TitleBar = T(c, "%s's profile", username)
data.DisableHH = true
data.Scripts = append(data.Scripts, "/static/profile_relax.js")
}
func autoProfile(c *gin.Context) {
var (
userID int
username string
privileges uint64
)
ctx := getContext(c)
u := c.Param("user")
if _, err := strconv.Atoi(u); err != nil {
err := db.QueryRow("SELECT id, username, privileges FROM users WHERE username = ? AND "+ctx.OnlyUserPublic()+" LIMIT 1", u).Scan(&userID, &username, &privileges)
if err != nil && err != sql.ErrNoRows {
c.Error(err)
}
} else {
err := db.QueryRow(`SELECT id, username, privileges FROM users WHERE id = ? AND `+ctx.OnlyUserPublic()+` LIMIT 1`, u).Scan(&userID, &username, &privileges)
switch {
case err == nil:
case err == sql.ErrNoRows:
err := db.QueryRow(`SELECT id, username, privileges FROM users WHERE username = ? AND `+ctx.OnlyUserPublic()+` LIMIT 1`, u).Scan(&userID, &username, &privileges)
if err != nil && err != sql.ErrNoRows {
c.Error(err)
}
default:
c.Error(err)
}
}
data := new(profileData)
data.UserID = userID
defer resp(c, 200, "profile_auto.html", data)
if data.UserID == 0 {
data.TitleBar = "User not found"
data.Messages = append(data.Messages, warningMessage{T(c, "That user could not be found.")})
return
}
if common.UserPrivileges(privileges)&common.UserPrivilegeDonor > 0 {
var profileBackground struct {
Type int
Value string
}
db.Get(&profileBackground, "SELECT type, value FROM profile_backgrounds WHERE uid = ?", data.UserID)
switch profileBackground.Type {
case 1:
data.KyutGrill = "/static/profbackgrounds/" + profileBackground.Value
data.KyutGrillAbsolute = true
case 2:
data.SolidColour = profileBackground.Value
}
}
data.TitleBar = T(c, "%s's profile", username)
data.DisableHH = true
data.Scripts = append(data.Scripts, "/static/profile_auto.js")
}

View File

@ -96,7 +96,7 @@ func registerSubmit(c *gin.Context) {
errr := db.QueryRow("SELECT id FROM beta_keys WHERE key_md5 = ? AND allowed = 1", cmd5(c.PostForm("key")))
if errr.Scan(new(int)) == sql.ErrNoRows {
registerResp(c, errorMessage{T(c, "key bad.")})
registerResp(c, errorMessage{T(c, "Your Invitation Code is invalid. Please use the form below to obtain one.")})
return
}

File diff suppressed because one or more lines are too long

2
static/dist.min.js vendored

File diff suppressed because one or more lines are too long

BIN
static/headers/beatmaps.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
!function(e){function r(r){for(var n,f,i=r[0],l=r[1],a=r[2],c=0,s=[];c<i.length;c++)f=i[c],o[f]&&s.push(o[f][0]),o[f]=0;for(n in l)Object.prototype.hasOwnProperty.call(l,n)&&(e[n]=l[n]);for(p&&p(r);s.length;)s.shift()();return u.push.apply(u,a||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,i=1;i<t.length;i++){var l=t[i];0!==o[l]&&(n=!1)}n&&(u.splice(r--,1),e=f(f.s=t[0]))}return e}var n={},o={2:0},u=[];function f(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,f),t.l=!0,t.exports}f.m=e,f.c=n,f.d=function(e,r,t){f.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},f.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},f.t=function(e,r){if(1&r&&(e=f(e)),8&r)return e;if(4&r&&"object"===typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(f.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)f.d(t,n,function(r){return e[r]}.bind(null,n));return t},f.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return f.d(r,"a",r),r},f.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},f.p="/";var i=window.webpackJsonp=window.webpackJsonp||[],l=i.push.bind(i);i.push=r,i=i.slice();for(var a=0;a<i.length;a++)r(i[a]);var p=l;t()}([]);
//# sourceMappingURL=runtime~main.229c360f.js.map

BIN
static/logos/circle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 975 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1005 B

442
static/profile_auto.js Normal file
View File

@ -0,0 +1,442 @@
// code that is executed on every user profile
$(document).ready(function() {
var wl = window.location;
var newPathName = wl.pathname;
// userID is defined in profile.html
if (newPathName.split("/")[2] != userID) {
newPathName = "/u/" + userID;
}
// if there's no mode parameter in the querystring, add it
if (wl.search.indexOf("mode=") === -1)
window.history.replaceState('', document.title, newPathName + "?mode=" + favouriteMode + wl.hash);
else if (wl.pathname != newPathName)
window.history.replaceState('', document.title, newPathName + wl.search + wl.hash);
setDefaultScoreTable();
// when an item in the mode menu is clicked, it means we should change the mode.
$("#mode-menu>.item").click(function(e) {
e.preventDefault();
if ($(this).hasClass("active"))
return;
var m = $(this).data("mode");
$("[data-mode]:not(.item):not([hidden])").attr("hidden", "");
$("[data-mode=" + m + "]:not(.item)").removeAttr("hidden");
$("#mode-menu>.active.item").removeClass("active");
var needsLoad = $("#scores-zone>[data-mode=" + m + "][data-loaded=0]");
if (needsLoad.length > 0)
initialiseScores(needsLoad, m);
$(this).addClass("active");
window.history.replaceState('', document.title, wl.pathname + "?mode=" + m + wl.hash);
});
initialiseAchievements();
initialiseFriends();
// load scores page for the current favourite mode
var i = function(){initialiseScores($("#scores-zone>div[data-mode=" + favouriteMode + "]"), favouriteMode)};
if (i18nLoaded)
i();
else
i18next.on("loaded", function() {
i();
});
});
function initialiseAchievements() {
api('users/achievements' + (currentUserID == userID ? '?all' : ''),
{id: userID}, function (resp) {
var achievements = resp.achievements;
// no achievements -- show default message
if (achievements.length === 0) {
$("#achievements")
.append($("<div class='ui sixteen wide column'>")
.text(T("Nothing here. Yet.")));
$("#load-more-achievements").remove();
return;
}
var displayAchievements = function(limit, achievedOnly) {
var $ach = $("#achievements").empty();
limit = limit < 0 ? achievements.length : limit;
var shown = 0;
for (var i = 0; i < achievements.length; i++) {
var ach = achievements[i];
if (shown >= limit || (achievedOnly && !ach.achieved)) {
continue;
}
shown++;
$ach.append(
$("<div class='ui two wide column'>").append(
$("<img src='https://s.ripple.moe/images/medals-" +
"client/" + ach.icon + ".png' alt='" + ach.name +
"' class='" +
(!ach.achieved ? "locked-achievement" : "achievement") +
"'>").popup({
title: ach.name,
content: ach.description,
position: "bottom center",
distanceAway: 10
})
)
);
}
// if we've shown nothing, and achievedOnly is enabled, try again
// this time disabling it.
if (shown == 0 && achievedOnly) {
displayAchievements(limit, false);
}
};
// only 8 achievements - we can remove the button completely, because
// it won't be used (no more achievements).
// otherwise, we simply remove the disabled class and add the click handler
// to activate it.
if (achievements.length <= 8) {
$("#load-more-achievements").remove();
} else {
$("#load-more-achievements")
.removeClass("disabled")
.click(function() {
$(this).remove();
displayAchievements(-1, false);
});
}
displayAchievements(8, true);
});
}
function initialiseFriends() {
var b = $("#add-friend-button");
if (b.length == 0) return;
api('friends/with', {id: userID}, setFriendOnResponse);
b.click(friendClick);
}
function setFriendOnResponse(r) {
var x = 0;
if (r.friend) x++;
if (r.mutual) x++;
setFriend(x);
}
function setFriend(i) {
var b = $("#add-friend-button");
b.removeClass("loading green blue red");
switch (i) {
case 0:
b
.addClass("blue")
.attr("title", T("Add friend"))
.html("<i class='plus icon'></i>");
break;
case 1:
b
.addClass("green")
.attr("title", T("Remove friend"))
.html("<i class='minus icon'></i>");
break;
case 2:
b
.addClass("red")
.attr("title", T("Unmutual friend"))
.html("<i class='heart icon'></i>");
break;
}
b.attr("data-friends", i > 0 ? 1 : 0)
}
function friendClick() {
var t = $(this);
if (t.hasClass("loading")) return;
t.addClass("loading");
api("friends/" + (t.attr("data-friends") == 1 ? "del" : "add"), {user: userID}, setFriendOnResponse, true);
}
var defaultScoreTable;
function setDefaultScoreTable() {
defaultScoreTable = $("<table class='ui table score-table' />")
.append(
$("<thead />").append(
$("<tr />").append(
$("<th>" + T("General info") + "</th>"),
$("<th>"+ T("Score") + "</th>")
)
)
)
.append(
$("<tbody />")
)
.append(
$("<tfoot />").append(
$("<tr />").append(
$("<th colspan=2 />").append(
$("<div class='ui right floated pagination menu' />").append(
$("<a class='disabled item load-more-button'>" + T("Load more") + "</a>").click(loadMoreClick)
)
)
)
)
)
;
}
i18next.on('loaded', function(loaded) {
setDefaultScoreTable();
});
function initialiseScores(el, mode) {
el.attr("data-loaded", "1");
var best = defaultScoreTable.clone(true).addClass("orange");
var recent = defaultScoreTable.clone(true).addClass("blue");
best.attr("data-type", "best");
recent.attr("data-type", "recent");
recent.addClass("no bottom margin");
el.append($("<div class='ui segments no bottom margin' />").append(
$("<div class='ui segment' />").append("<h2 class='ui header'>" + T("Best scores") + "</h2>", best),
$("<div class='ui segment' />").append("<h2 class='ui header'>" + T("Recent scores") + "</h2>", recent)
));
loadScoresPage("best", mode);
loadScoresPage("recent", mode);
};
function loadMoreClick() {
var t = $(this);
if (t.hasClass("disabled"))
return;
t.addClass("disabled");
var type = t.parents("table[data-type]").data("type");
var mode = t.parents("div[data-mode]").data("mode");
loadScoresPage(type, mode);
}
// currentPage for each mode
var currentPage = {
0: {best: 0, recent: 0},
1: {best: 0, recent: 0},
2: {best: 0, recent: 0},
3: {best: 0, recent: 0},
};
var scoreStore = {};
function loadScoresPage(type, mode) {
var table = $("#scores-zone div[data-mode=" + mode + "] table[data-type=" + type + "] tbody");
var page = ++currentPage[mode][type];
console.log("loadScoresPage with", {
page: page,
type: type,
mode: mode,
});
api("users/scores/ap/" + type, {
mode: mode,
p: page,
l: 20,
id: userID,
}, function(r) {
if (r.scores == null) {
disableLoadMoreButton(type, mode);
return;
}
r.scores.forEach(function(v, idx){
scoreStore[v.id] = v;
if (v.completed == 0){
var scoreRank = "failed";
}else{
var scoreRank = getRank(mode, v.mods, v.accuracy, v.count_300, v.count_100, v.count_50, v.count_miss);
}
table.append($("<tr class='new score-row' data-scoreid='" + v.id + "' />").append(
$(
"<td><img src='/static/ranking-icons/" + scoreRank + ".png' class='score rank' alt='" + scoreRank + "' height='16' width='16' > " +
escapeHTML(v.beatmap.song_name) + " <b>" + getScoreMods(v.mods) + "</b> <i>(" + v.accuracy.toFixed(2) + "%)</i><br />" +
"<div class='subtitle'><time class='new timeago' datetime='" + v.time + "'>" + v.time + "</time></div></td>"
),
$("<td><b>" + ppOrScore(v.pp, v.score) + "</b> " + weightedPP(type, page, idx, v.pp) + (v.completed == 3 ? "<br>" + downloadStar(v.id) : "") + "</td>")
));
});
$(".new.timeago").timeago().removeClass("new");
$(".new.score-row").click(viewScoreInfo).removeClass("new");
$(".new.downloadstar").click(function(e) {
e.stopPropagation();
}).removeClass("new");
var enable = true;
if (r.scores.length != 20)
enable = false;
disableLoadMoreButton(type, mode, enable);
});
}
function downloadStar(id) {
return "<a href='/web/replays_auto/" + id + "' class='new downloadstar'><i class='star icon'></i>" + T("Download") + "</a>";
}
function weightedPP(type, page, idx, pp) {
if (type != "best" || pp == 0)
return "";
var perc = Math.pow(0.95, ((page - 1) * 20) + idx);
var wpp = pp * perc;
return "<i title='Weighted PP, " + Math.round(perc*100) + "%'>(" + wpp.toFixed(2) + "pp)</i>";
}
function disableLoadMoreButton(type, mode, enable) {
var button = $("#scores-zone div[data-mode=" + mode + "] table[data-type=" + type + "] .load-more-button");
if (enable) button.removeClass("disabled");
else button.addClass("disabled");
}
function viewScoreInfo() {
var scoreid = $(this).data("scoreid");
if (!scoreid && scoreid !== 0) return;
var s = scoreStore[scoreid];
if (s === undefined) return;
// data to be displayed in the table.
var data = {
"Points": addCommas(s.score),
"PP": addCommas(s.pp),
"Beatmap": "<a href='/b/" + s.beatmap.beatmap_id + "'>" + escapeHTML(s.beatmap.song_name) + "</a>",
"Accuracy": s.accuracy + "%",
"Max combo": addCommas(s.max_combo) + "/" + addCommas(s.beatmap.max_combo)
+ (s.full_combo ? " " + T("(full combo)") : ""),
"Difficulty": T("{{ stars }} star", {
stars: s.beatmap.difficulty2[modesShort[s.play_mode]],
count: Math.round(s.beatmap.difficulty2[modesShort[s.play_mode]]),
}),
"Mods": getScoreMods(s.mods, true),
};
// hits data
var hd = {};
var trans = modeTranslations[s.play_mode];
[
s.count_300,
s.count_100,
s.count_50,
s.count_geki,
s.count_katu,
s.count_miss,
].forEach(function(val, i) {
hd[trans[i]] = val;
});
data = $.extend(data, hd, {
"Ranked?": T(s.completed == 3 ? "Yes" : "No"),
"Achieved": s.time,
"Mode": modes[s.play_mode],
});
var els = [];
$.each(data, function(key, value) {
els.push(
$("<tr />").append(
$("<td>" + T(key) + "</td>"),
$("<td>" + value + "</td>")
)
);
});
$("#score-data-table tr").remove();
$("#score-data-table").append(els);
$(".ui.modal").modal("show");
}
var modeTranslations = [
[
"300s",
"100s",
"50s",
"Gekis",
"Katus",
"Misses"
],
[
"GREATs",
"GOODs",
"50s",
"GREATs (Gekis)",
"GOODs (Katus)",
"Misses"
],
[
"Fruits (300s)",
"Ticks (100s)",
"Droplets",
"Gekis",
"Droplet misses",
"Misses"
],
[
"300s",
"200s",
"50s",
"Max 300s",
"100s",
"Misses"
]
];
function getRank(gameMode, mods, acc, c300, c100, c50, cmiss) {
var total = c300+c100+c50+cmiss;
// Hidden | Flashlight | FadeIn
var hdfl = (mods & (1049608)) > 0;
var ss = hdfl ? "SSHD" : "SS";
var s = hdfl ? "SHD" : "S";
switch(gameMode) {
case 0:
case 1:
var ratio300 = c300 / total;
var ratio50 = c50 / total;
if (ratio300 == 1)
return ss;
if (ratio300 > 0.9 && ratio50 <= 0.01 && cmiss == 0)
return s;
if ((ratio300 > 0.8 && cmiss == 0) || (ratio300 > 0.9))
return "A";
if ((ratio300 > 0.7 && cmiss == 0) || (ratio300 > 0.8))
return "B";
if (ratio300 > 0.6)
return "C";
return "D";
case 2:
if (acc == 100)
return ss;
if (acc > 98)
return s;
if (acc > 94)
return "A";
if (acc > 90)
return "B";
if (acc > 85)
return "C";
return "D";
case 3:
if (acc == 100)
return ss;
if (acc > 95)
return s;
if (acc > 90)
return "A";
if (acc > 80)
return "B";
if (acc > 70)
return "C";
return "D";
}
}
function ppOrScore(pp, score) {
if (pp != 0)
return addCommas(pp.toFixed(2)) + "pp";
return addCommas(score);
}
function beatmapLink(type, id) {
if (type == "s")
return "<a href='/s/" + id + "'>" + id + '</a>';
return "<a href='/b/" + id + "'>" + id + '</a>';
}

442
static/profile_relax.js Normal file
View File

@ -0,0 +1,442 @@
// code that is executed on every user profile
$(document).ready(function() {
var wl = window.location;
var newPathName = wl.pathname;
// userID is defined in profile.html
if (newPathName.split("/")[2] != userID) {
newPathName = "/u/" + userID;
}
// if there's no mode parameter in the querystring, add it
if (wl.search.indexOf("mode=") === -1)
window.history.replaceState('', document.title, newPathName + "?mode=" + favouriteMode + wl.hash);
else if (wl.pathname != newPathName)
window.history.replaceState('', document.title, newPathName + wl.search + wl.hash);
setDefaultScoreTable();
// when an item in the mode menu is clicked, it means we should change the mode.
$("#mode-menu>.item").click(function(e) {
e.preventDefault();
if ($(this).hasClass("active"))
return;
var m = $(this).data("mode");
$("[data-mode]:not(.item):not([hidden])").attr("hidden", "");
$("[data-mode=" + m + "]:not(.item)").removeAttr("hidden");
$("#mode-menu>.active.item").removeClass("active");
var needsLoad = $("#scores-zone>[data-mode=" + m + "][data-loaded=0]");
if (needsLoad.length > 0)
initialiseScores(needsLoad, m);
$(this).addClass("active");
window.history.replaceState('', document.title, wl.pathname + "?mode=" + m + wl.hash);
});
initialiseAchievements();
initialiseFriends();
// load scores page for the current favourite mode
var i = function(){initialiseScores($("#scores-zone>div[data-mode=" + favouriteMode + "]"), favouriteMode)};
if (i18nLoaded)
i();
else
i18next.on("loaded", function() {
i();
});
});
function initialiseAchievements() {
api('users/achievements' + (currentUserID == userID ? '?all' : ''),
{id: userID}, function (resp) {
var achievements = resp.achievements;
// no achievements -- show default message
if (achievements.length === 0) {
$("#achievements")
.append($("<div class='ui sixteen wide column'>")
.text(T("Nothing here. Yet.")));
$("#load-more-achievements").remove();
return;
}
var displayAchievements = function(limit, achievedOnly) {
var $ach = $("#achievements").empty();
limit = limit < 0 ? achievements.length : limit;
var shown = 0;
for (var i = 0; i < achievements.length; i++) {
var ach = achievements[i];
if (shown >= limit || (achievedOnly && !ach.achieved)) {
continue;
}
shown++;
$ach.append(
$("<div class='ui two wide column'>").append(
$("<img src='https://s.ripple.moe/images/medals-" +
"client/" + ach.icon + ".png' alt='" + ach.name +
"' class='" +
(!ach.achieved ? "locked-achievement" : "achievement") +
"'>").popup({
title: ach.name,
content: ach.description,
position: "bottom center",
distanceAway: 10
})
)
);
}
// if we've shown nothing, and achievedOnly is enabled, try again
// this time disabling it.
if (shown == 0 && achievedOnly) {
displayAchievements(limit, false);
}
};
// only 8 achievements - we can remove the button completely, because
// it won't be used (no more achievements).
// otherwise, we simply remove the disabled class and add the click handler
// to activate it.
if (achievements.length <= 8) {
$("#load-more-achievements").remove();
} else {
$("#load-more-achievements")
.removeClass("disabled")
.click(function() {
$(this).remove();
displayAchievements(-1, false);
});
}
displayAchievements(8, true);
});
}
function initialiseFriends() {
var b = $("#add-friend-button");
if (b.length == 0) return;
api('friends/with', {id: userID}, setFriendOnResponse);
b.click(friendClick);
}
function setFriendOnResponse(r) {
var x = 0;
if (r.friend) x++;
if (r.mutual) x++;
setFriend(x);
}
function setFriend(i) {
var b = $("#add-friend-button");
b.removeClass("loading green blue red");
switch (i) {
case 0:
b
.addClass("blue")
.attr("title", T("Add friend"))
.html("<i class='plus icon'></i>");
break;
case 1:
b
.addClass("green")
.attr("title", T("Remove friend"))
.html("<i class='minus icon'></i>");
break;
case 2:
b
.addClass("red")
.attr("title", T("Unmutual friend"))
.html("<i class='heart icon'></i>");
break;
}
b.attr("data-friends", i > 0 ? 1 : 0)
}
function friendClick() {
var t = $(this);
if (t.hasClass("loading")) return;
t.addClass("loading");
api("friends/" + (t.attr("data-friends") == 1 ? "del" : "add"), {user: userID}, setFriendOnResponse, true);
}
var defaultScoreTable;
function setDefaultScoreTable() {
defaultScoreTable = $("<table class='ui table score-table' />")
.append(
$("<thead />").append(
$("<tr />").append(
$("<th>" + T("General info") + "</th>"),
$("<th>"+ T("Score") + "</th>")
)
)
)
.append(
$("<tbody />")
)
.append(
$("<tfoot />").append(
$("<tr />").append(
$("<th colspan=2 />").append(
$("<div class='ui right floated pagination menu' />").append(
$("<a class='disabled item load-more-button'>" + T("Load more") + "</a>").click(loadMoreClick)
)
)
)
)
)
;
}
i18next.on('loaded', function(loaded) {
setDefaultScoreTable();
});
function initialiseScores(el, mode) {
el.attr("data-loaded", "1");
var best = defaultScoreTable.clone(true).addClass("orange");
var recent = defaultScoreTable.clone(true).addClass("blue");
best.attr("data-type", "best");
recent.attr("data-type", "recent");
recent.addClass("no bottom margin");
el.append($("<div class='ui segments no bottom margin' />").append(
$("<div class='ui segment' />").append("<h2 class='ui header'>" + T("Best scores") + "</h2>", best),
$("<div class='ui segment' />").append("<h2 class='ui header'>" + T("Recent scores") + "</h2>", recent)
));
loadScoresPage("best", mode);
loadScoresPage("recent", mode);
};
function loadMoreClick() {
var t = $(this);
if (t.hasClass("disabled"))
return;
t.addClass("disabled");
var type = t.parents("table[data-type]").data("type");
var mode = t.parents("div[data-mode]").data("mode");
loadScoresPage(type, mode);
}
// currentPage for each mode
var currentPage = {
0: {best: 0, recent: 0},
1: {best: 0, recent: 0},
2: {best: 0, recent: 0},
3: {best: 0, recent: 0},
};
var scoreStore = {};
function loadScoresPage(type, mode) {
var table = $("#scores-zone div[data-mode=" + mode + "] table[data-type=" + type + "] tbody");
var page = ++currentPage[mode][type];
console.log("loadScoresPage with", {
page: page,
type: type,
mode: mode,
});
api("users/scores/relax/" + type, {
mode: mode,
p: page,
l: 20,
id: userID,
}, function(r) {
if (r.scores == null) {
disableLoadMoreButton(type, mode);
return;
}
r.scores.forEach(function(v, idx){
scoreStore[v.id] = v;
if (v.completed == 0){
var scoreRank = "failed";
}else{
var scoreRank = getRank(mode, v.mods, v.accuracy, v.count_300, v.count_100, v.count_50, v.count_miss);
}
table.append($("<tr class='new score-row' data-scoreid='" + v.id + "' />").append(
$(
"<td><img src='/static/ranking-icons/" + scoreRank + ".png' class='score rank' alt='" + scoreRank + "' height='16' width='16' > " +
escapeHTML(v.beatmap.song_name) + " <b>" + getScoreMods(v.mods) + "</b> <i>(" + v.accuracy.toFixed(2) + "%)</i><br />" +
"<div class='subtitle'><time class='new timeago' datetime='" + v.time + "'>" + v.time + "</time></div></td>"
),
$("<td><b>" + ppOrScore(v.pp, v.score) + "</b> " + weightedPP(type, page, idx, v.pp) + (v.completed == 3 ? "<br>" + downloadStar(v.id) : "") + "</td>")
));
});
$(".new.timeago").timeago().removeClass("new");
$(".new.score-row").click(viewScoreInfo).removeClass("new");
$(".new.downloadstar").click(function(e) {
e.stopPropagation();
}).removeClass("new");
var enable = true;
if (r.scores.length != 20)
enable = false;
disableLoadMoreButton(type, mode, enable);
});
}
function downloadStar(id) {
return "<a href='/web/replays_relax/" + id + "' class='new downloadstar'><i class='star icon'></i>" + T("Download") + "</a>";
}
function weightedPP(type, page, idx, pp) {
if (type != "best" || pp == 0)
return "";
var perc = Math.pow(0.95, ((page - 1) * 20) + idx);
var wpp = pp * perc;
return "<i title='Weighted PP, " + Math.round(perc*100) + "%'>(" + wpp.toFixed(2) + "pp)</i>";
}
function disableLoadMoreButton(type, mode, enable) {
var button = $("#scores-zone div[data-mode=" + mode + "] table[data-type=" + type + "] .load-more-button");
if (enable) button.removeClass("disabled");
else button.addClass("disabled");
}
function viewScoreInfo() {
var scoreid = $(this).data("scoreid");
if (!scoreid && scoreid !== 0) return;
var s = scoreStore[scoreid];
if (s === undefined) return;
// data to be displayed in the table.
var data = {
"Points": addCommas(s.score),
"PP": addCommas(s.pp),
"Beatmap": "<a href='/b/" + s.beatmap.beatmap_id + "'>" + escapeHTML(s.beatmap.song_name) + "</a>",
"Accuracy": s.accuracy + "%",
"Max combo": addCommas(s.max_combo) + "/" + addCommas(s.beatmap.max_combo)
+ (s.full_combo ? " " + T("(full combo)") : ""),
"Difficulty": T("{{ stars }} star", {
stars: s.beatmap.difficulty2[modesShort[s.play_mode]],
count: Math.round(s.beatmap.difficulty2[modesShort[s.play_mode]]),
}),
"Mods": getScoreMods(s.mods, true),
};
// hits data
var hd = {};
var trans = modeTranslations[s.play_mode];
[
s.count_300,
s.count_100,
s.count_50,
s.count_geki,
s.count_katu,
s.count_miss,
].forEach(function(val, i) {
hd[trans[i]] = val;
});
data = $.extend(data, hd, {
"Ranked?": T(s.completed == 3 ? "Yes" : "No"),
"Achieved": s.time,
"Mode": modes[s.play_mode],
});
var els = [];
$.each(data, function(key, value) {
els.push(
$("<tr />").append(
$("<td>" + T(key) + "</td>"),
$("<td>" + value + "</td>")
)
);
});
$("#score-data-table tr").remove();
$("#score-data-table").append(els);
$(".ui.modal").modal("show");
}
var modeTranslations = [
[
"300s",
"100s",
"50s",
"Gekis",
"Katus",
"Misses"
],
[
"GREATs",
"GOODs",
"50s",
"GREATs (Gekis)",
"GOODs (Katus)",
"Misses"
],
[
"Fruits (300s)",
"Ticks (100s)",
"Droplets",
"Gekis",
"Droplet misses",
"Misses"
],
[
"300s",
"200s",
"50s",
"Max 300s",
"100s",
"Misses"
]
];
function getRank(gameMode, mods, acc, c300, c100, c50, cmiss) {
var total = c300+c100+c50+cmiss;
// Hidden | Flashlight | FadeIn
var hdfl = (mods & (1049608)) > 0;
var ss = hdfl ? "SSHD" : "SS";
var s = hdfl ? "SHD" : "S";
switch(gameMode) {
case 0:
case 1:
var ratio300 = c300 / total;
var ratio50 = c50 / total;
if (ratio300 == 1)
return ss;
if (ratio300 > 0.9 && ratio50 <= 0.01 && cmiss == 0)
return s;
if ((ratio300 > 0.8 && cmiss == 0) || (ratio300 > 0.9))
return "A";
if ((ratio300 > 0.7 && cmiss == 0) || (ratio300 > 0.8))
return "B";
if (ratio300 > 0.6)
return "C";
return "D";
case 2:
if (acc == 100)
return ss;
if (acc > 98)
return s;
if (acc > 94)
return "A";
if (acc > 90)
return "B";
if (acc > 85)
return "C";
return "D";
case 3:
if (acc == 100)
return ss;
if (acc > 95)
return s;
if (acc > 90)
return "A";
if (acc > 80)
return "B";
if (acc > 70)
return "C";
return "D";
}
}
function ppOrScore(pp, score) {
if (pp != 0)
return addCommas(pp.toFixed(2)) + "pp";
return addCommas(score);
}
function beatmapLink(type, id) {
if (type == "s")
return "<a href='/s/" + id + "'>" + id + '</a>';
return "<a href='/b/" + id + "'>" + id + '</a>';
}

View File

@ -466,4 +466,78 @@ img.locked-achievement:hover {
-webkit-filter: brightness(0.5) drop-shadow(0 5px 5px #777);
filter: brightness(0.5) drop-shadow(0 5px 5px #777);
}
.animated.flipOutX,
.animated.flipOutY,
.animated.bounceIn,
.animated.bounceOut {
-webkit-animation-duration: .75s;
animation-duration: .75s;
}
@-webkit-keyframes bounce {
from, 20%, 53%, 80%, to {
-webkit-animation-timing-function: cubic-bezier(0.215, 0.610, 0.355, 1.000);
animation-timing-function: cubic-bezier(0.215, 0.610, 0.355, 1.000);
-webkit-transform: translate3d(0,0,0);
transform: translate3d(0,0,0);
}
40%, 43% {
-webkit-animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060);
animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060);
-webkit-transform: translate3d(0, -30px, 0);
transform: translate3d(0, -30px, 0);
}
70% {
-webkit-animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060);
animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060);
-webkit-transform: translate3d(0, -15px, 0);
transform: translate3d(0, -15px, 0);
}
90% {
-webkit-transform: translate3d(0,-4px,0);
transform: translate3d(0,-4px,0);
}
}
@keyframes bounce {
from, 20%, 53%, 80%, to {
-webkit-animation-timing-function: cubic-bezier(0.215, 0.610, 0.355, 1.000);
animation-timing-function: cubic-bezier(0.215, 0.610, 0.355, 1.000);
-webkit-transform: translate3d(0,0,0);
transform: translate3d(0,0,0);
}
40%, 43% {
-webkit-animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060);
animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060);
-webkit-transform: translate3d(0, -30px, 0);
transform: translate3d(0, -30px, 0);
}
70% {
-webkit-animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060);
animation-timing-function: cubic-bezier(0.755, 0.050, 0.855, 0.060);
-webkit-transform: translate3d(0, -15px, 0);
transform: translate3d(0, -15px, 0);
}
90% {
-webkit-transform: translate3d(0,-4px,0);
transform: translate3d(0,-4px,0);
}
}
#bouncy {
-webkit-animation-duration: 1s;
animation-duration: 1s;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
-webkit-animation-iteration-count: infinite;
animation-iteration-count: infinite;
-webkit-animation-name: bounce;
animation-name: bounce;
-webkit-transform-origin: center bottom;
transform-origin: center bottom;
}

View File

@ -13,6 +13,7 @@
<link rel="stylesheet" type="text/css" href="/static/{{ if $ds }}dark/{{ end }}semantic.min.css?{{ unixNano }}">
<link rel="stylesheet" type="text/css" href="/static/ripple.css?{{ unixNano }}">
<link rel="stylesheet" type="text/css" href="/static/snow/snow.css?{{ unixNano }}">
<link href="/static/css/beatmaps.dfc480ea.chunk.css" rel="stylesheet">
</head>
<body {{ if $ds }} class="ds"{{ end }}>
{{/*

13
templates/beatmaps.html Normal file
View File

@ -0,0 +1,13 @@
{{/*###
Handler=/beatmaps
TitleBar=Beatmaps
KyutGrill=beatmaps.jpg
*/}}
{{ define "tpl" }}
<div class="ui container">
<div id="react-app"></div>
</div>
<script>!function (l) { function e(e) { for (var r, t, n = e[0], o = e[1], u = e[2], f = 0, i = []; f < n.length; f++)t = n[f], p[t] && i.push(p[t][0]), p[t] = 0; for (r in o) Object.prototype.hasOwnProperty.call(o, r) && (l[r] = o[r]); for (s && s(e); i.length;)i.shift()(); return c.push.apply(c, u || []), a() } function a() { for (var e, r = 0; r < c.length; r++) { for (var t = c[r], n = !0, o = 1; o < t.length; o++) { var u = t[o]; 0 !== p[u] && (n = !1) } n && (c.splice(r--, 1), e = f(f.s = t[0])) } return e } var t = {}, p = { 2: 0 }, c = []; function f(e) { if (t[e]) return t[e].exports; var r = t[e] = { i: e, l: !1, exports: {} }; return l[e].call(r.exports, r, r.exports, f), r.l = !0, r.exports } f.m = l, f.c = t, f.d = function (e, r, t) { f.o(e, r) || Object.defineProperty(e, r, { enumerable: !0, get: t }) }, f.r = function (e) { "undefined" != typeof Symbol && Symbol.toStringTag && Object.defineProperty(e, Symbol.toStringTag, { value: "Module" }), Object.defineProperty(e, "__esModule", { value: !0 }) }, f.t = function (r, e) { if (1 & e && (r = f(r)), 8 & e) return r; if (4 & e && "object" == typeof r && r && r.__esModule) return r; var t = Object.create(null); if (f.r(t), Object.defineProperty(t, "default", { enumerable: !0, value: r }), 2 & e && "string" != typeof r) for (var n in r) f.d(t, n, function (e) { return r[e] }.bind(null, n)); return t }, f.n = function (e) { var r = e && e.__esModule ? function () { return e.default } : function () { return e }; return f.d(r, "a", r), r }, f.o = function (e, r) { return Object.prototype.hasOwnProperty.call(e, r) }, f.p = "/"; var r = window.webpackJsonp = window.webpackJsonp || [], n = r.push.bind(r); r.push = e, r = r.slice(); for (var o = 0; o < r.length; o++)e(r[o]); var s = n; a() }([])</script>
<script src="/static/js/1.7e1ca608.chunk.js"></script>
<script src="/static/js/beatmaps.faace68d.chunk.js"></script>
{{ end }}

59
templates/clan_group.html Normal file
View File

@ -0,0 +1,59 @@
{{/*###
NoCompile=true
*/}}
{{ define "clanGroup" }}
{{ with . }}
<div class="ui one column center aligned stackable grid">
{{ $teamJSON := teamJSON }}
{{ range .members }}
{{/* ignore fokabot */}}
{{ if ne (int .id) 999 }}
{{ $tj := index $teamJSON (print .id)}}
<div class="column">
<div class="ui left aligned fluid card">
<div class="image">
<img src="{{ config "AvatarURL" }}/{{ .id }}" alt="Avatar">
</div>
<div class="content">
<a class="header" href="/u/{{ .id }}">{{ country .country false }}{{ .username }}</a>
{{ with $tj.real_name }}
<div class="meta">
<a>{{ . }}</a>
</div>
{{ end }}
{{ with $tj.role }}
<div class="description">
{{ . }}
</div>
{{ end }}
</div>
<div class="extra content">
<div title="Registered">
<i class="sign in icon"></i>
{{ time .registered_on }}
</div>
<div title="Latest activity">
<i class="sign out icon"></i>
{{ time .latest_activity }}
</div>
</div>
{{ if or $tj.twitter $tj.mail $tj.github }}
<div class="extra content">
<div class="center aligned">
{{ range $k, $v := $tj }}
{{ if and $v (in $k "github" "twitter" "mail") }}
<a href="{{ servicePrefix $k }}{{ $v }}" title="{{ capitalise $k }}">
<i class="{{ $k }} icon"></i>
</a>
{{ end }}
{{ end }}
</div>
</div>
{{ end }}
</div>
</div>
{{ end }}
{{ end }}
</div>
{{ end }}
{{ end }}

44
templates/clanboard.html Normal file
View File

@ -0,0 +1,44 @@
{{/*###
Handler=/clans
TitleBar=Clans
KyutGrill=leaderboard2.jpg
*/}}
{{ define "tpl" }}
<div class="ui container">
{{ $favMode := _or (atoi (.Gin.Query "mode")) }}
<script>
var favouriteMode = {{ $favMode }};
var page = {{ .Gin.Query "p" | atoint | atLeastOne }};
</script>
<div class="ui four item menu" id="mode-menu">
<a class="0 item" href="/clans?mode=0">osu!</a>
<a class="1 item" href="/clans?mode=1">osu!taiko</a>
<a class="2 item" href="/clans?mode=2">osu!catch</a>
<a class="3 item" href="/clans?mode=3">osu!mania</a>
</div>
<table class="ui fixed table">
<thead>
{{ template "simplepag" 4 }}
<tr>
<th class="four wide">{{ .T "Rank" }} </th>
<th class="four wide">{{ .T "Clan" }} </th>
<th class="four wide">{{ .T "PP/Score" }} </th>
<th class="four wide">{{ .T "Playcount" }} </th>
</tr>
</table>
<table class="ui fixed table">
<thead>
{{ range (.Get "clans/stats/all?m=$d" $favMode).clans }}
{{ if .name }}
<tr>
<th class="four wide">#{{ .rank }} </th>
<th class="four wide"> <a href="/c/{{ .id }}?mode={{ $favMode }}">{{ .name }}</a></th>
<th class="four wide">{{ .chosen_mode.pp }}pp ({{ humanize .chosen_mode.total_score }})</th>
<th class="four wide">{{ .chosen_mode.playcount }}</th>
</tr>
{{end}}
{{end}}
</thead>
</table>
</div>
{{ end }}

View File

@ -0,0 +1,48 @@
{{ define "tpl" }}
<div class="ui container">
{{ $regEnabled := qb "SELECT value_int FROM system_settings WHERE name = 'ccreation_enabled'" }}
{{ $isClan := qb "SELECT user, clan FROM user_clans WHERE user = ?" .Context.User.ID }}
{{ if not .Context.User.ID }}
<div class="ui segment">
{{ .T "You need to login!" }}
</div>
{{ else if not ($regEnabled.value_int.Bool) }}
<div class="ui error message">
{{ .T "Sorry, it's not possible to create a clan at the moment. Please try again later." }}
</div>
{{ else if ($isClan) }}
<div class="ui segment">
{{ .T "You're already in a Clan." }}
</div>
{{ else }}
<div class="tiny container">
<div class="ui raised segments">
<div class="ui segment">
<form id="register-form" class="ui form" method="post" action="/clans/create">
<div class="field">
<label>{{ .T "Name (2 to 15 characters, alphanumeric, spaces, <code>_[]-</code>)" | html }}</label>
<input tabindex="1" type="text" name="username" placeholder="{{ .T "Name" }}" value="{{ .FormData.username }}" required pattern="{2,15}$">
</div>
<div class="field">
<label class="left aligned">{{ "Clantag (2 to 6 characters)" }}</label>
<input tabindex="2" type="text" name="tag" placeholder="{{ "Clantag" }}" value="{{ .FormData.tag }}" pattern="{2,6}$">
</div>
<div class="field">
<label>{{ .T "Description (optional)" }}</label>
<input tabindex="3" type="text" name="password" placeholder="{{ .T "Description" }}" value="{{ .FormData.password }}">
</div>
<div class="field">
<label>{{ .T "Icon [URL] (optional)" }}</label>
<input tabindex="4" type="url" name="email" placeholder="{{ .T "Icon URL" }}" value="{{ .FormData.email }}">
</div>
{{ ieForm .Gin }}
</form>
</div>
<div class="ui right aligned segment">
<button tabindex="5" class="ui primary button" type="submit" form="register-form">{{ .T "Submit" }}</button>
</div>
</div>
</div>
{{ end }}
</div>
{{ end }}

128
templates/clansample.html Normal file
View File

@ -0,0 +1,128 @@
{{/*###
Include=members.html
*/}}
{{ define "tpl" }}
<div class="ui container">
{{ if .ClanID }}
{{ $gqm := .Gin.Query "mode" }}
{{ $global := . }}
{{ $favouritemode := $gqm }}
{{ range (.Get "clans?id=%d" .ClanID).clans }}
<div class="ui top attached segment overflow auto aligned">
<div class="magic table">
{{ if .icon }}
<div class="table element">
<img src="{{ .icon }}" alt="icon" class="clan icon" style="border-radius:5%; height:90px;">
</div>
{{ end }}
<div class="table element">
<h1 class="ui header">
{{ $global.T "%s" .name | html }}
</h1>
<div class="subtitle">
{{ $global.T "(%s)" (.tag | htmlescaper) | html }}
</div>
</div>
</div>
</div>
<div class="ui four item bottom attached menu" id="mode-menu">
<a class="mode item" href="/c/{{ .id }}?mode=0">osu!</a>
<a class="mode item" href="/c/{{ .id }}?mode=1">osu!taiko</a>
<a class="mode item" href="/c/{{ .id }}?mode=2">osu!catch</a>
<a class="mode item" href="/c/{{ .id }}?mode=3">osu!mania</a>
</div>
<div class="ui segment">
<div class="ui two column divided stackable grid">
<div class="row">
<div class="column">
{{ $global.T "%s" (.description | htmlescaper) | html }}
{{ end }}
<br><br><table class="ui very basic two column compact table nopad">
<tbody>
{{ with (.Get "clans/stats?id=%d&m=%d" .ClanID (.Gin.Query "mode"))}}
<td></td>
<tr>
<td><b>{{ $global.T "Global Rank" }}</b></td>
<td class="right aligned">#{{ humanize .rank }}</td>
</tr>
<tr>
<td><b>{{ $global.T "PP" }}</b></td>
<td class="right aligned">{{ humanize .chosen_mode.pp }}</td>
</tr>
<tr>
<td><b>{{ $global.T "Ranked Score" }}</b></td>
<td class="right aligned">{{ humanize .chosen_mode.ranked_score }}</td>
</tr>
<tr>
<td><b>{{ $global.T "Total Score" }}</b></td>
<td class="right aligned">{{ humanize .chosen_mode.total_score }}</td>
</tr>
<tr>
<td><b>{{ $global.T "Total Playcount" }}</b></td>
<td class="right aligned">{{ humanize .chosen_mode.playcount }}</td>
</tr>
<tr>
<td><b>{{ $global.T "Total Replays Watched" }}</b></td>
<td class="right aligned">{{ humanize .chosen_mode.replays_watched }}</td>
</tr>
<tr>
<td><b>{{ $global.T "Total Hits" }}</b></td>
<td class="right aligned">{{ humanize .chosen_mode.total_hits }}</td>
</tr>
{{end}}
{{ range (.Get "clans?id=%d" .ClanID).clans }}
</tbody>
</table>{{ end }}
</div>
<div class="column">
{{ if $global.Context.User.ID }}
{{ $d := qb "SELECT user, clan, perms FROM user_clans WHERE user = ? LIMIT 1" .Context.User.ID }}
{{ $p := qb "SELECT user, clan, perms FROM user_clans WHERE user = ? AND perms = 8 LIMIT 1" .Context.User.ID }}
{{ $tc := qb "SELECT user, clan, perms FROM user_clans WHERE user = ? AND clan = ? LIMIT 1" .Context.User.ID .ClanID }}
{{ $uc := or $d.clan.Int -1 }}
{{ if $d }}
{{ if $tc }}
{{ if $p }}
<form id="register-form" class="ui form" method="post" action="/c/{{ .ClanID }}">
{{ ieForm .Gin }}
</form>
<div class="ui one item menu"><button tabindex="1" class="ui primary button" style="background-color:rgba(255,0,0,.5)" type="submit" form="register-form">{{ .T "Disband Clan" }}</button></div>
{{ else }}
<form id="register-form" class="ui form" method="post" action="/c/{{ .ClanID }}">
{{ ieForm .Gin }}
</form>
<div class="ui one item menu"><button tabindex="1" class="ui primary button" style="background-color:rgba(255,0,0,.3)" type="submit" form="register-form">{{ .T "Leave Clan" }}</button></div>
{{ end }}
{{ else }}
<div class="ui one item menu" id="join-menu"><a class="item" style="background-color:rgba(255,0,0,.3)">Already joined a clan</a></div>
{{end}}
{{ else }}
{{ end }}
{{ else }}
<div class="ui one item menu" id="join-menu"><a class="item" href="/login" style="background-color:rgba(0,128,255,.3)">Please login to join a clan</a></div>
{{ end }}
<h1 class="ui heading">{{ .T "Clan Owner" }}</h1>
<p>
{{ .T "The leader of the clan." }}<br>
</p>
{{ template "clanMembers" (.Get "clans/members?id=%d&r=%d" .ClanID 8) }}
</div>
</div>
</div>
<div class="ui aligned segment">
<h1 class="ui heading">{{ .T "Members" }}</h1>
<p>
{{ .T "The members of the clan." }}<br>
</p>
{{ template "clanMembers" (.Get "clans/members?id=%d&r=%d" .ClanID 1) }}
</div>
</div>
{{ end }}
</div>
{{ end }}

25
templates/help.html Normal file
View File

@ -0,0 +1,25 @@
{{/*###
Handler=/help
TitleBar=Contact support
KyutGrill=help.jpg
*/}}
{{ define "tpl" }}
<div class="ui container">
<div class="ui segments">
<div class="ui segment">
<p>
{{ .T "If you need to get in touch with our support team (our Community Managers), you will need to either send an email to our support email address, or join our <a href='https://discord.gg/mT2EsD8'>Discord Server</a>. You can compose an email to it by clicking the button at the bottom of this page." | html }}
</p>
<p>
{{ .T "When contacting the support mail, please make sure to <b>send the email from the email address you signed up on Akatsuki with</b>." | html }}
</p>
</div>
<div class="ui right aligned segment">
<a class="ui right labeled blue icon button" href="mailto:support@yozora.pw">
<i class="right arrow icon"></i>
{{ .T "Do be warned though, our email is rarely checked as we handle most support via the Discord server." }}
</a>
</div>
</div>
</div>
{{ end }}

41
templates/members.html Normal file
View File

@ -0,0 +1,41 @@
{{/*###
NoCompile=true
*/}}
{{ define "clanMembers" }}
{{ with . }}
<div class="ui three column center aligned stackable grid">
{{ $teamJSON := teamJSON }}
{{ range .members }}
{{/* ignore fokabot */}}
{{ if ne (int .id) 999 }}
{{ $tj := index $teamJSON (print .id)}}
<div class="column">
<div class="ui left aligned fluid card">
<div class="image">
<img src="{{ config "AvatarURL" }}/{{ .id }}" alt="Avatar">
</div>
<div class="content">
<a class="header" href="/u/{{ .id }}">{{ country .country false }}{{ .username }}</a>
{{ with $tj.real_name }}
<div class="meta">
<a>{{ . }}</a>
</div>
{{ end }}
</div>
<div class="extra content">
<div title="Registered">
<i class="sign in icon"></i>
{{ time .registered_on }}
</div>
<div title="Latest activity">
<i class="sign out icon"></i>
{{ time .latest_activity }}
</div>
</div>
</div>
</div>
{{ end }}
{{ end }}
</div>
{{ end }}
{{ end }}

View File

@ -37,12 +37,26 @@
<div class="ui dropdown item">
<span>{{ .T "Beatmaps" }}</span>
<div class="menu">
{{ navbarItem .Path (.T "Listing") "/beatmaps" }}
{{ navbarItem .Path (.T "Request beatmap ranking") "/beatmaps/rank_request" }}
</div>
</div>
<div class="ui dropdown item">
<span>{{ .T "Clans" }}</span>
<div class="menu">
{{ navbarItem .Path (.T "Create Clan") "/clans/create" }}
</div>
</div>
{{ else }}
<div class="ui dropdown item">
<span>{{ .T "Beatmaps" }}</span>
<div class="menu">
{{ navbarItem .Path (.T "Listing") "/beatmaps" }}
</div>
</div>
{{ end }}
{{ navbarItem .Path (.T "Donate" | printf "<i class=\"red heart icon\"></i>%s") "/donate" }}
{{ if $isAdmin }}{{ navbarItem .Path (.T "RAP" | printf "<b>%s</b>") "https://old.yozora.pw/p/100" }}{{ end }}
{{ if $isAdmin }}{{ navbarItem .Path (.T "Admin" | printf "<b>%s</b>") "https://old.yozora.pw/p/100" }}{{ end }}
{{ end }}
<div class="firetrucking-right-menu">
<div class="item">

View File

@ -62,15 +62,27 @@
</div>
<div class="table element">
<h1 class="ui header">
{{ $user := . }}
{{ if $super }}
<div class="owner">
{{ .username }}
{{ if $user.clan.tag}}
<td class="right aligned"><a href="/c/{{ $user.clan.id }}?mode=0">[{{ $user.clan.tag }}]</a></td>
{{ end }}
{{ .username }}
</div>
{{else if $donor}}
<div class="dev">
{{ if $user.clan.tag}}
<td class="right aligned"><a href="/c/{{ $user.clan.id }}?mode=0">[{{ $user.clan.tag }}]</a></td>
{{ end }}
{{ .username }}
</div>
{{ else }}
{{ if $user.clan.tag}}
<td class="right aligned"><a href="/c/{{ $user.clan.id }}?mode=0">[{{ $user.clan.tag }}]</a></td>
{{ end }}
{{ .username }}
{{ end }}
</h1>
@ -115,6 +127,11 @@
</div>
</div>
{{ $user := . }}
<div class="ui three item bottom attached menu" id="rx-menu">
<a class="0 item" data-rx="0" href="/u/{{ $user.id }}">Vanilla</a>
<a class="1 item" data-rx="1" href="/rx/u/{{ $user.id }}">Relax</a>
<a class="1 item" data-rx="2" href="/ap/u/{{ $user.id }}">AutoPilot</a>
</div>
<div class="ui four item bottom attached menu" id="mode-menu">
{{ range $k, $v := modes }}
<a class="{{ favMode $favouritemode $k }}item" data-mode="{{ $k }}" href="/u/{{ $user.id }}?mode={{ $k }}">{{ $v }}</a>

280
templates/profile_auto.html Normal file
View File

@ -0,0 +1,280 @@
{{ define "tpl" }}
<div class="ui container">
{{ if .UserID }}
{{ $gqm := atoi (.Gin.Query "mode") }}
{{ $global := . }}
{{ with (.Get "users/ap/full?id=%d" .UserID) }}
{{ $favouritemode := _or $gqm .favourite_mode }}
<script>
window.favouriteMode = {{ $favouritemode }};
window.userID = {{ .id }};
</script>
{{ if after .silence_info.end }}
<div class="ui error centered message">{{ $global.T "User is <b>silenced</b> for %s, expires %s." (.silence_info.reason | htmlescaper) (time .silence_info.end) | html }}</div>
{{ end }}
{{ $sarah := has .id 1193 }}
{{ $alicia := has .id 1000 }}
{{ $catherine := has .id 999 }}
{{ $super := has .privileges 7340031 }}
{{ $dev := has .privileges 11534335 }}
{{ $donor := has .privileges 7 }}
{{ $admin := has .privileges 3049983 }}
{{ $chatmod := has .privileges 786763 }}
{{ $bn := has .privileges 267 }}
{{ if hasAdmin $global.Context.User.Privileges }}
{{ $restr := not (has .privileges 1) }}
{{ $disab := not (has .privileges 2) }}
{{ $pend := has .privileges 1048576 }}
{{ if and $disab $restr }}
{{ if $pend }}
<div class="ui warning centered message">{{ $global.T "User is <b>%s</b>" "pending verification" | html }}.</div>
{{ else }}
<div class="ui error centered message">{{ $global.T "User is <b>%s</b>" "banned" | html }}.</div>
{{ end }}
{{ else if $restr }}
<div class="ui error centered message">{{ $global.T "User is <b>%s</b>" "restricted" | html }}.</div>
{{ else if $disab }}
<div class="ui error centered message">{{ $global.T "User is <b>%s</b>" "locked" | html }}.</div>
{{ end }}
{{ end }}
{{ with $global.Get "users/userpage?id=%.0f" .id }}
{{ if .userpage }}
{{ with parseUserpage .userpage }}
<div class="ui raised segment twemoji" id="userpage-content">
{{ html . }}
</div>
{{ end }}
{{ end }}
{{ end }}
<div class="ui top attached segment overflow auto">
<div class="magic table">
<div class="table element">
{{ if eq $global.UserID $global.Context.User.ID }}
<a href="/settings/avatar">
{{ end }}
<img src="{{ config "AvatarURL" }}/{{ .id }}" alt="avatar" class="user avatar">
{{ if eq $global.UserID $global.Context.User.ID }}
</a>
{{ end }}
</div>
<div class="table element">
<h1 class="ui header">
{{ $user := . }}
{{ if $super }}
<div class="owner">
{{ if $user.clan.tag }}
<td class="right aligned"><a href="/c/{{ $user.clan.id }}?mode=0">[{{ $user.clan.tag }}]</a></td>
{{ end }}
{{ .username }}
</div>
{{else if $donor}}
<div class="dev">
{{ if $user.clan.tag}}
<td class="right aligned"><a href="/c/{{ $user.clan.id }}?mode=0">[{{ $user.clan.tag }}]</a></td>
{{ end }}
{{ .username }}
</div>
{{ else }}
{{ if $user.clan.tag}}
<td class="right aligned"><a href="/c/{{ $user.clan.id }}?mode=0">[{{ $user.clan.tag }}]</a></td>
{{ end }}
{{ .username }}
{{ end }}
</h1>
{{ if .username_aka }}
<div class="subtitle">
{{ $global.T "(aka <b>%s</b>)" (.username_aka | htmlescaper) | html }}
</div>
{{ end }}
{{ with bget "isOnline?id=%.0f" .id }}
<div class="subtitle">
<i class="{{ if .result }}green{{ else }}grey{{ end }} circle icon"></i>
{{ if .result }}{{ $global.T "Online" }}{{ else }}{{ $global.T "Offline" }}{{ end }}
</div>
{{ end }}
</div>
</div>
<div class="magic table floating right">
<div class="table element">
{{ range $k, $v := (slice .std .taiko .ctb .mania) }}
<h1 data-mode="{{ $k }}"{{ if ne $k (int $favouritemode) }} hidden{{ end }}>{{ with and $v $v.global_leaderboard_rank }}#{{ . }}{{ else }}{{ $global.T "Unknown" }}{{ end }}</h1>
{{ end }}
<div id="profile-actions">
{{ if and (ne $global.Context.User.ID $global.UserID) (ne $global.Context.User.ID 0) }}
<button class="ui circular mini icon loading button" id="add-friend-button">
<i class="horizontal ellipsis icon"></i>
</button>
{{ end }}
{{ if eq $global.Context.User.ID $global.UserID }}
<a href="/settings" class="ui circular mini teal icon button"
title="{{ $global.T "Settings" }}">
<i class="edit icon"></i>
</a>
{{ end }}
{{ if hasAdmin $global.Context.User.Privileges }}
<a href="https://old.yozora.pw/index.php?p=103&id={{ $global.UserID }}"
target="_blank" title="Quick edit" class="ui circular mini blue icon button">
<i class="folder open outline icon"></i>
</a>
{{ end }}
</div>
</div>
</div>
</div>
{{ $user := . }}
<div class="ui three item bottom attached menu" id="rx-menu">
<a class="0 item" data-rx="0" href="/u/{{ $user.id }}">Vanilla</a>
<a class="1 item" data-rx="1" href="/rx/u/{{ $user.id }}">Relax</a>
<a class="1 item" data-rx="2" href="/ap/u/{{ $user.id }}">AutoPilot</a>
</div>
<div class="ui four item bottom attached menu" id="mode-menu">
{{ range $k, $v := modes }}
<a class="{{ favMode $favouritemode $k }}item" data-mode="{{ $k }}" href="/u/{{ $user.id }}?mode={{ $k }}">{{ $v }}</a>
{{ end }}
</div>
<div class="ui segment">
<div class="ui three column divided stackable grid">
<div class="row">
<div class="column">
{{if $super }}
{{ $global.T "<b >%s</b> " .username | html }}
is an <i class="pink code small icon"></i><b>Owner</b>
{{ $global.T " from <b>%s</b>." (country .country true) | html }}
{{else if $dev}}
{{ $global.T "<b>%s</b> " .username | html }}
is a <i class="blue code small icon"></i><b>Developer</b>
{{ $global.T " from <b>%s</b>." (country .country true) | html }}
{{else if $admin}}
{{ $global.T "<b>%s</b> " .username | html }}
is an <i class="red lightning small icon"></i><b>Administrator</b>
{{ $global.T " from <b>%s</b>." (country .country true) | html }}
{{else if $chatmod}}
{{ $global.T "<b>%s</b> " .username | html }}
is a <i class="blue star small icon"></i><b>Chat Mod</b>
{{ $global.T " from <b>%s</b>." (country .country true) | html }}
{{else if $bn}}
{{ $global.T "<b>%s</b> " .username | html }}
is a <i class="orange universal access small icon"></i><b>Nominator</b>
{{ $global.T " from <b>%s</b>." (country .country true) | html }}
{{else if $donor }}
{{ $global.T "<b>%s</b> " .username | html }}
is a <i class="pink heart small icon"></i><b>Supporter</b>
{{ $global.T " from <b>%s</b>." (country .country true) | html }}
{{ else }}
{{ $global.T "<b>%s</b> is a player from <b>%s</b>." .username (country .country true) | html }}
{{ end }}
<br>{{ $global.T "They signed up on Yozora %s." (time $user.registered_on) | html }}
<br>{{ $global.T "Last seen: %s." (time $user.latest_activity) | html }}
<br>{{ with playstyle .play_style $global }}{{ $global.T "They play with %s." . }}{{ end }}
</div>
<div class="column">
{{ if and (not .badges) (not .custom_badge) }}
{{ $global.T "This user hasn't got any badges!" }}
{{ else }}
<div class="ui grid">
{{ range .badges }}
<div class="eight wide centered column">
<i class="circular {{ faIcon .icon }} big icon"></i><br>
<b>{{ .name }}</b>
</div>
{{ end }}
{{ with .custom_badge }}
<div class="eight wide centered column">
<i class="circular {{ faIcon .icon }} big icon"></i><br>
<b><i>{{ .name }}</i></b>
</div>
{{ end }}
</div>
{{ end }}
</div>
<div class="column">
{{ range $k, $v := (slice .std .taiko .ctb .mania) }}
<div data-mode="{{ $k }}" {{ if ne $k (int $favouritemode) }} hidden{{ end }}>
<table class="ui very basic two column compact table nopad">
<tbody>
{{ with .global_leaderboard_rank }}
<tr>
<td><b>{{ $global.T "Global rank" }}</b></td>
<td class="right aligned">#{{ . }}</td>
</tr>
{{ end }}
{{ with .country_leaderboard_rank }}
<tr>
<td><b>{{ $global.T "Country rank" }} &nbsp;{{ country $user.country false }}</b></td>
<td class="right aligned">#{{ . }}</td>
</tr>
{{ end }}
<tr>
<td><b>{{ $global.T "PP" }}</b></td>
<td class="right aligned">{{ humanize .pp }}</td>
</tr>
<tr>
<td><b>{{ $global.T "Ranked score" }}</b></td>
<td class="right aligned">{{ humanize .ranked_score }}</td>
</tr>
<tr>
<td><b>{{ $global.T "Total score" }}</b></td>
<td class="right aligned">{{ humanize .total_score }}</td>
</tr>
<tr>
<td><b>{{ $global.T "Playcount" }}</b></td>
<td class="right aligned">{{ humanize .playcount }}</td>
</tr>
<tr>
<td><b>{{ $global.T "Replays watched" }}</b></td>
<td class="right aligned">{{ humanize .replays_watched }}</td>
</tr>
<tr>
<td><b>{{ $global.T "Total hits" }}</b></td>
<td class="right aligned">{{ humanize .total_hits }}</td>
</tr> <tr>
<td><b>{{ $global.T "Accuracy" }}</b></td>
<td class="right aligned">{{ printf "%.2f" .accuracy }}%</td>
</tr>
</tbody>
</table>
<div class="ui blue progress little margin top" data-percent="{{ levelPercent .level }}">
<div class="bar">
<div class="progress">{{ levelPercent .level }}%</div>
</div>
<div class="label">{{ $global.T "Level %s" (level .level) }}</div>
</div>
</div>
{{ end }}
</div>
</div>
</div>
</div> <!-- end grid segment -->
<div id="scores-zone">
{{ range _range 4 }}
<div data-mode="{{ . }}" {{ if ne . (int $favouritemode) }} hidden{{ end }} data-loaded="0">
</div>
{{ end }}
</div>
<div class="ui segment">
<h2 class="ui header">{{ $global.T "Achievements" }}</h2>
<div id="achievements" class="ui grid">
</div>
<div class="right aligned">
<button class="ui disabled button" id="load-more-achievements">
{{ $global.T "Load more" }}
</button>
</div>
</div>
<div class="ui modal">
<i class="close icon"></i>
<div class="content">
<table class="ui definition table" id="score-data-table">
</table>
</div>
</div>
{{ end }}
{{ end }}
</div>
{{ end }}

View File

@ -0,0 +1,280 @@
{{ define "tpl" }}
<div class="ui container">
{{ if .UserID }}
{{ $gqm := atoi (.Gin.Query "mode") }}
{{ $global := . }}
{{ with (.Get "users/rx/full?id=%d" .UserID) }}
{{ $favouritemode := _or $gqm .favourite_mode }}
<script>
window.favouriteMode = {{ $favouritemode }};
window.userID = {{ .id }};
</script>
{{ if after .silence_info.end }}
<div class="ui error centered message">{{ $global.T "User is <b>silenced</b> for %s, expires %s." (.silence_info.reason | htmlescaper) (time .silence_info.end) | html }}</div>
{{ end }}
{{ $sarah := has .id 1193 }}
{{ $alicia := has .id 1000 }}
{{ $catherine := has .id 999 }}
{{ $super := has .privileges 7340031 }}
{{ $dev := has .privileges 11534335 }}
{{ $donor := has .privileges 7 }}
{{ $admin := has .privileges 3049983 }}
{{ $chatmod := has .privileges 786763 }}
{{ $bn := has .privileges 267 }}
{{ if hasAdmin $global.Context.User.Privileges }}
{{ $restr := not (has .privileges 1) }}
{{ $disab := not (has .privileges 2) }}
{{ $pend := has .privileges 1048576 }}
{{ if and $disab $restr }}
{{ if $pend }}
<div class="ui warning centered message">{{ $global.T "User is <b>%s</b>" "pending verification" | html }}.</div>
{{ else }}
<div class="ui error centered message">{{ $global.T "User is <b>%s</b>" "banned" | html }}.</div>
{{ end }}
{{ else if $restr }}
<div class="ui error centered message">{{ $global.T "User is <b>%s</b>" "restricted" | html }}.</div>
{{ else if $disab }}
<div class="ui error centered message">{{ $global.T "User is <b>%s</b>" "locked" | html }}.</div>
{{ end }}
{{ end }}
{{ with $global.Get "users/userpage?id=%.0f" .id }}
{{ if .userpage }}
{{ with parseUserpage .userpage }}
<div class="ui raised segment twemoji" id="userpage-content">
{{ html . }}
</div>
{{ end }}
{{ end }}
{{ end }}
<div class="ui top attached segment overflow auto">
<div class="magic table">
<div class="table element">
{{ if eq $global.UserID $global.Context.User.ID }}
<a href="/settings/avatar">
{{ end }}
<img src="{{ config "AvatarURL" }}/{{ .id }}" alt="avatar" class="user avatar">
{{ if eq $global.UserID $global.Context.User.ID }}
</a>
{{ end }}
</div>
<div class="table element">
<h1 class="ui header">
{{ $user := . }}
{{ if $super }}
<div class="owner">
{{ if $user.clan.tag}}
<td class="right aligned"><a href="/c/{{ $user.clan.id }}?mode=0">[{{ $user.clan.tag }}]</a></td>
{{ end }}
{{ .username }}
</div>
{{else if $donor}}
<div class="dev">
{{ if $user.clan.tag}}
<td class="right aligned"><a href="/c/{{ $user.clan.id }}?mode=0">[{{ $user.clan.tag }}]</a></td>
{{ end }}
{{ .username }}
</div>
{{ else }}
{{ if $user.clan.tag}}
<td class="right aligned"><a href="/c/{{ $user.clan.id }}?mode=0">[{{ $user.clan.tag }}]</a></td>
{{ end }}
{{ .username }}
{{ end }}
</h1>
{{ if .username_aka }}
<div class="subtitle">
{{ $global.T "(aka <b>%s</b>)" (.username_aka | htmlescaper) | html }}
</div>
{{ end }}
{{ with bget "isOnline?id=%.0f" .id }}
<div class="subtitle">
<i class="{{ if .result }}green{{ else }}grey{{ end }} circle icon"></i>
{{ if .result }}{{ $global.T "Online" }}{{ else }}{{ $global.T "Offline" }}{{ end }}
</div>
{{ end }}
</div>
</div>
<div class="magic table floating right">
<div class="table element">
{{ range $k, $v := (slice .std .taiko .ctb .mania) }}
<h1 data-mode="{{ $k }}"{{ if ne $k (int $favouritemode) }} hidden{{ end }}>{{ with and $v $v.global_leaderboard_rank }}#{{ . }}{{ else }}{{ $global.T "Unknown" }}{{ end }}</h1>
{{ end }}
<div id="profile-actions">
{{ if and (ne $global.Context.User.ID $global.UserID) (ne $global.Context.User.ID 0) }}
<button class="ui circular mini icon loading button" id="add-friend-button">
<i class="horizontal ellipsis icon"></i>
</button>
{{ end }}
{{ if eq $global.Context.User.ID $global.UserID }}
<a href="/settings" class="ui circular mini teal icon button"
title="{{ $global.T "Settings" }}">
<i class="edit icon"></i>
</a>
{{ end }}
{{ if hasAdmin $global.Context.User.Privileges }}
<a href="https://old.yozora.pw/index.php?p=103&id={{ $global.UserID }}"
target="_blank" title="Quick edit" class="ui circular mini blue icon button">
<i class="folder open outline icon"></i>
</a>
{{ end }}
</div>
</div>
</div>
</div>
{{ $user := . }}
<div class="ui three item bottom attached menu" id="rx-menu">
<a class="0 item" data-rx="0" href="/u/{{ $user.id }}">Vanilla</a>
<a class="1 item" data-rx="1" href="/rx/u/{{ $user.id }}">Relax</a>
<a class="1 item" data-rx="2" href="/ap/u/{{ $user.id }}">AutoPilot</a>
</div>
<div class="ui four item bottom attached menu" id="mode-menu">
{{ range $k, $v := modes }}
<a class="{{ favMode $favouritemode $k }}item" data-mode="{{ $k }}" href="/u/{{ $user.id }}?mode={{ $k }}">{{ $v }}</a>
{{ end }}
</div>
<div class="ui segment">
<div class="ui three column divided stackable grid">
<div class="row">
<div class="column">
{{if $super }}
{{ $global.T "<b >%s</b> " .username | html }}
is an <i class="pink code small icon"></i><b>Owner</b>
{{ $global.T " from <b>%s</b>." (country .country true) | html }}
{{else if $dev}}
{{ $global.T "<b>%s</b> " .username | html }}
is a <i class="blue code small icon"></i><b>Developer</b>
{{ $global.T " from <b>%s</b>." (country .country true) | html }}
{{else if $admin}}
{{ $global.T "<b>%s</b> " .username | html }}
is an <i class="red lightning small icon"></i><b>Administrator</b>
{{ $global.T " from <b>%s</b>." (country .country true) | html }}
{{else if $chatmod}}
{{ $global.T "<b>%s</b> " .username | html }}
is a <i class="blue star small icon"></i><b>Chat Mod</b>
{{ $global.T " from <b>%s</b>." (country .country true) | html }}
{{else if $bn}}
{{ $global.T "<b>%s</b> " .username | html }}
is a <i class="orange universal access small icon"></i><b>Nominator</b>
{{ $global.T " from <b>%s</b>." (country .country true) | html }}
{{else if $donor }}
{{ $global.T "<b>%s</b> " .username | html }}
is a <i class="pink heart small icon"></i><b>Supporter</b>
{{ $global.T " from <b>%s</b>." (country .country true) | html }}
{{ else }}
{{ $global.T "<b>%s</b> is a player from <b>%s</b>." .username (country .country true) | html }}
{{ end }}
<br>{{ $global.T "They signed up on Yozora %s." (time $user.registered_on) | html }}
<br>{{ $global.T "Last seen: %s." (time $user.latest_activity) | html }}
<br>{{ with playstyle .play_style $global }}{{ $global.T "They play with %s." . }}{{ end }}
</div>
<div class="column">
{{ if and (not .badges) (not .custom_badge) }}
{{ $global.T "This user hasn't got any badges!" }}
{{ else }}
<div class="ui grid">
{{ range .badges }}
<div class="eight wide centered column">
<i class="circular {{ faIcon .icon }} big icon"></i><br>
<b>{{ .name }}</b>
</div>
{{ end }}
{{ with .custom_badge }}
<div class="eight wide centered column">
<i class="circular {{ faIcon .icon }} big icon"></i><br>
<b><i>{{ .name }}</i></b>
</div>
{{ end }}
</div>
{{ end }}
</div>
<div class="column">
{{ range $k, $v := (slice .std .taiko .ctb .mania) }}
<div data-mode="{{ $k }}" {{ if ne $k (int $favouritemode) }} hidden{{ end }}>
<table class="ui very basic two column compact table nopad">
<tbody>
{{ with .global_leaderboard_rank }}
<tr>
<td><b>{{ $global.T "Global rank" }}</b></td>
<td class="right aligned">#{{ . }}</td>
</tr>
{{ end }}
{{ with .country_leaderboard_rank }}
<tr>
<td><b>{{ $global.T "Country rank" }} &nbsp;{{ country $user.country false }}</b></td>
<td class="right aligned">#{{ . }}</td>
</tr>
{{ end }}
<tr>
<td><b>{{ $global.T "PP" }}</b></td>
<td class="right aligned">{{ humanize .pp }}</td>
</tr>
<tr>
<td><b>{{ $global.T "Ranked score" }}</b></td>
<td class="right aligned">{{ humanize .ranked_score }}</td>
</tr>
<tr>
<td><b>{{ $global.T "Total score" }}</b></td>
<td class="right aligned">{{ humanize .total_score }}</td>
</tr>
<tr>
<td><b>{{ $global.T "Playcount" }}</b></td>
<td class="right aligned">{{ humanize .playcount }}</td>
</tr>
<tr>
<td><b>{{ $global.T "Replays watched" }}</b></td>
<td class="right aligned">{{ humanize .replays_watched }}</td>
</tr>
<tr>
<td><b>{{ $global.T "Total hits" }}</b></td>
<td class="right aligned">{{ humanize .total_hits }}</td>
</tr> <tr>
<td><b>{{ $global.T "Accuracy" }}</b></td>
<td class="right aligned">{{ printf "%.2f" .accuracy }}%</td>
</tr>
</tbody>
</table>
<div class="ui blue progress little margin top" data-percent="{{ levelPercent .level }}">
<div class="bar">
<div class="progress">{{ levelPercent .level }}%</div>
</div>
<div class="label">{{ $global.T "Level %s" (level .level) }}</div>
</div>
</div>
{{ end }}
</div>
</div>
</div>
</div> <!-- end grid segment -->
<div id="scores-zone">
{{ range _range 4 }}
<div data-mode="{{ . }}" {{ if ne . (int $favouritemode) }} hidden{{ end }} data-loaded="0">
</div>
{{ end }}
</div>
<div class="ui segment">
<h2 class="ui header">{{ $global.T "Achievements" }}</h2>
<div id="achievements" class="ui grid">
</div>
<div class="right aligned">
<button class="ui disabled button" id="load-more-achievements">
{{ $global.T "Load more" }}
</button>
</div>
</div>
<div class="ui modal">
<i class="close icon"></i>
<div class="content">
<table class="ui definition table" id="score-data-table">
</table>
</div>
</div>
{{ end }}
{{ end }}
</div>
{{ end }}

View File

@ -2,52 +2,60 @@
<div class="ui container">
{{ $regEnabled := qb "SELECT value_int FROM system_settings WHERE name = 'registrations_enabled'" }}
{{ if .Context.User.ID }}
<div class="ui segment">
{{ .T "You're already logged in!" }}
</div>
<div class="ui segment">
{{ .T "You're already logged in!" }}
</div>
{{ else if not ($regEnabled.value_int.Bool) }}
<div class="ui error message">
{{ .T "Sorry, it's not possible to register at the moment. Please try again later." }}
</div>
<div class="ui error message">
{{ .T "Sorry, it's not possible to register at the moment. Please try again later." }}
</div>
{{ else }}
{{ if eq (.Gin.Query "stopsign") "1" }}
<div class="ui warning message">
{{ .T "Remember: this seems like it's your second account! Do not multiaccount, or you're likely to get restricted!" }}
{{ if eq (.Gin.Query "stopsign") "1" }}
<div class="ui warning message">
{{ .T "Remember: this seems like it's your second account! Do not multiaccount, or you're likely to get restricted!" }}
</div>
{{ end }}
<div class="ui info message">
<center> Don't have a code? Apply <a href="https://docs.google.com/forms/d/1oYO8Cky0piTsfhMDxnQOZnNOo4K4tT3itZ5CdLY-w2k">here!</a></center>
</div>
<div class="tiny container">
<div class="ui raised segments">
<div class="ui segment">
<form id="register-form" class="ui form" method="post" action="/register">
<div class="field">
<label>{{ .T "Username (2 to 15 characters, alphanumeric, spaces, <code>_[]-</code>)" | html }}</label>
<input tabindex="1" type="text" name="username" placeholder="{{ .T "Username" }}"
value="{{ .FormData.username }}" required pattern="^[A-Za-z0-9 _\[\]-]{2,15}$">
</div>
<div class="field">
<label>{{ .T "Password (at least 8 characters)" }}</label>
<input tabindex="2" type="password" name="password" placeholder="{{ .T "Password" }}"
value="{{ .FormData.password }}" required pattern="^.{8,}$">
</div>
<div class="field">
<label>{{ .T "Email" }}</label>
<input tabindex="3" type="email" name="email" placeholder="{{ .T "Email" }}"
value="{{ .FormData.email }}" required>
</div>
<div class="field">
<label>{{ .T "Registration Key" }}</label>
<input tabindex="3" type="text" name="key" placeholder="{{ .T "Key" }}"
value="{{ .FormData.key }}" required>
</div>
{{ with config "RecaptchaSite" }}
<div class="field">
<div class="g-recaptcha" data-sitekey="{{ . }}"></div>
</div>
{{ end }}
{{ ieForm .Gin }}
</form>
</div>
{{ end }}
<div class="tiny container">
<div class="ui raised segments">
<div class="ui segment">
<form id="register-form" class="ui form" method="post" action="/register">
<div class="field">
<label>{{ .T "Username (2 to 15 characters, alphanumeric, spaces, <code>_[]-</code>)" | html }}</label>
<input tabindex="1" type="text" name="username" placeholder="{{ .T "Username" }}" value="{{ .FormData.username }}" required pattern="^[A-Za-z0-9 _\[\]-]{2,15}$">
</div>
<div class="field">
<label>{{ .T "Password (at least 8 characters)" }}</label>
<input tabindex="2" type="password" name="password" placeholder="{{ .T "Password" }}" value="{{ .FormData.password }}" required pattern="^.{8,}$">
</div>
<div class="field">
<label>{{ .T "Email" }}</label>
<input tabindex="3" type="email" name="email" placeholder="{{ .T "Email" }}" value="{{ .FormData.email }}" required>
</div>
<div class="field">
<label>{{ .T "Registration Key" }}</label>
<input tabindex="3" type="text" name="key" placeholder="{{ .T "Key" }}" value="{{ .FormData.key }}" required>
</div>
{{ with config "RecaptchaSite" }}
<div class="field">
<div class="g-recaptcha" data-sitekey="{{ . }}"></div>
</div>
{{ end }}
{{ ieForm .Gin }}
</form>
</div>
<div class="ui right aligned segment">
<button tabindex="4" class="ui primary button" type="submit" form="register-form">{{ .T "Submit" }}</button>
</div>
<div class="ui right aligned segment">
<button tabindex="4" class="ui primary button" type="submit"
form="register-form">{{ .T "Submit" }}</button>
</div>
</div>
</div>
{{ end }}
</div>
{{ end }}

View File

@ -0,0 +1,86 @@
{{/*###
Handler=/settings/clansettings
TitleBar=Clan Settings
Include=menu.html
*/}}
{{ define "tpl" }}
<div class="ui container">
<div class="ui stackable grid">
{{ template "settingsSidebar" . }}
<div class="twelve wide column">
<div class="ui segment">
{{ $d := qb "SELECT user, clan, perms FROM user_clans WHERE user = ? AND perms = 8 LIMIT 1" .Context.User.ID }}
{{ $g := or $d.clan.Int -1 }}
{{ if $d }}
<font size="6">Clan Settings</font>
<br>
<br>
<div class="ui center aligned segment">
{{ $o := (.Get "clans/isclan?uid=%d" .Context.User.ID).clan.clan }}
{{ $c := qb "SELECT * FROM clans WHERE id = ? LIMIT 1" $o }}
{{ $tag := or $c.tag.String "" }}
{{ $desc := or $c.description.String "" }}
{{ $icon := or $c.icon.String "" }}
{{ $bg := or $c.background.String "" }}
<form id="register-form" class="ui form" method="post" action="/settings/clansettings">
<div class="field">
<label class="left aligned">{{ "Clantag (2 to 6 characters)" }}</label>
<input tabindex="1" type="text" name="tag" placeholder="{{ "Clantag" }}" value="{{ $tag }}" pattern="{2,6}$">
</div>
<div class="field">
<label class="left aligned">{{ "Description (optional)" }}</label>
<input tabindex="2" type="text" name="password" placeholder="{{ "Description" }}" value="{{ $desc }}">
</div>
<div class="field">
<label class="left aligned">{{ "Icon [URL] (optional)" }}</label>
<input tabindex="3" type="url" name="email" placeholder="{{ "Icon URL" }}" value="{{ $icon }}">
</div>
{{ if has .Context.User.Privileges 8388612 }}
<div class="field">
<label class="left aligned">{{ "Background [URL] (optional)" }}</label>
<input tabindex="4" type="url" name="bg" placeholder="{{ "Background URL" }}" value="{{ $bg }}">
</div>
{{ end }}
{{ ieForm .Gin }}
</form>
<br>
<button tabindex="5" class="ui primary button" type="submit" form="register-form">{{ .T "Save" }}</button>
</div>
<br>
<font size="6">Invite</font>
<br>
<br><center><div class="ui two item menu" id="join-menu">
{{ with .Get "clans/getinvite?id=%d" .Context.User.ID }}
<input class="item" type="text" value="https://yozora.pw/clans/invite/{{ .invite }}" href="https://yozora.pw/clans/invite/{{ .invite }}" style="background-color:rgba(0,0,0,.6)" disabled></input>
{{ end }}
</div>
<br><form action="/settings/clansettings" method="POST">
{{ ieForm .Gin }}
<button type="submit" class="ui right green button">
{{ .T "Generate a new invite!" }}
</button>
</form>
<br>
<font size="6">Members {{ $g }}</font>
<br>
{{ range (.Get "clans/members?id=%d&r=%d" $g 1).members }}
<form id="kick-form" class="ui form" method="post" action="/settings/clansettings/k">
<div class="column">
<div class="ui left aligned fluid card">
<div class="content">
<a href="/u/{{ .id }}"><a class="header" href="/u/{{ .id }}">{{ country .country false }}{{ .username }}</a> <button name="member" value="{{ .id }}" class="ui right green button" style="float:right; display:block; margin-right:0px; clear:left; width: 40%;"> Kick </button></a>
</div>
</div>
</div>
</form>
{{ end }}
{{ else }}
<font size="3">You haven't joined a clan yet, or you aren't the owner of your current one.</font>
{{ end }}
</div>
</div>
</div>
</div>
{{ end }}

View File

@ -15,6 +15,8 @@ NoCompile=true
{{ navbarItem .Path (.T "Two Factor Authentication") "/settings/2fa" }}
{{ navbarItem .Path (.T "Authorized applications") "/settings/authorized_applications" }}
{{ navbarItem .Path (.T "Clan Settings") "/settings/clansettings" }}
{{/* Stuff for donators */}}
{{ if has .Context.User.Privileges 4 }}

View File

@ -64,6 +64,9 @@ KyutGrill=team2.jpg
{{ .T "<b>jrosdahl</b>, for <a href='https://github.com/jrosdahl/miniircd'>miniircd</a>, used as a base for our IRC server." | html }}<br>
miniircd is licensed under GPL v2. Our implementation can be found <a href="https://zxq.co/ripple/pep.py/src/master/irc/ircserver.py">here</a>.
</li>
<li>
{{ .T "<b>Kotrik</b>, for helping out with some features." | html }}.
</li>
<li>{{ .T "<b>Jacksonisiah</b>, for designing the Yozora logo." | html }}</li>
<li>{{ .T "<b><a id='everyone' class='clickable'>Everyone</a></b> who has supported the Yozora project by donating or inviting other people." | html }}</li>
</ul>