Compare commits
3 commits
f7b3faf8e8
...
375ae84ae3
Author | SHA1 | Date | |
---|---|---|---|
375ae84ae3 | |||
581273370d | |||
9274796729 |
11 changed files with 369 additions and 66 deletions
|
@ -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_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_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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
94
controller/twitch.go
Normal file
94
controller/twitch.go
Normal file
|
@ -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
|
||||||
|
}
|
49
main.go
49
main.go
|
@ -22,7 +22,6 @@ import (
|
||||||
"arimelody-web/cursor"
|
"arimelody-web/cursor"
|
||||||
"arimelody-web/log"
|
"arimelody-web/log"
|
||||||
"arimelody-web/model"
|
"arimelody-web/model"
|
||||||
"arimelody-web/templates"
|
|
||||||
"arimelody-web/view"
|
"arimelody-web/view"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
|
@ -40,6 +39,7 @@ func main() {
|
||||||
|
|
||||||
app := model.AppState{
|
app := model.AppState{
|
||||||
Config: controller.GetConfig(),
|
Config: controller.GetConfig(),
|
||||||
|
Twitch: nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
// initialise database connection
|
// initialise database connection
|
||||||
|
@ -460,6 +460,13 @@ func main() {
|
||||||
// handle DB migrations
|
// handle DB migrations
|
||||||
controller.CheckDBVersionAndMigrate(app.DB)
|
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
|
// initial invite code
|
||||||
accountsCount := 0
|
accountsCount := 0
|
||||||
err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account")
|
err = app.DB.Get(&accountsCount, "SELECT count(*) FROM account")
|
||||||
|
@ -511,49 +518,13 @@ func createServeMux(app *model.AppState) *http.ServeMux {
|
||||||
mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app)))
|
mux.Handle("/admin/", http.StripPrefix("/admin", admin.Handler(app)))
|
||||||
mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app)))
|
mux.Handle("/api/", http.StripPrefix("/api", api.Handler(app)))
|
||||||
mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(app)))
|
mux.Handle("/music/", http.StripPrefix("/music", view.MusicHandler(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("/cursor-ws", cursor.Handler(app))
|
||||||
mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
mux.Handle("/", view.IndexHandler(app))
|
||||||
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)
|
|
||||||
}))
|
|
||||||
|
|
||||||
return mux
|
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{
|
var PoweredByStrings = []string{
|
||||||
"nerd rage",
|
"nerd rage",
|
||||||
"estrogen",
|
"estrogen",
|
||||||
|
|
|
@ -21,6 +21,12 @@ type (
|
||||||
Secret string `toml:"secret"`
|
Secret string `toml:"secret"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TwitchConfig struct {
|
||||||
|
Broadcaster string `toml:"broadcaster"`
|
||||||
|
ClientID string `toml:"client_id"`
|
||||||
|
Secret string `toml:"secret"`
|
||||||
|
}
|
||||||
|
|
||||||
Config struct {
|
Config struct {
|
||||||
BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."`
|
BaseUrl string `toml:"base_url" comment:"Used for OAuth redirects."`
|
||||||
Host string `toml:"host"`
|
Host string `toml:"host"`
|
||||||
|
@ -28,12 +34,14 @@ type (
|
||||||
DataDirectory string `toml:"data_dir"`
|
DataDirectory string `toml:"data_dir"`
|
||||||
TrustedProxies []string `toml:"trusted_proxies"`
|
TrustedProxies []string `toml:"trusted_proxies"`
|
||||||
DB DBConfig `toml:"db"`
|
DB DBConfig `toml:"db"`
|
||||||
Discord DiscordConfig `toml:"discord"`
|
Discord *DiscordConfig `toml:"discord"`
|
||||||
|
Twitch *TwitchConfig `toml:"twitch"`
|
||||||
}
|
}
|
||||||
|
|
||||||
AppState struct {
|
AppState struct {
|
||||||
DB *sqlx.DB
|
DB *sqlx.DB
|
||||||
Config Config
|
Config Config
|
||||||
Log log.Logger
|
Log log.Logger
|
||||||
|
Twitch *TwitchState
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
43
model/twitch.go
Normal file
43
model/twitch.go
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -6,6 +6,7 @@
|
||||||
--secondary: #f8e05b;
|
--secondary: #f8e05b;
|
||||||
--tertiary: #f788fe;
|
--tertiary: #f788fe;
|
||||||
--links: #5eb2ff;
|
--links: #5eb2ff;
|
||||||
|
--live: #fd3737;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
@media (prefers-color-scheme: light) {
|
||||||
|
|
|
@ -222,3 +222,113 @@ div#web-buttons {
|
||||||
transform: translate(-2px, -2px);
|
transform: translate(-2px, -2px);
|
||||||
box-shadow: 1px 1px 0 #eee, 2px 2px 0 #eee;
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
@import url("/style/footer.css");
|
@import url("/style/footer.css");
|
||||||
@import url("/style/prideflag.css");
|
@import url("/style/prideflag.css");
|
||||||
@import url("/style/cursor.css");
|
@import url("/style/cursor.css");
|
||||||
|
@import url("/font/inter/inter.css");
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Monaspace Argon";
|
font-family: "Monaspace Argon";
|
||||||
|
|
45
view/index.go
Normal file
45
view/index.go
Normal file
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
31
view/static.go
Normal file
31
view/static.go
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
package view
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -6,8 +6,8 @@
|
||||||
|
|
||||||
<meta property="og:title" content="ari melody">
|
<meta property="og:title" content="ari melody">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:url" content="www.arimelody.me">
|
<meta property="og:url" content="www.arimelody.space">
|
||||||
<meta property="og:image" content="https://www.arimelody.me/img/favicon.png">
|
<meta property="og:image" content="https://www.arimelody.space/img/favicon.png">
|
||||||
<meta property="og:site_name" content="ari melody">
|
<meta property="og:site_name" content="ari melody">
|
||||||
<meta property="og:description" content="home to your local SPACEGIRL 💫">
|
<meta property="og:description" content="home to your local SPACEGIRL 💫">
|
||||||
|
|
||||||
|
@ -22,6 +22,23 @@
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<main>
|
<main>
|
||||||
|
{{if .TwitchStatus}}
|
||||||
|
<div id="live-banner">
|
||||||
|
<div class="live-preview">
|
||||||
|
<div>
|
||||||
|
<img src="{{.TwitchStatus.Thumbnail 144 81}}" alt="livestream thumbnail" class="live-thumbnail">
|
||||||
|
<a href="https://twitch.tv/{{.TwitchStatus.UserName}}" class="live-button">join in!</a>
|
||||||
|
</div>
|
||||||
|
<div class="live-info">
|
||||||
|
<h2>ari melody <span class="live-highlight">LIVE</span> <i class="live-pinger"></i></h2>
|
||||||
|
<p class="live-game"><span class="live-game-prefix">streaming:</span> {{.TwitchStatus.GameName}}</p>
|
||||||
|
<p class="live-title">{{.TwitchStatus.Title}}</p>
|
||||||
|
<p class="live-viewers">{{.TwitchStatus.ViewerCount}} viewers</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<h1 class="typeout">
|
<h1 class="typeout">
|
||||||
# hello, world!
|
# hello, world!
|
||||||
</h1>
|
</h1>
|
||||||
|
@ -84,28 +101,6 @@
|
||||||
<p>
|
<p>
|
||||||
<strong>where to find me 🛰️</strong>
|
<strong>where to find me 🛰️</strong>
|
||||||
</p>
|
</p>
|
||||||
<!--
|
|
||||||
<ul class="links">
|
|
||||||
<li>
|
|
||||||
<a href="https://youtube.com/@arispacegirl">youtube</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://twitch.tv/arispacegirl">twitch</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://arimelody.bandcamp.com">bandcamp</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://codeberg.org/arimelody">codeberg</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://bsky.app/profile/arimelody.me">bluesky</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="https://discord.gg/MmJtBebF28">discord</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
-->
|
|
||||||
<ul class="platform-links">
|
<ul class="platform-links">
|
||||||
<li>
|
<li>
|
||||||
<a href="https://youtube.com/@arispacegirl" title="youtube">
|
<a href="https://youtube.com/@arispacegirl" title="youtube">
|
||||||
|
@ -132,7 +127,7 @@
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="https://bsky.app/profile/arimelody.me" title="bluesky">
|
<a href="https://bsky.app/profile/arimelody.space" title="bluesky">
|
||||||
<img src="/img/brand/bluesky.svg" alt="bluesky" width="32" height="32"/>
|
<img src="/img/brand/bluesky.svg" alt="bluesky" width="32" height="32"/>
|
||||||
bluesky
|
bluesky
|
||||||
</a>
|
</a>
|
||||||
|
@ -164,7 +159,7 @@
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li class="project-item">
|
<li class="project-item">
|
||||||
<img src="https://git.arimelody.me/repo-avatars/6b0a1ffb78cbc6f906f83152ea42a710220174e8f48a3e44f159ae58dacd7a2f" alt="catdance icon" aria-hidden=true class="project-icon" width="64" height="64">
|
<img src="https://git.arimelody.me/repo-avatars/6b0a1ffb78cbc6f906f83152ea42a710220174e8f48a3e44f159ae58dacd7a2f" alt="pride flag icon" aria-hidden=true class="project-icon" width="64" height="64">
|
||||||
<div class="project-info">
|
<div class="project-info">
|
||||||
<a href="https://git.arimelody.me/ari/prideflag">pride flag</a>
|
<a href="https://git.arimelody.me/ari/prideflag">pride flag</a>
|
||||||
<p>progressive pride flag widget for websites</p>
|
<p>progressive pride flag widget for websites</p>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue