first commit! 🎉

port "ari is learning!" assets to live go backend.
some prep work for further developments for ari melody LIVE
This commit is contained in:
ari melody 2026-06-12 04:50:45 +01:00
commit 6927d54cbd
Signed by: ari
GPG key ID: 60B5F0386E3DDB7E
12 changed files with 675 additions and 0 deletions

167
learning/learning.go Normal file
View file

@ -0,0 +1,167 @@
package learning
import (
"context"
"embed"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
"codeberg.org/arimelody/ari-is-learning/broadcast"
"github.com/gin-gonic/gin"
)
//go:embed public
var publicFS embed.FS
//go:embed pages
var pagesFS embed.FS
type (
ServiceConfig struct {
TitleFilePath string
}
Service struct {
cfg ServiceConfig
ctx context.Context
titleFileMutex sync.Mutex
titleLastModTime time.Time
titleText string
titleUpdated chan string
titleUpdatedBroadcast broadcast.BroadcastChannel[string]
}
)
func New(ctx context.Context, cfg ServiceConfig) *Service {
titleUpdated := make(chan string)
if _, err := os.Stat(cfg.TitleFilePath); os.IsNotExist(err) {
log.Printf("Learning title file [%s] does not exist, creating one...", cfg.TitleFilePath)
os.WriteFile(cfg.TitleFilePath, []byte("Untitled"), 0644)
} else if err != nil {
log.Fatalf("Failed to stat title file: %v", err)
}
srv := &Service{
cfg: cfg,
ctx: ctx,
titleFileMutex: sync.Mutex{},
titleLastModTime: time.Unix(0, 0),
titleText: "",
titleUpdated: titleUpdated,
titleUpdatedBroadcast: broadcast.NewBroadcastChannel(ctx, titleUpdated),
}
return srv
}
func (srv *Service) BindRoutes(group *gin.RouterGroup) {
group.GET("/public/*path", func(ctx *gin.Context) {
path := strings.TrimPrefix(ctx.Request.URL.Path, "/learning/")
http.ServeFileFS(ctx.Writer, ctx.Request, publicFS, path)
})
group.GET("/sse", func(ctx *gin.Context) {
ctx.Header("connection", "keep-alive")
ctx.SSEvent("title-update", srv.titleText)
titleUpdated := srv.titleUpdatedBroadcast.Subscribe()
ticker := time.NewTicker(10 * time.Millisecond)
ctx.Stream(func(w io.Writer) bool {
select {
case titleText := <-titleUpdated:
ctx.SSEvent("title-update", titleText)
case <-ticker.C:
}
return true
})
srv.titleUpdatedBroadcast.Cancel(titleUpdated)
})
group.POST("/title", func(ctx *gin.Context) {
data, err := io.ReadAll(ctx.Request.Body)
if err != nil {
log.Printf("Failed to read request body: %v", err)
ctx.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
return
}
log.Printf("Waiting on title file lock...")
srv.titleFileMutex.Lock()
defer srv.titleFileMutex.Unlock()
log.Printf("Got it!")
file, err := os.OpenFile(srv.cfg.TitleFilePath, os.O_RDWR | os.O_CREATE | os.O_TRUNC, 0)
if err != nil {
log.Printf("Failed to open title file: %v", err)
ctx.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
return
}
if _, err := file.Write(data); err != nil {
log.Printf("Failed to write to title file: %v", err)
ctx.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
return
}
ctx.String(http.StatusOK, http.StatusText(http.StatusOK))
})
group.GET("/hello", func(ctx *gin.Context) {
http.ServeFileFS(ctx.Writer, ctx.Request, pagesFS, "pages/index.html")
})
group.GET("/brb", func(ctx *gin.Context) {
http.ServeFileFS(ctx.Writer, ctx.Request, pagesFS, "pages/brb.html")
})
group.GET("/bye", func(ctx *gin.Context) {
http.ServeFileFS(ctx.Writer, ctx.Request, pagesFS, "pages/bye.html")
})
}
func (srv *Service) Run(ctx context.Context) {
failed := make(chan error)
go func() {
for {
srv.titleFileMutex.Lock()
stat, err := os.Stat(srv.cfg.TitleFilePath)
if err != nil {
failed<-fmt.Errorf("Failed to stat title file: %v", err)
return
}
if srv.titleLastModTime.Before(stat.ModTime()) {
data, err := os.ReadFile(srv.cfg.TitleFilePath)
if err != nil {
failed<-fmt.Errorf("Failed to read title file: %v", err)
return
}
log.Printf(
"title updated: \"%s\" -> \"%s\"",
srv.titleText,
strings.TrimSpace(string(data)),
)
srv.titleText = strings.TrimSpace(string(data))
if !srv.titleLastModTime.IsZero() { srv.titleUpdated<-srv.titleText }
srv.titleLastModTime = stat.ModTime()
}
srv.titleFileMutex.Unlock()
time.Sleep(100 * time.Millisecond)
}
}()
select {
case err := <-failed:
log.Fatalf("Learning service error: %v", err)
case <-ctx.Done():
}
}

