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:
commit
6927d54cbd
12 changed files with 675 additions and 0 deletions
167
learning/learning.go
Normal file
167
learning/learning.go
Normal 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
27
learning/pages/brb.html
Normal 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
27
learning/pages/bye.html
Normal 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
27
learning/pages/index.html
Normal 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
125
learning/public/cards.css
Normal 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
51
learning/public/cards.js
Normal 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue