diff --git a/controller/config.go b/controller/config.go index 1d3cbbb..fdfa756 100644 --- a/controller/config.go +++ b/controller/config.go @@ -77,5 +77,9 @@ func handleConfigOverrides(config *model.Config) error { if env, has := os.LookupEnv("ARIMELODY_DISCORD_CLIENT_ID"); has { config.Discord.ClientID = env } if env, has := os.LookupEnv("ARIMELODY_DISCORD_SECRET"); has { config.Discord.Secret = env } + if env, has := os.LookupEnv("ARIMELODY_TWITCH_BROADCASTER"); has { config.Twitch.Broadcaster = env } + if env, has := os.LookupEnv("ARIMELODY_TWITCH_CLIENT_ID"); has { config.Twitch.ClientID = env } + if env, has := os.LookupEnv("ARIMELODY_TWITCH_SECRET"); has { config.Twitch.Secret = env } + return nil } diff --git a/controller/qr.go b/controller/qr.go index 6b04e69..dd08637 100644 --- a/controller/qr.go +++ b/controller/qr.go @@ -1,13 +1,9 @@ package controller import ( - "bytes" "encoding/base64" - "errors" - "fmt" "image" "image/color" - "image/png" "github.com/skip2/go-qrcode" ) @@ -33,69 +29,6 @@ const ( HIGH ) -func noDepsGenerateQRCode() (string, error) { - version := 1 - - size := 0 - size = 21 + version * 4 - if version > 10 { - return "", errors.New(fmt.Sprintf("QR version %d not supported", version)) - } - - img := image.NewGray(image.Rect(0, 0, size + margin * 2, size + margin * 2)) - - // fill white - for y := range size + margin * 2 { - for x := range size + margin * 2 { - img.Set(x, y, color.White) - } - } - - // draw alignment squares - drawLargeAlignmentSquare(margin, margin, img) - drawLargeAlignmentSquare(margin, margin + size - 7, img) - drawLargeAlignmentSquare(margin + size - 7, margin, img) - drawSmallAlignmentSquare(size - 5, size - 5, img) - /* - if version > 4 { - space := version * 3 - 2 - end := size / space - for y := range size / space + 1 { - for x := range size / space + 1 { - if x == 0 && y == 0 { continue } - if x == 0 && y == end { continue } - if x == end && y == 0 { continue } - if x == end && y == end { continue } - drawSmallAlignmentSquare( - x * space + margin + 4, - y * space + margin + 4, - img, - ) - } - } - } - */ - - // draw timing bits - for i := margin + 6; i < size - 4; i++ { - if (i % 2 == 0) { - img.Set(i, margin + 6, color.Black) - img.Set(margin + 6, i, color.Black) - } - } - img.Set(margin + 8, size - 4, color.Black) - - var imgBuf bytes.Buffer - err := png.Encode(&imgBuf, img) - if err != nil { - return "", err - } - - base64Img := base64.StdEncoding.EncodeToString(imgBuf.Bytes()) - - return "data:image/png;base64," + base64Img, nil -} - func drawLargeAlignmentSquare(x int, y int, img *image.Gray) { for yi := range 7 { for xi := range 7 { diff --git a/controller/twitch.go b/controller/twitch.go new file mode 100644 index 0000000..98d3b9a --- /dev/null +++ b/controller/twitch.go @@ -0,0 +1,94 @@ +package controller + +import ( + "arimelody-web/model" + "bytes" + "encoding/json" + "net/http" + "net/url" + "time" +) + +const TWITCH_API_BASE = "https://api.twitch.tv/helix/" + +func TwitchSetup(app *model.AppState) error { + app.Twitch = &model.TwitchState{} + err := RefreshTwitchToken(app) + return err +} + +func RefreshTwitchToken(app *model.AppState) error { + if app.Twitch != nil && app.Twitch.Token != nil && time.Now().UTC().After(app.Twitch.Token.ExpiresAt) { + return nil + } + + requestUrl, _ := url.Parse("https://id.twitch.tv/oauth2/token") + req, _ := http.NewRequest(http.MethodPost, requestUrl.String(), bytes.NewBuffer([]byte(url.Values{ + "client_id": []string{ app.Config.Twitch.ClientID }, + "client_secret": []string{ app.Config.Twitch.Secret }, + "grant_type": []string{ "client_credentials" }, + }.Encode()))) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + + type TwitchOAuthToken struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` + } + oauthResponse := TwitchOAuthToken{} + err = json.NewDecoder(res.Body).Decode(&oauthResponse) + if err != nil { + return err + } + + app.Twitch.Token = &model.TwitchOAuthToken{ + AccessToken: oauthResponse.AccessToken, + ExpiresAt: time.Now().UTC().Add(time.Second * time.Duration(oauthResponse.ExpiresIn)).UTC(), + TokenType: oauthResponse.TokenType, + } + + return nil +} + +var lastStreamState *model.TwitchStreamInfo +var lastStreamStateAt time.Time + +func GetTwitchStatus(app *model.AppState, broadcaster string) (*model.TwitchStreamInfo, error) { + if lastStreamState != nil && time.Now().UTC().Before(lastStreamStateAt.Add(time.Minute)) { + return lastStreamState, nil + } + + requestUrl, _ := url.Parse(TWITCH_API_BASE + "streams") + requestUrl.RawQuery = url.Values{ + "user_login": []string{ broadcaster }, + }.Encode() + req, _ := http.NewRequest(http.MethodGet, requestUrl.String(), nil) + req.Header.Set("Client-Id", app.Config.Twitch.ClientID) + req.Header.Set("Authorization", "Bearer " + app.Twitch.Token.AccessToken) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + type StreamsResponse struct { + Data []model.TwitchStreamInfo `json:"data"` + } + streamInfo := StreamsResponse{} + err = json.NewDecoder(res.Body).Decode(&streamInfo) + if err != nil { + return nil, err + } + + if len(streamInfo.Data) == 0 { + return nil, nil + } + + lastStreamState = &streamInfo.Data[0] + lastStreamStateAt = time.Now().UTC() + return lastStreamState, nil +} diff --git a/main.go b/main.go index 1e37bb1..e7a0291 100644 --- a/main.go +++ b/main.go @@ -22,7 +22,6 @@ import ( "arimelody-web/cursor" "arimelody-web/log" "arimelody-web/model" - "arimelody-web/templates" "arimelody-web/view" "github.com/jmoiron/sqlx" @@ -40,6 +39,7 @@ func main() { app := model.AppState{ Config: controller.GetConfig(), + Twitch: nil, } // initialise database connection @@ -460,6 +460,13 @@ func main() { // handle DB migrations controller.CheckDBVersionAndMigrate(app.DB) + if app.Config.Twitch != nil { + err = controller.TwitchSetup(&app) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to set up Twitch integration: %v\n", err) + } + } + // initial invite code accountsCount := 0 err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account") @@ -512,49 +519,13 @@ func createServeMux(app *model.AppState) *http.ServeMux { mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app))) mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(app))) mux.Handle("/blog/", http.StripPrefix("/blog", view.BlogHandler(app))) - mux.Handle("/uploads/", http.StripPrefix("/uploads", staticHandler(filepath.Join(app.Config.DataDirectory, "uploads")))) + mux.Handle("/uploads/", http.StripPrefix("/uploads", view.StaticHandler(filepath.Join(app.Config.DataDirectory, "uploads")))) mux.Handle("/cursor-ws", cursor.Handler(app)) - mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodHead { - w.WriteHeader(http.StatusOK) - return - } - - if r.URL.Path == "/" || r.URL.Path == "/index.html" { - err := templates.IndexTemplate.Execute(w, nil) - if err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } - return - } - staticHandler("public").ServeHTTP(w, r) - })) + mux.Handle("/", view.IndexHandler(app)) return mux } -func staticHandler(directory string) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - info, err := os.Stat(filepath.Join(directory, filepath.Clean(r.URL.Path))) - - // does the file exist? - if err != nil { - if errors.Is(err, os.ErrNotExist) { - http.NotFound(w, r) - return - } - } - - // is thjs a directory? (forbidden) - if info.IsDir() { - http.NotFound(w, r) - return - } - - http.FileServer(http.Dir(directory)).ServeHTTP(w, r) - }) -} - var PoweredByStrings = []string{ "nerd rage", "estrogen", diff --git a/model/appstate.go b/model/appstate.go index a910a29..3a1c230 100644 --- a/model/appstate.go +++ b/model/appstate.go @@ -21,6 +21,12 @@ type ( Secret string `toml:"secret"` } + TwitchConfig struct { + Broadcaster string `toml:"broadcaster"` + ClientID string `toml:"client_id"` + Secret string `toml:"secret"` + } + Config struct { BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."` Host string `toml:"host"` @@ -28,12 +34,14 @@ type ( DataDirectory string `toml:"data_dir"` TrustedProxies []string `toml:"trusted_proxies"` DB DBConfig `toml:"db"` - Discord DiscordConfig `toml:"discord"` + Discord *DiscordConfig `toml:"discord"` + Twitch *TwitchConfig `toml:"twitch"` } AppState struct { DB *sqlx.DB Config Config Log log.Logger + Twitch *TwitchState } ) diff --git a/model/twitch.go b/model/twitch.go new file mode 100644 index 0000000..6bca17d --- /dev/null +++ b/model/twitch.go @@ -0,0 +1,43 @@ +package model + +import ( + "fmt" + "strings" + "time" +) + +type ( + TwitchOAuthToken struct { + AccessToken string + ExpiresAt time.Time + TokenType string + } + + TwitchState struct { + Token *TwitchOAuthToken + } + + TwitchStreamInfo struct { + ID string `json:"id"` + UserID string `json:"user_id"` + UserLogin string `json:"user_login"` + UserName string `json:"user_name"` + GameID string `json:"game_id"` + GameName string `json:"game_name"` + Type string `json:"type"` + Title string `json:"title"` + ViewerCount int `json:"viewer_count"` + StartedAt string `json:"started_at"` + Language string `json:"language"` + ThumbnailURL string `json:"thumbnail_url"` + TagIDs []string `json:"tag_ids"` + Tags []string `json:"tags"` + IsMature bool `json:"is_mature"` + } +) + +func (info *TwitchStreamInfo) Thumbnail(width int, height int) string { + res := strings.Replace(info.ThumbnailURL, "{width}", fmt.Sprintf("%d", width), 1) + res = strings.Replace(res, "{height}", fmt.Sprintf("%d", height), 1) + return res +} diff --git a/public/img/brand/bandcamp.svg b/public/img/brand/bandcamp.svg new file mode 100644 index 0000000..9623ec2 --- /dev/null +++ b/public/img/brand/bandcamp.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/img/brand/bluesky.svg b/public/img/brand/bluesky.svg new file mode 100644 index 0000000..d77fafe --- /dev/null +++ b/public/img/brand/bluesky.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/img/brand/codeberg.svg b/public/img/brand/codeberg.svg new file mode 100644 index 0000000..028b729 --- /dev/null +++ b/public/img/brand/codeberg.svg @@ -0,0 +1,164 @@ + + + Codeberg logo + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + Codeberg logo + + + + Robert Martinez + + + + + Codeberg and the Codeberg Logo are trademarks of Codeberg e.V. + + + 2020-04-09 + + + Codeberg e.V. + + + codeberg.org + + + + + + + + + + + + + diff --git a/public/img/brand/discord.svg b/public/img/brand/discord.svg new file mode 100644 index 0000000..b636d15 --- /dev/null +++ b/public/img/brand/discord.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/img/brand/twitch.svg b/public/img/brand/twitch.svg new file mode 100644 index 0000000..3120fea --- /dev/null +++ b/public/img/brand/twitch.svg @@ -0,0 +1,21 @@ + + + + +Asset 2 + + + + + + + + + + + diff --git a/public/img/brand/youtube.svg b/public/img/brand/youtube.svg new file mode 100644 index 0000000..3286071 --- /dev/null +++ b/public/img/brand/youtube.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/img/buttons/thermia.gif b/public/img/buttons/thermia.gif new file mode 100644 index 0000000..a7ee70a Binary files /dev/null and b/public/img/buttons/thermia.gif differ diff --git a/public/script/index.js b/public/script/index.js index 512ed8f..2197bd4 100644 --- a/public/script/index.js +++ b/public/script/index.js @@ -1,3 +1,5 @@ +import { hijackClickEvent } from "./main.js"; + const hexPrimary = document.getElementById("hex-primary"); const hexSecondary = document.getElementById("hex-secondary"); const hexTertiary = document.getElementById("hex-tertiary"); @@ -14,3 +16,8 @@ updateHexColours(); window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { updateHexColours(); }); + +document.querySelectorAll("ul#projects li.project-item").forEach(projectItem => { + const link = projectItem.querySelector('a'); + hijackClickEvent(projectItem, link); +}); diff --git a/public/script/main.js b/public/script/main.js index c1d5101..19eddc2 100644 --- a/public/script/main.js +++ b/public/script/main.js @@ -45,6 +45,23 @@ function fill_list(list) { }); } +export function hijackClickEvent(container, link) { + container.addEventListener('click', event => { + if (event.target.tagName.toLowerCase() === 'a') return; + event.preventDefault(); + link.dispatchEvent(new MouseEvent('click', { + bubbles: true, + cancelable: true, + view: window, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + button: event.button, + })); + }); +} + document.addEventListener("DOMContentLoaded", () => { [...document.querySelectorAll(".typeout")] .filter((e) => e.innerText != "") diff --git a/public/script/music.js b/public/script/music.js index 273ce2b..91f34ab 100644 --- a/public/script/music.js +++ b/public/script/music.js @@ -1,12 +1,6 @@ -import "./main.js"; +import { hijackClickEvent } from "./main.js"; document.querySelectorAll("div.music").forEach(container => { - const link = container.querySelector(".music-title a").href - - container.addEventListener("click", event => { - if (event.target.href) return; - - event.preventDefault(); - location = link; - }); + const link = container.querySelector(".music-title a") + hijackClickEvent(container, link); }); diff --git a/public/style/colours.css b/public/style/colours.css index 1407aa8..898e5b9 100644 --- a/public/style/colours.css +++ b/public/style/colours.css @@ -7,6 +7,7 @@ --secondary: #f8e05b; --tertiary: #f788fe; --links: #5eb2ff; + --live: #fd3737; } @media (prefers-color-scheme: light) { diff --git a/public/style/header.css b/public/style/header.css index 48971b5..f399d4d 100644 --- a/public/style/header.css +++ b/public/style/header.css @@ -25,7 +25,6 @@ nav { flex-grow: 1; display: flex; gap: .5em; - cursor: pointer; } img#header-icon { @@ -36,19 +35,19 @@ img#header-icon { } #header-text { - width: 11em; display: flex; flex-direction: column; justify-content: center; - flex-grow: 1; } #header-text h1 { + width: fit-content; margin: 0; font-size: 1em; } #header-text h2 { + width: fit-content; height: 1.2em; line-height: 1.2em; margin: 0; @@ -154,7 +153,7 @@ header ul li a:hover { flex-direction: column; gap: 1rem; border-bottom: 1px solid #888; - background: #080808; + background: var(--background); display: none; } diff --git a/public/style/index.css b/public/style/index.css index f3cb761..6efe241 100644 --- a/public/style/index.css +++ b/public/style/index.css @@ -96,30 +96,36 @@ hr { overflow: visible; } -ul.links { +ul.platform-links { + padding-left: 1em; display: flex; - gap: 1em .5em; + gap: .5em; flex-wrap: wrap; } -ul.links li { +ul.platform-links li { list-style: none; } -ul.links li a { +ul.platform-links li a { padding: .4em .5em; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: .5em; border: 1px solid var(--links); color: var(--links); border-radius: 2px; background-color: transparent; - transition-property: color, border-color, background-color; + transition-property: color, border-color, background-color, box-shadow; transition-duration: .2s; animation-delay: 0s; animation: list-item-fadein .2s forwards; opacity: 0; } -ul.links li a:hover { +ul.platform-links li a:hover { color: #eee; border-color: #eee; background-color: var(--links) !important; @@ -127,6 +133,75 @@ ul.links li a:hover { box-shadow: 0 0 1em var(--links); } +ul.platform-links li a img { + height: 1em; + width: 1em; +} + +ul#projects { + padding: 0; + list-style: none; +} + +li.project-item { + padding: .5em; + border: 1px solid var(--links); + margin: 1em 0; + display: flex; + flex-direction: row; + gap: .5em; + border-radius: 2px; + transition-property: color, border-color, background-color, box-shadow; + transition-duration: .2s; + cursor: pointer; +} +li.project-item a { + transition: color .2s linear; +} + +li.project-item:hover { + color: #eee; + border-color: #eee; + background-color: var(--links) !important; + text-decoration: none; + box-shadow: 0 0 1em var(--links); +} +li.project-item:hover a { + color: #eee; +} + +li.project-item .project-info { + display: flex; + flex-direction: column; + justify-content: center; +} + +li.project-item img.project-icon { + width: 2.5em; + height: 2.5em; + object-fit: cover; + border-radius: 2px; +} + +li.project-item span.project-icon { + font-size: 2em; + display: block; + width: 45px; + height: 45px; + text-align: center; + /* background: #0004; */ + /* border: 1px solid var(--on-background); */ + border-radius: 2px; +} + +li.project-item a { + text-decoration: none; +} + +li.project-item p { + margin: 0; +} + div#web-buttons { margin: 2rem 0; } @@ -148,3 +223,112 @@ div#web-buttons { box-shadow: 1px 1px 0 #eee, 2px 2px 0 #eee; } +#live-banner { + margin: 1em 0 2em 0; + padding: 1em; + border-radius: 4px; + border: 1px solid var(--primary); + box-shadow: 0 0 8px var(--primary); +} + +#live-banner p { + margin: 0; +} + +.live-highlight { + color: var(--primary); +} + +.live-preview { + display: flex; + flex-direction: row; + justify-content: center; + gap: 1em; +} + +.live-preview div:first-of-type { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + gap: .3em; +} + +.live-thumbnail { + border-radius: 4px; +} + +.live-button { + margin: .2em; + padding: .4em .5em; + display: inline-block; + color: var(--primary); + border: 1px solid var(--primary); + border-radius: 4px; + transition: color .1s linear, background-color .1s linear, box-shadow .1s linear; +} + +.live-button:hover { + color: var(--background); + background-color: var(--primary); + box-shadow: 0 0 8px var(--primary); + text-decoration: none; +} + +.live-info { + display: flex; + flex-direction: column; + gap: .3em; + overflow-x: hidden; +} + +#live-banner h2 { + margin: 0; + color: var(--on-background); + font-family: 'Inter', sans-serif; + font-weight: 800; + font-style: italic; +} + +.live-pinger { + width: .5em; + height: .5em; + margin: .1em .2em; + display: inline-block; + border-radius: 100%; + background-color: var(--primary); + box-shadow: 0 0 4px var(--primary); + animation: live-pinger-pulse 1s infinite alternate ease-in-out; +} + +@keyframes live-pinger-pulse { + from { + opacity: .8; + transform: scale(1.0); + } + to { + opacity: 1; + transform: scale(1.1); + } +} + +.live-game { + overflow: hidden; + text-wrap: nowrap; + text-overflow: ellipsis; +} + +.live-game .live-game-prefix { + opacity: .8; +} + +.live-title { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.live-viewers { + opacity: .5; +} diff --git a/public/style/main.css b/public/style/main.css index 4e9e113..680ee2b 100644 --- a/public/style/main.css +++ b/public/style/main.css @@ -3,6 +3,7 @@ @import url("/style/footer.css"); @import url("/style/prideflag.css"); @import url("/style/cursor.css"); +@import url("/font/inter/inter.css"); @font-face { font-family: "Monaspace Argon"; diff --git a/view/footer.html b/view/footer.html index 217c4b5..eccf125 100644 --- a/view/footer.html +++ b/view/footer.html @@ -2,7 +2,12 @@ diff --git a/view/header.html b/view/header.html index 291b8ac..03a8384 100644 --- a/view/header.html +++ b/view/header.html @@ -23,9 +23,6 @@
  • music
  • -
  • - source -
  • blog diff --git a/view/index.go b/view/index.go new file mode 100644 index 0000000..b6e3891 --- /dev/null +++ b/view/index.go @@ -0,0 +1,45 @@ +package view + +import ( + "arimelody-web/controller" + "arimelody-web/model" + "arimelody-web/templates" + "fmt" + "net/http" + "os" +) + +func IndexHandler(app *model.AppState) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + return + } + + type IndexData struct { + TwitchStatus *model.TwitchStreamInfo + } + + var err error + var twitchStatus *model.TwitchStreamInfo = nil + if app.Twitch != nil && len(app.Config.Twitch.Broadcaster) > 0 { + twitchStatus, err = controller.GetTwitchStatus(app, app.Config.Twitch.Broadcaster) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to get Twitch status for %s: %v\n", app.Config.Twitch.Broadcaster, err) + } + } + + if r.URL.Path == "/" || r.URL.Path == "/index.html" { + err := templates.IndexTemplate.Execute(w, IndexData{ + TwitchStatus: twitchStatus, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to render index page: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + return + } + + StaticHandler("public").ServeHTTP(w, r) + }) +} diff --git a/view/index.html b/view/index.html index ba0ca15..37bd2a3 100644 --- a/view/index.html +++ b/view/index.html @@ -6,8 +6,8 @@ - - + + @@ -17,11 +17,28 @@ - + {{end}} {{define "content"}}
    + {{if .TwitchStatus}} +
    +
    +
    + livestream thumbnail + join in! +
    +
    +

    ari melody LIVE

    +

    streaming: {{.TwitchStatus.GameName}}

    +

    {{.TwitchStatus.Title}}

    +

    {{.TwitchStatus.ViewerCount}} viewers

    +
    +
    +
    + {{end}} +

    # hello, world!

    @@ -33,7 +50,7 @@

    - i'm a musician, developer, + i'm a musician, developer, streamer, youtuber, and probably a bunch of other things i forgot to mention!

    @@ -44,7 +61,7 @@

    if you're looking to support me financially, that's so cool of you!! if you like, you can buy some of my music over on - bandcamp + bandcamp so you can at least get something for your money. thank you very much either way!! 💕

    @@ -84,57 +101,97 @@

    where to find me 🛰️

    -