27
learning/pages/brb.html Normal file
View file

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>brb - ari is LEARNING</title>
<link href="/learning/public/cards.css" rel="stylesheet">
</head>
<body>
<div id="app">
<header>
<h3>today's show: <span id="title"></span></h3>
<h3>stream.place</h3>
</header>
<div class="title">
<h1>💫 ari is LEARNING</h1>
<h2>be right back! ⏳</h2>
</div>
<footer>
<h3>@arimelody.space</h3>
<h3 id="time">{time}</h3>
</footer>
</div>
<script type="module" src="/learning/public/cards.js"></script>
<div id="cover"></div>
</body>
</html>

27
learning/pages/bye.html Normal file
View file

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>bye - ari is LEARNING</title>
<link href="/learning/public/cards.css" rel="stylesheet">
</head>
<body>
<div id="app">
<header>
<h3>wrapping up...</h3>
<h3>stream.place</h3>
</header>
<div class="title">
<h1>💫 ari is LEARNING</h1>
<h2>see you around! 👋</h2>
</div>
<footer>
<h3>@arimelody.space</h3>
<h3 id="time">{time}</h3>
</footer>
</div>
<script type="module" src="/learning/public/cards.js"></script>
<div id="cover"></div>
</body>
</html>

27
learning/pages/index.html Normal file
View file

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>starting soon... - ari is LEARNING</title>
<link href="/learning/public/cards.css" rel="stylesheet">
</head>
<body>
<div id="app">
<header>
<h3>today's show: <span id="title"></span></h3>
<h3>stream.place</h3>
</header>
<div class="title">
<h1>💫 ari is LEARNING</h1>
<h2>starting soon... 💚</h2>
</div>
<footer>
<h3>@arimelody.space</h3>
<h3 id="time">{time}</h3>
</footer>
</div>
<script type="module" src="/learning/public/cards.js"></script>
<div id="cover"></div>
</body>
</html>

125
learning/public/cards.css Normal file
View file

@ -0,0 +1,125 @@
:root {
--bg-0: #e0e0e0;
--on-bg-0: #303030;
--on-bg-1: #606060;
--on-bg-2: #808080;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-0: #202020;
--on-bg-0: #e0e0e0;
--on-bg-1: #c0c0c0;
--on-bg-2: #808080;
}
}
body {
margin: 0;
padding: 0;
width: 100%;
height: 100vh;
font-family: "Inter", "Arial", sans-serif;
font-size: 32px;
color: var(--on-bg-0);
background-color: var(--bg-0);
transition: color 1s, background-color 1s;
}
#app {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.vignette {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background-image: radial-gradient(transparent, black);
opacity: .1;
pointer-events: none;
}
.title {
display: flex;
flex-direction: column;
justify-content: center;
align-items: end;
}
.title h1, .title h2 {
margin: 0;
}
.title h1 {
font-size: 2em;
margin-bottom: -.2em;
}
.title h2 {
font-size: 1.25em;
color: var(--on-bg-2);
animation: pulse .5s alternate ease-in-out infinite;
}
@keyframes pulse {
from { opacity: 1; }
to { opacity: .5; }
}
header {
position: fixed;
top: 0;
}
footer {
position: fixed;
bottom: 0;
}
header,
footer {
width: calc(100vw - 2.8em);
padding: 1em 1.4em;
display: flex;
flex-direction: row;
justify-content: space-between;
color: var(--on-bg-2);
}
header h3,
footer h3 {
margin: 0;
font-family: "Monaspace Argon", monospace;
}
#time {
font-family: "Monaspace Argon", monospace;
}
#cover {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: var(--bg-0);
transition: opacity 1s ease-out;
}
#cover.hidden {
opacity: 0;
pointer-events: none;
}

51
learning/public/cards.js Normal file
View file

@ -0,0 +1,51 @@
const timeEl = document.getElementById("time");
const titleEl = document.getElementById("title");
const coverEl = document.getElementById("cover");
function render() {
const date = new Date();
const tick = (date.getMilliseconds() * 0.001 % 1.0 > 0.5) ? ":" : " ";
const hh = `${date.getUTCHours()}`.padStart(2, "0");
const mm = `${date.getUTCMinutes()}`.padStart(2, "0");
const ss = `${date.getUTCSeconds()}`.padStart(2, "0");
timeEl.innerText = `${hh}${tick}${mm}${tick}${ss}`;
requestAnimationFrame(render);
}
function ready() {
coverEl.classList.add("hidden");
requestAnimationFrame(render);
}
async function updateTitle() {
if (!titleEl) {
ready();
return;
}
const eventSource = new EventSource("/learning/sse");
eventSource.addEventListener("open", () => {
console.log("Connected.");
eventSource.addEventListener("title-update", event => {
console.log(`title updated: \"${titleEl.innerText}\" -> \"${event.data}\"`)
titleEl.innerText = event.data;
ready();
});
eventSource.addEventListener("error", () => {
eventSource.removeEventListener("error");
eventSource.close();
console.error("Connection lost, or an error has occurred.");
console.log("Attempting to reconnect...");
setTimeout(() => {
updateTitle();
}, 1000);
});
});
}
updateTitle();