From 5125614b938fbd351699b6110187dd13ee905239 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AD=A6=E8=94=B5=E4=B8=80=E5=BC=98?= Date: Sat, 3 Oct 2026 17:01:50 +0900 Subject: [PATCH] =?UTF-8?q?web=20=E3=82=B3=E3=83=B3=E3=83=86=E3=83=8A?= =?UTF-8?q?=E3=81=AE=E5=9C=9F=E5=8F=B0=E3=82=92=E8=BF=BD=E5=8A=A0=EF=BC=88?= =?UTF-8?q?HTTP=20=E3=82=B5=E3=83=BC=E3=83=90=E3=83=BC=E3=80=81TVer=20API?= =?UTF-8?q?=20=E3=82=AF=E3=83=A9=E3=82=A4=E3=82=A2=E3=83=B3=E3=83=88?= =?UTF-8?q?=E3=80=81htmx=20=E3=81=AE=E7=94=BB=E9=9D=A2=E6=9E=A0=E3=80=81Do?= =?UTF-8?q?ckerfile=E3=83=BBcompose.yaml=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 4 + Dockerfile | 15 ++ PROJECT.md | 20 ++- cmd/web/main.go | 24 +++ compose.yaml | 9 + go.mod | 3 + internal/tver/client.go | 154 +++++++++++++++++ internal/tver/client_test.go | 213 ++++++++++++++++++++++++ internal/tver/testdata/newer_drama.json | 91 ++++++++++ internal/web/handler.go | 43 +++++ internal/web/handler_test.go | 63 +++++++ internal/web/static/app.css | 5 + internal/web/static/htmx-2.0.11.min.js | 1 + internal/web/static/htmx-LICENSE.txt | 13 ++ internal/web/templates/layout.html | 24 +++ 15 files changed, 677 insertions(+), 5 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 cmd/web/main.go create mode 100644 compose.yaml create mode 100644 go.mod create mode 100644 internal/tver/client.go create mode 100644 internal/tver/client_test.go create mode 100644 internal/tver/testdata/newer_drama.json create mode 100644 internal/web/handler.go create mode 100644 internal/web/handler_test.go create mode 100644 internal/web/static/app.css create mode 100644 internal/web/static/htmx-2.0.11.min.js create mode 100644 internal/web/static/htmx-LICENSE.txt create mode 100644 internal/web/templates/layout.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4e0696c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +.gitignore +PROJECT.md +AGENTS.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9dceb66 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.27.1-alpine AS build + +WORKDIR /src +COPY go.mod ./ +COPY cmd ./cmd +COPY internal ./internal +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /web ./cmd/web + +FROM alpine:3.22 +RUN apk add --no-cache ca-certificates tzdata +WORKDIR /app +COPY --from=build /web /app/web +USER 65532:65532 +EXPOSE 8080 +ENTRYPOINT ["/app/web"] diff --git a/PROJECT.md b/PROJECT.md index 46b9705..f91b6db 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -3,7 +3,7 @@ title: tver の現状 tags: - homelab - tver -status: 要件定義中 +status: web 土台実装中 --- # tver の現状 @@ -13,7 +13,8 @@ status: 要件定義中 ## 今の状態 -- **要件定義中。** 実装はまだ無い +- **web の土台を実装中。** Go の HTTP サーバー、TVer API クライアント、画面の空のページ、テスト、web コンテナの定義がある +- 新着一覧の画面表示、DB、worker は未実装 - リポジトリ: `https://gitea.kazu634.com/kazu634/tver.git` ## 目的 @@ -65,11 +66,14 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ ## 構成(案) -| コンテナ | 役割 | -|---|---| -| `web` | UI・API。新着一覧の取得、待ち行列への登録、番組登録、動画の配信(再生) | +| コンテナ | 役割 | +| -------- | ----------------------------------------------------- | +| `web` | UI・API。新着一覧の取得、待ち行列への登録、番組登録、動画の配信(再生) | | `worker` | Go + yt-dlp + ffmpeg。待ち行列からダウンロードし、登録番組の新しい回を定期的に確認する | +- 現在の `web` は `/healthz` と「新着/待ち行列/登録番組/ライブラリ」の空ページを提供する。`internal/tver` はセッション発行とドラマ・バラエティの新着取得を実装済みだが、画面からはまだ呼び出さない +- 画面は `html/template` と htmx 2.0.11 を使い、htmx は Go の `embed` から `/static/` でローカル配信する +- `compose.yaml` は web のみを定義し、dockage VM の LAN アドレス `192.168.10.155:8080` で公開する。待ち受けは `WEB_ADDR` で変更でき、既定は `:8080` - DB はコンテナを持たず、NAS の共有 Postgres に接続する(番組登録・ダウンロード履歴・待ち行列) - 保存先の NAS 共有フォルダは dockage VM に NFS でマウントし、`worker`(書き込み)と `web`(読み取り・削除)にバインドマウントする @@ -98,3 +102,9 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ - 非公開 API と yt-dlp の TVer 対応は、TVer 側の変更で予告なく壊れうる。壊れたことを検知する手段が要る - NAS の準備(NFS 共有フォルダ、共有 Postgres へのデータベース・ユーザー作成、DSM ファイアウォール)は DSM での手作業になる。共有 Postgres の現行の公開ポートは [[homelab/docs/inventory|インフラ詳細]] に記載が無い [要確認] - Go から yt-dlp の進捗を読み取る方式(`--progress-template` と `--print` で 1 行 1 JSON を出す等)を実機で確かめる [要確認] + +## 確かめ方 + +- `env GOCACHE=/private/tmp/tver-go-cache GOPROXY=off go test -count=1 ./...` で Go のテストを実行する。テストの TVer 応答は `internal/tver/testdata/newer_drama.json`(2026-10-03 の実応答を 3 件に縮小)と `httptest` を使い、テストから本物の TVer にはアクセスしない +- `env GOCACHE=/private/tmp/tver-go-cache GOPROXY=off go vet ./...` で静的解析する +- `WEB_ADDR=127.0.0.1:18080 go run ./cmd/web` で起動し、`/healthz` と `/` を確認できる。Dockerfile と Compose のビルド・実行は Docker が使える環境で別途確認が必要 diff --git a/cmd/web/main.go b/cmd/web/main.go new file mode 100644 index 0000000..c7f48e8 --- /dev/null +++ b/cmd/web/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "log" + "net/http" + "os" + "time" + + "gitea.kazu634.com/kazu634/tver/internal/web" +) + +func main() { + address := os.Getenv("WEB_ADDR") + if address == "" { + address = ":8080" + } + server := &http.Server{ + Addr: address, + Handler: web.NewHandler(), + ReadHeaderTimeout: 5 * time.Second, + } + log.Printf("web listening on %s", address) + log.Fatal(server.ListenAndServe()) +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..f00af23 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,9 @@ +services: + web: + build: . + environment: + TZ: Asia/Tokyo + WEB_ADDR: ":8080" + ports: + - "192.168.10.155:8080:8080" + restart: unless-stopped diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..179554c --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module gitea.kazu634.com/kazu634/tver + +go 1.27.1 diff --git a/internal/tver/client.go b/internal/tver/client.go new file mode 100644 index 0000000..62d2ef0 --- /dev/null +++ b/internal/tver/client.go @@ -0,0 +1,154 @@ +package tver + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const DefaultBaseURL = "https://platform-api.tver.jp" + +type Session struct { + PlatformUID string `json:"platform_uid"` + PlatformToken string `json:"platform_token"` +} + +type Episode struct { + ID string `json:"id"` + SeriesID string `json:"seriesID"` + SeriesTitle string `json:"seriesTitle"` + Title string `json:"title"` + BroadcasterName string `json:"broadcasterName"` + Duration int64 `json:"duration"` + EndAt int64 `json:"endAt"` + ThumbnailPath string `json:"thumbnailPath"` + BroadcastDateLabel string `json:"broadcastDateLabel"` +} + +type Client struct { + baseURL *url.URL + httpClient *http.Client +} + +// NewClient はテスト用に API の接続先と HTTP クライアントを差し替えられる。 +// 接続先が空、またはクライアントが nil の場合は本番用の既定値を使う。 +func NewClient(baseURL string, httpClient *http.Client) (*Client, error) { + if baseURL == "" { + baseURL = DefaultBaseURL + } + parsed, err := url.Parse(baseURL) + if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, fmt.Errorf("invalid TVer API base URL") + } + if httpClient == nil { + httpClient = &http.Client{Timeout: 15 * time.Second} + } + return &Client{baseURL: parsed, httpClient: httpClient}, nil +} + +func (c *Client) CreateSession(ctx context.Context) (Session, error) { + endpoint := c.baseURL.ResolveReference(&url.URL{Path: "/v2/api/platform_users/browser/create"}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader("device_type=pc")) + if err != nil { + return Session{}, fmt.Errorf("create session request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + var response struct { + Code int `json:"code"` + Message string `json:"message"` + Result Session `json:"result"` + } + if err := c.getJSON(req, &response); err != nil { + return Session{}, fmt.Errorf("create session: %w", err) + } + if response.Code != 0 || response.Result.PlatformUID == "" || response.Result.PlatformToken == "" { + return Session{}, fmt.Errorf("create session: invalid API result (code %d)", response.Code) + } + return response.Result, nil +} + +func (c *Client) ListNewer(ctx context.Context, category string, session Session) ([]Episode, error) { + if category != "drama" && category != "variety" { + return nil, fmt.Errorf("unsupported category %q", category) + } + if session.PlatformUID == "" || session.PlatformToken == "" { + return nil, errors.New("missing TVer session credentials") + } + endpoint := c.baseURL.ResolveReference(&url.URL{Path: "/service/api/v1/callNewerDetail/" + category}) + query := endpoint.Query() + query.Set("platform_uid", session.PlatformUID) + query.Set("platform_token", session.PlatformToken) + endpoint.RawQuery = query.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, fmt.Errorf("newer list request: %w", err) + } + req.Header.Set("x-tver-platform-type", "web") + req.Header.Set("Origin", "https://tver.jp") + req.Header.Set("Referer", "https://tver.jp/") + var response struct { + Code int `json:"code"` + Result struct { + Contents *struct { + Contents []struct { + Type string `json:"type"` + Content Episode `json:"content"` + } `json:"contents"` + } `json:"contents"` + } `json:"result"` + } + if err := c.getJSON(req, &response); err != nil { + return nil, fmt.Errorf("newer list: %w", err) + } + if response.Code != 0 { + return nil, fmt.Errorf("newer list: API code %d", response.Code) + } + if response.Result.Contents == nil || response.Result.Contents.Contents == nil { + return nil, errors.New("newer list: missing contents") + } + var episodes []Episode + for _, item := range response.Result.Contents.Contents { + if item.Type != "episode" { + continue + } + if item.Content.ID == "" { + return nil, errors.New("newer list: episode without id") + } + episodes = append(episodes, item.Content) + } + return episodes, nil +} + +func (c *Client) FetchNewer(ctx context.Context, category string) ([]Episode, error) { + session, err := c.CreateSession(ctx) + if err != nil { + return nil, err + } + return c.ListNewer(ctx, category, session) +} + +func (c *Client) getJSON(req *http.Request, dst any) error { + response, err := c.httpClient.Do(req) + if err != nil { + // url.Error には platform_token を含むリクエスト URL が入るため、内部のエラーだけを返す。 + var urlErr *url.Error + if errors.As(err, &urlErr) { + err = urlErr.Err + } + return fmt.Errorf("request failed: %w", err) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("HTTP %d", response.StatusCode) + } + if err := json.NewDecoder(io.LimitReader(response.Body, 8<<20)).Decode(dst); err != nil { + return fmt.Errorf("decode JSON: %w", err) + } + return nil +} diff --git a/internal/tver/client_test.go b/internal/tver/client_test.go new file mode 100644 index 0000000..fd47ed4 --- /dev/null +++ b/internal/tver/client_test.go @@ -0,0 +1,213 @@ +package tver + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" +) + +type localOnlyTransport struct { + host string + base http.RoundTripper +} + +func (tr localOnlyTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.URL.Scheme != "http" || req.URL.Host != tr.host { + return nil, fmt.Errorf("test attempted external request to %s", req.URL.Host) + } + return tr.base.RoundTrip(req) +} + +func testClient(t *testing.T, handler http.HandlerFunc) *Client { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + serverURL, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + httpClient := server.Client() + baseTransport := httpClient.Transport + if baseTransport == nil { + baseTransport = http.DefaultTransport + } + httpClient.Transport = localOnlyTransport{host: serverURL.Host, base: baseTransport} + client, err := NewClient(server.URL, httpClient) + if err != nil { + t.Fatal(err) + } + return client +} + +func TestTestClientRejectsExternalRequests(t *testing.T) { + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected local request to %s", r.URL) + }) + _, err := client.httpClient.Get(DefaultBaseURL) + if err == nil || !strings.Contains(err.Error(), "test attempted external request") { + t.Errorf("external request error = %v", err) + } +} + +func TestFetchNewerFromRecordedResponse(t *testing.T) { + fixture, err := os.ReadFile("testdata/newer_drama.json") + if err != nil { + t.Fatal(err) + } + var sessionCalls, newerCalls int + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/api/platform_users/browser/create": + sessionCalls++ + if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { + t.Errorf("session request method/Content-Type: %s %s", r.Method, r.Header.Get("Content-Type")) + } + if err := r.ParseForm(); err != nil || r.PostForm.Get("device_type") != "pc" { + t.Errorf("session form: %v, %v", r.PostForm, err) + } + _, _ = w.Write([]byte(`{"code":0,"result":{"platform_uid":"uid-test","platform_token":"token-test"}}`)) + case "/service/api/v1/callNewerDetail/drama": + newerCalls++ + if r.Method != http.MethodGet || r.URL.Query().Get("platform_uid") != "uid-test" || r.URL.Query().Get("platform_token") != "token-test" { + t.Errorf("newer request method/query: %s %v", r.Method, r.URL.Query()) + } + for name, want := range map[string]string{"x-tver-platform-type": "web", "Origin": "https://tver.jp", "Referer": "https://tver.jp/"} { + if got := r.Header.Get(name); got != want { + t.Errorf("header %s = %q; want %q", name, got, want) + } + } + _, _ = w.Write(fixture) + default: + t.Errorf("unexpected path %s", r.URL.Path) + http.NotFound(w, r) + } + }) + episodes, err := client.FetchNewer(context.Background(), "drama") + if err != nil { + t.Fatal(err) + } + if sessionCalls != 1 || newerCalls != 1 { + t.Fatalf("calls: session=%d newer=%d", sessionCalls, newerCalls) + } + if len(episodes) != 3 { + t.Fatalf("episodes = %d; want 3", len(episodes)) + } + want := Episode{ + ID: "eps0z5761a", + SeriesID: "sr0lhdrtdb", + SeriesTitle: "韓国ドラマ「良いが悪い、ドンジェ」", + Title: "#10(最終話)", + BroadcasterName: "BS12トゥエルビ", + Duration: 2712, + EndAt: 1792209540, + ThumbnailPath: "/images/content/thumbnail/episode/xlarge/eps0z5761a.jpg?v=b45ddb51", + BroadcastDateLabel: "10月3日(土)放送分", + } + if episodes[0] != want { + t.Errorf("first episode = %+v; want %+v", episodes[0], want) + } + if episodes[2].Duration != 60 || episodes[2].BroadcastDateLabel != "" { + t.Errorf("third episode = %+v", episodes[2]) + } +} + +func TestListNewerVarietySkipsOtherTypes(t *testing.T) { + var calls int + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.URL.Path != "/service/api/v1/callNewerDetail/variety" { + t.Errorf("path = %s", r.URL.Path) + } + if r.Method != http.MethodGet || r.URL.Query().Get("platform_uid") != "u" || r.URL.Query().Get("platform_token") != "t" { + t.Errorf("variety request method/query: %s %v", r.Method, r.URL.Query()) + } + for name, want := range map[string]string{"x-tver-platform-type": "web", "Origin": "https://tver.jp", "Referer": "https://tver.jp/"} { + if got := r.Header.Get(name); got != want { + t.Errorf("variety header %s = %q; want %q", name, got, want) + } + } + _, _ = w.Write([]byte(`{"code":0,"result":{"contents":{"contents":[{"type":"series","content":{"id":"series-1"}},{"type":"episode","content":{"id":"episode-1","duration":42}}]}}}`)) + }) + episodes, err := client.ListNewer(context.Background(), "variety", Session{PlatformUID: "u", PlatformToken: "t"}) + if err != nil { + t.Fatal(err) + } + if len(episodes) != 1 || episodes[0].ID != "episode-1" || episodes[0].Duration != 42 { + t.Errorf("episodes = %+v", episodes) + } + if calls != 1 { + t.Errorf("variety calls = %d; want 1", calls) + } +} + +func TestFetchNewerErrors(t *testing.T) { + tests := []struct { + name string + sessionStatus int + sessionBody string + newerStatus int + newerBody string + want string + }{ + {"session HTTP error", 503, "", 200, "", "create session: HTTP 503"}, + {"session invalid JSON", 200, "{", 200, "", "create session: decode JSON"}, + {"session API error", 200, `{"code":7,"result":{"platform_uid":"u","platform_token":"t"}}`, 200, "", "create session: invalid API result"}, + {"session missing credentials", 200, `{"code":0,"result":{}}`, 200, "", "create session: invalid API result"}, + {"session missing token", 200, `{"code":0,"result":{"platform_uid":"u"}}`, 200, "", "create session: invalid API result"}, + {"session missing uid", 200, `{"code":0,"result":{"platform_token":"t"}}`, 200, "", "create session: invalid API result"}, + {"newer HTTP error", 200, `{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`, 502, "", "newer list: HTTP 502"}, + {"newer invalid JSON", 200, `{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`, 200, "{", "newer list: decode JSON"}, + {"newer API error", 200, `{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`, 200, `{"code":7}`, "newer list: API code 7"}, + {"newer missing contents", 200, `{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`, 200, `{"code":0,"result":{}}`, "newer list: missing contents"}, + {"newer missing items", 200, `{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`, 200, `{"code":0,"result":{"contents":{}}}`, "newer list: missing contents"}, + {"episode missing id", 200, `{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`, 200, `{"code":0,"result":{"contents":{"contents":[{"type":"episode","content":{"title":"untitled"}}]}}}`, "newer list: episode without id"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var sessionCalls, newerCalls int + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/api/platform_users/browser/create": + sessionCalls++ + w.WriteHeader(tt.sessionStatus) + _, _ = w.Write([]byte(tt.sessionBody)) + case "/service/api/v1/callNewerDetail/drama": + newerCalls++ + w.WriteHeader(tt.newerStatus) + _, _ = w.Write([]byte(tt.newerBody)) + default: + t.Errorf("unexpected path %s", r.URL.Path) + http.NotFound(w, r) + } + }) + _, err := client.FetchNewer(context.Background(), "drama") + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Errorf("error = %v; want %q", err, tt.want) + } + wantNewerCalls := 1 + if strings.HasPrefix(tt.name, "session ") { + wantNewerCalls = 0 + } + if sessionCalls != 1 || newerCalls != wantNewerCalls { + t.Errorf("calls: session=%d newer=%d; want session=1 newer=%d", sessionCalls, newerCalls, wantNewerCalls) + } + }) + } +} + +func TestListNewerRejectsInvalidInput(t *testing.T) { + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request to %s", r.URL) + }) + if _, err := client.ListNewer(context.Background(), "news", Session{PlatformUID: "u", PlatformToken: "t"}); err == nil { + t.Error("unsupported category accepted") + } + if _, err := client.ListNewer(context.Background(), "drama", Session{}); err == nil { + t.Error("empty session accepted") + } +} diff --git a/internal/tver/testdata/newer_drama.json b/internal/tver/testdata/newer_drama.json new file mode 100644 index 0000000..e3866cd --- /dev/null +++ b/internal/tver/testdata/newer_drama.json @@ -0,0 +1,91 @@ +{ + "api_version": "", + "code": 0, + "message": "", + "type": "hasharray", + "result": { + "contents": { + "type": "newer", + "content": { + "id": "drama", + "version": 1, + "title": "ドラマ" + }, + "thumbnailType": "horizontal", + "contents": [ + { + "type": "episode", + "content": { + "id": "eps0z5761a", + "version": 4, + "title": "#10(最終話)", + "seriesID": "sr0lhdrtdb", + "duration": 2712, + "endAt": 1792209540, + "broadcastDateLabel": "10月3日(土)放送分", + "isNHKContent": false, + "isSubtitle": false, + "ribbonID": 0, + "seriesTitle": "韓国ドラマ「良いが悪い、ドンジェ」", + "isAvailable": true, + "broadcasterName": "BS12トゥエルビ", + "productionProviderName": "BS12 トゥエルビ", + "isEndingSoon": false, + "thumbnailPath": "/images/content/thumbnail/episode/xlarge/eps0z5761a.jpg?v=b45ddb51", + "isChannelDefaultSeries": false + }, + "startAt": 1791007200, + "endAt": 1792209540 + }, + { + "type": "episode", + "content": { + "id": "epo4tfi10u", + "version": 4, + "title": "#9", + "seriesID": "sr0lhdrtdb", + "duration": 2712, + "endAt": 1792209540, + "broadcastDateLabel": "10月3日(土)放送分", + "isNHKContent": false, + "isSubtitle": false, + "ribbonID": 0, + "seriesTitle": "韓国ドラマ「良いが悪い、ドンジェ」", + "isAvailable": true, + "broadcasterName": "BS12トゥエルビ", + "productionProviderName": "BS12 トゥエルビ", + "isEndingSoon": false, + "thumbnailPath": "/images/content/thumbnail/episode/xlarge/epo4tfi10u.jpg?v=2d8ff444", + "isChannelDefaultSeries": false + }, + "startAt": 1791007200, + "endAt": 1792209540 + }, + { + "type": "episode", + "content": { + "id": "ep8cxuessg", + "version": 6, + "title": "【予告】10月12日(月)24時24分 ~ 放送予定 60秒PR!", + "seriesID": "srne6nwihe", + "duration": 60, + "endAt": 1800282240, + "broadcastDateLabel": "", + "isNHKContent": false, + "isSubtitle": false, + "ribbonID": 1, + "seriesTitle": "十六夜膳房 -いざよいぜんぼう-", + "isAvailable": true, + "broadcasterName": "日テレ", + "productionProviderName": "日テレ", + "isEndingSoon": false, + "thumbnailPath": "/images/content/thumbnail/episode/xlarge/ep8cxuessg.jpg?v=58408db2", + "isChannelDefaultSeries": false + }, + "startAt": 1791007200, + "endAt": 1800282240 + } + ] + } + } +} diff --git a/internal/web/handler.go b/internal/web/handler.go new file mode 100644 index 0000000..5aba47f --- /dev/null +++ b/internal/web/handler.go @@ -0,0 +1,43 @@ +package web + +import ( + "embed" + "html/template" + "io/fs" + "net/http" +) + +//go:embed templates/*.html static/* +var assets embed.FS + +type page struct { + Title string +} + +func NewHandler() http.Handler { + tmpl := template.Must(template.ParseFS(assets, "templates/*.html")) + staticFiles, err := fs.Sub(assets, "static") + if err != nil { + panic(err) + } + mux := http.NewServeMux() + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticFiles))) + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte("ok\n")) + }) + for path, title := range map[string]string{ + "GET /{$}": "新着", + "GET /queue": "待ち行列", + "GET /subscriptions": "登録番組", + "GET /library": "ライブラリ", + } { + mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.ExecuteTemplate(w, "layout", page{Title: title}); err != nil { + http.Error(w, "template error", http.StatusInternalServerError) + } + }) + } + return mux +} diff --git a/internal/web/handler_test.go b/internal/web/handler_test.go new file mode 100644 index 0000000..de4681f --- /dev/null +++ b/internal/web/handler_test.go @@ -0,0 +1,63 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHealthz(t *testing.T) { + response := httptest.NewRecorder() + NewHandler().ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if response.Code != http.StatusOK || response.Body.String() != "ok\n" { + t.Errorf("healthz: status=%d body=%q", response.Code, response.Body.String()) + } +} + +func TestPagesAndEmbeddedAssets(t *testing.T) { + handler := NewHandler() + for _, tt := range []struct{ path, title string }{ + {"/", "新着"}, {"/queue", "待ち行列"}, {"/subscriptions", "登録番組"}, {"/library", "ライブラリ"}, + } { + t.Run(tt.path, func(t *testing.T) { + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, tt.path, nil)) + body := response.Body.String() + if response.Code != http.StatusOK || !strings.Contains(response.Header().Get("Content-Type"), "text/html") { + t.Fatalf("status=%d Content-Type=%q", response.Code, response.Header().Get("Content-Type")) + } + for _, want := range []string{"

" + tt.title + "

", "新着", "待ち行列", "登録番組", "ライブラリ", `hx-boost="true"`, "/static/htmx-2.0.11.min.js"} { + if !strings.Contains(body, want) { + t.Errorf("page missing %q", want) + } + } + }) + } + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/static/htmx-2.0.11.min.js", nil)) + if response.Code != http.StatusOK || !strings.Contains(response.Header().Get("Content-Type"), "javascript") || response.Body.Len() < 1000 || !strings.Contains(response.Body.String(), "htmx") { + t.Errorf("embedded htmx: status=%d Content-Type=%q bytes=%d", response.Code, response.Header().Get("Content-Type"), response.Body.Len()) + } +} + +func TestUnknownPathsAndMethods(t *testing.T) { + handler := NewHandler() + for _, tt := range []struct { + name, method, path string + wantStatus int + }{ + {"unknown page", http.MethodGet, "/missing", http.StatusNotFound}, + {"root prefix", http.MethodGet, "/missing/nested", http.StatusNotFound}, + {"missing static asset", http.MethodGet, "/static/missing.js", http.StatusNotFound}, + {"unsupported page method", http.MethodPost, "/queue", http.StatusMethodNotAllowed}, + } { + t.Run(tt.name, func(t *testing.T) { + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(tt.method, tt.path, nil)) + if response.Code != tt.wantStatus { + t.Errorf("%s %s: status=%d; want %d", tt.method, tt.path, response.Code, tt.wantStatus) + } + }) + } +} diff --git a/internal/web/static/app.css b/internal/web/static/app.css new file mode 100644 index 0000000..522afa0 --- /dev/null +++ b/internal/web/static/app.css @@ -0,0 +1,5 @@ +body { font-family: system-ui, sans-serif; margin: 0; color: #17212b; } +header { display: flex; align-items: center; gap: 2rem; padding: 1rem 2rem; border-bottom: 1px solid #d6dee4; } +nav { display: flex; flex-wrap: wrap; gap: 1.5rem; } +nav a { color: #145a86; } +main { max-width: 72rem; margin: 2rem auto; padding: 0 2rem; } diff --git a/internal/web/static/htmx-2.0.11.min.js b/internal/web/static/htmx-2.0.11.min.js new file mode 100644 index 0000000..e6b8394 --- /dev/null +++ b/internal/web/static/htmx-2.0.11.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Y={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=pn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true,historyRestoreAsHxRequest:true,reportValidityOfForms:false},parseInterval:null,location:location,_:null,version:"2.0.11"};Y.onLoad=V;Y.process=Bt;Y.on=ye;Y.off=xe;Y.trigger=ae;Y.ajax=In;Y.find=a;Y.findAll=x;Y.closest=g;Y.remove=z;Y.addClass=w;Y.removeClass=S;Y.toggleClass=G;Y.takeClass=W;Y.swap=C;Y.defineExtension=zn;Y.removeExtension=Jn;Y.logAll=$;Y.logNone=_;Y.parseInterval=d;Y._=e;const n={addTriggerHandler:Et,bodyContains:ie,canAccessLocalStorage:U,findThisElement:we,filterValues:xn,swap:C,hasAttribute:s,getAttributeValue:f,getClosestAttributeValue:te,getClosestMatch:A,getExpressionVars:qn,getHeaders:yn,getInputValues:pn,getInternalData:re,getSwapSpecification:vn,getTriggerSpecs:lt,getTarget:Se,makeFragment:D,mergeObjects:se,makeSettleInfo:En,oobSwap:Te,querySelectorExt:ce,settleImmediately:Qt,shouldCancel:dt,triggerEvent:ae,triggerErrorEvent:ue,withExtensions:Vt};const he=["get","post","put","delete","patch"];const R=he.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function Q(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function f(e,t){return Q(e,t)||Q(e,"data-"+t)}function u(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ee(){return document}function q(e,t){return e.getRootNode?e.getRootNode({composed:t}):ee()}function A(e,t){while(e&&!t(e)){e=u(e)}return e||null}function o(e,t,n){const r=f(t,n);const o=f(t,"hx-disinherit");var i=f(t,"hx-inherit");if(e!==t){if(Y.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function te(t,n){let r=null;A(t,function(e){return!!(r=o(t,le(e),n))});if(r!=="unset"){return r}}function h(e,t){return e instanceof Element&&e.matches(t)}function N(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function I(e){if("parseHTMLUnsafe"in Document){return Document.parseHTMLUnsafe(e)}const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function r(e){const t=ee().createElement("script");oe(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Y.config.inlineScriptNonce){t.nonce=Y.config.inlineScriptNonce}return t}function i(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function k(e){if(!Y.config.allowScriptTags){e.querySelectorAll("script").forEach(e=>e.remove());return}Array.from(e.querySelectorAll("script")).forEach(e=>{if(i(e)){const t=r(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){T(e)}finally{e.remove()}}})}function D(e){e=e.replace(/]*)?)>/gi,'");const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=N(t);let r;if(n==="html"){r=new DocumentFragment;const i=I(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=I(t);L(r,i.body);r.title=i.title}else{const i=I('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){k(r)}return r}function ne(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function P(e){return typeof e==="function"}function M(e){return t(e,"Object")}function re(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function F(t){const n=[];if(t){for(let e=0;e=0}function ie(e){return e.getRootNode({composed:true})===document}function X(e){return e.trim().split(/\s+/)}function se(e,t){return Object.assign({},e,t)}function v(e){try{return JSON.parse(e)}catch(e){T(e);return null}}function U(){const e="htmx:sessionStorageTest";try{sessionStorage.setItem(e,e);sessionStorage.removeItem(e);return true}catch(e){return false}}function j(e){try{const t=new URL(e,window.location.href);e=t.pathname+t.search}catch(e){}if(e!="/"){e=e.replace(/\/+$/,"")}return e}function e(e){return Tn(ee().body,function(){return eval(e)})}function V(t){const e=Y.on("htmx:load",function(e){t(e.detail.elt)});return e}function $(){Y.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Y.logger=null}function a(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return a(ee(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ee(),e)}}function b(){return window}function z(e,t){e=E(e);if(t){b().setTimeout(function(){z(e);e=null},t)}else{u(e).removeChild(e)}}function le(e){return e instanceof Element?e:null}function J(e){return e instanceof HTMLElement?e:null}function K(e){return typeof e==="string"?e:null}function p(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function w(e,t,n){e=le(E(e));if(!e){return}if(n){b().setTimeout(function(){w(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function S(e,t,n){let r=le(E(e));if(!r){return}if(n){b().setTimeout(function(){S(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function G(e,t){e=E(e);e.classList.toggle(t)}function W(e,t){e=E(e);oe(e.parentElement.children,function(e){S(e,t)});w(le(e),t)}function g(e,t){e=le(E(e));if(e){return e.closest(t)}return null}function l(e,t){return e.substring(0,t.length)===t}function Z(e,t){return e.substring(e.length-t.length)===t}function de(e){const t=e.trim();if(l(t,"<")&&Z(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function m(t,r,n){if(r.indexOf("global ")===0){return m(t,r.slice(7),true)}t=E(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=de(o.shift());let e;if(r.indexOf("closest ")===0){e=g(le(t),de(r.slice(8)))}else if(r.indexOf("find ")===0){e=a(p(t),de(r.slice(5)))}else if(r==="next"||r==="nextElementSibling"){e=le(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,de(r.slice(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=le(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=ge(t,de(r.slice(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=q(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=p(q(t,!!n));i.push(...F(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=p(q(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ce(e,t){if(typeof e!=="string"){return m(e,t)[0]}else{return m(ee().body,e)[0]}}function E(e,t){if(typeof e==="string"){return a(p(t)||document,e)}else{return e}}function me(e,t,n,r){if(P(t)){return{target:ee().body,event:K(e),listener:t,options:n}}else{return{target:E(e),event:K(t),listener:n,options:r}}}function ye(t,n,r,o){Wn(function(){const e=me(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=P(n);return e?n:r}function xe(t,n,r){Wn(function(){const e=me(t,n,r);e.target.removeEventListener(e.event,e.listener)});return P(n)?n:r}const be=ee().createElement("output");function ve(t,n){const e=te(t,n);if(e){if(e==="this"){return[we(t,n)]}else{const r=m(t,e);const o=/(^|,)(\s*)inherit(\s*)($|,)/.test(e);if(o){const i=le(A(t,function(e){return e!==t&&s(le(e),n)}));if(i){r.push(...ve(i,n))}}if(r.length===0){T('The selector "'+e+'" on '+n+" returned no matches!");return[be]}else{return r}}}}function we(e,t){return le(A(e,function(e){return f(le(e),t)!=null}))}function Se(e){const t=te(e,"hx-target");if(t){if(t==="this"){return we(e,"hx-target")}else{return ce(e,t)}}else{const n=re(e);if(n.boosted){return ee().body}else{return e}}}function Ee(e){return Y.config.attributesToSettle.includes(e)}function Ce(t,n){oe(Array.from(t.attributes),function(e){if(!n.hasAttribute(e.name)&&Ee(e.name)){t.removeAttribute(e.name)}});oe(n.attributes,function(e){if(Ee(e.name)){t.setAttribute(e.name,e.value)}})}function Oe(t,e){const n=Kn(e);for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=m(t,n,false);if(r.length){oe(r,function(e){let t;const n=o.cloneNode(true);t=ee().createDocumentFragment();t.appendChild(n);if(!Oe(s,e)){t=p(n)}const r={shouldSwap:true,target:e,fragment:t};if(!ae(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){Re(t);Ve(s,e,e,t,i);He()}oe(i.elts,function(e){ae(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);ue(ee().body,"htmx:oobErrorNoTarget",{content:o,target:n})}return e}function He(){const e=a("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=a("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function Re(e){oe(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=f(e,"id");const n=ee().getElementById(t);if(n!=null){if(e.moveBefore){let e=a("#--htmx-preserve-pantry--");if(e==null){ee().body.insertAdjacentHTML("afterend","
");e=a("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function qe(i,e,s){oe(e.querySelectorAll("[id]"),function(t){const n=Q(t,"id");if(n&&n.length>0){const e=p(i);const r=e&&e.querySelector(CSS.escape(t.tagName)+"#"+CSS.escape(n));if(r&&r!==e){const o=t.cloneNode();Ce(t,r);s.tasks.push(function(){Ce(t,o)})}}})}function Ae(e){return function(){S(e,Y.config.addedClass);Bt(le(e));Ne(p(e));ae(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=J(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function c(e,t,n,r){qe(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;w(le(o),Y.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function _e(e,n,r){var t=x(e,"[hx-swap-oob], [data-hx-swap-oob]");oe(t,function(e){if(Y.config.allowNestedOobSwaps||e.parentElement===null){const t=f(e,"hx-swap-oob");if(t!=null){Te(t,e,n,r)}}else{e.removeAttribute("hx-swap-oob");e.removeAttribute("data-hx-swap-oob")}});return t.length>0}function C(d,p,g,m){if(!m){m={}}let y=null;let n=null;let e=function(){ne(m.beforeSwapCallback);d=E(d);const r=m.contextElement&&m.contextElement.isConnected?q(m.contextElement,false):ee();const e=document.activeElement;let t={};t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null};const o=En(d);if(g.swapStyle==="textContent"){d.textContent=p}else{let n=typeof p==="string"?D(p):p;o.title=m.title||n.title;if(m.historyRequest){n=n.querySelector("[hx-history-elt],[data-hx-history-elt]")||n}if(m.selectOOB){const s=m.selectOOB.split(",");for(let t=0;t0){b().setTimeout(n,g.settleDelay)}else{n()}};let t=Y.config.globalViewTransitions;if(g.hasOwnProperty("transition")){t=g.transition}const r=m.contextElement||ee();if(t&&ae(r,"htmx:beforeTransition",m.eventInfo)&&typeof Promise!=="undefined"&&document.startViewTransition){const o=new Promise(function(e,t){y=e;n=t});const i=e;e=function(){document.startViewTransition(function(){i();return o})}}try{if(g?.swapDelay&&g.swapDelay>0){b().setTimeout(e,g.swapDelay)}else{e()}}catch(e){ue(r,"htmx:swapError",m.eventInfo);ne(n);throw e}}function ze(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=v(r)||{};for(const i of Object.keys(o)){let e=o[i];if(M(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}ae(n,i,e)}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=Tn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){ue(ee().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(nt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function O(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function ot(e){let t;if(e.length>0&&Qe.test(e[0])){e.shift();t=O(e,et).trim();e.shift()}else{t=O(e,Ke)}return t}const it="input, textarea, select";function st(e,t,n){const r=[];const o=tt(t);do{O(o,Ye);const l=o.length;const c=O(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};O(o,Ye);u.pollInterval=d(O(o,/[,\[\s]/));O(o,Ye);var i=rt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=rt(e,o,"event");if(i){a.eventFilter=i}O(o,Ye);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(O(o,Ke))}else if(f==="from"&&o[0]===":"){o.shift();if(Qe.test(o[0])){var s=ot(o)}else{var s=O(o,Ke);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=ot(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=ot(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(O(o,Ke))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=O(o,Ke)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=ot(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=O(o,Ke)}else{ue(e,"htmx:syntax:error",{token:o.shift()})}O(o,Ye)}r.push(a)}}if(o.length===l){ue(e,"htmx:syntax:error",{token:o.shift()})}O(o,Ye)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function lt(e){const t=f(e,"hx-trigger");let n=[];if(t){const r=Y.config.triggerSpecsCache;n=r&&r[t]||st(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,it)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function ct(e){re(e).cancelled=true}function ut(e,t,n){const r=re(e);r.timeout=b().setTimeout(function(){if(ie(e)&&r.cancelled!==true){if(!gt(n,e,Ut("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ut(e,t,n)}},n.pollInterval)}function at(e){return location.hostname===e.hostname&&Q(e,"href")&&Q(e,"href").indexOf("#")!==0}function ft(e){return g(e,Y.config.disableSelector)}function ht(t,n,e){if(t instanceof HTMLAnchorElement&&at(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(Q(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=Q(t,"href")}else{const i=Q(t,"method");r=i?i.toLowerCase():"get";o=Q(t,"action");if(o==null||o===""){o=location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){mt(t,function(e,t){const n=le(e);if(ft(n)){y(n);return}fe(r,o,n,t)},n,e,true)})}}function dt(e,t){if(e.type==="submit"&&t.tagName==="FORM"){return true}else if(e.type==="click"){const n=t.closest('input[type="submit"], button');if(n&&n.form&&n.type==="submit"){return true}const r=t.closest("a");const o=/^#.+/;if(r&&r.href&&!o.test(r.getAttribute("href"))){return true}}return false}function pt(e,t){return re(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;ue(ee().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function mt(l,c,e,u,a){const f=re(l);let t;if(u.from){t=m(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}oe(t,function(i){const s=function(e){if(!ie(l)){i.removeEventListener(u.trigger,s);return}if(pt(l,e)){return}if(a||dt(e,i)){e.preventDefault()}if(gt(u,l,e)){return}const t=re(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(le(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=e.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){ae(l,"htmx:trigger");c(l,e);f.throttle=b().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=b().setTimeout(function(){ae(l,"htmx:trigger");c(l,e)},u.delay)}else{ae(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let yt=false;let xt=null;function bt(){if(!xt){xt=function(){yt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(yt){yt=false;oe(ee().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){vt(e)})}},200)}}function vt(e){if(!s(e,"data-hx-revealed")&&B(e)){e.setAttribute("data-hx-revealed","true");const t=re(e);if(t.initHash){ae(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){ae(e,"revealed")},{once:true})}}}function wt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;ae(e,"htmx:trigger");t(e)}};if(r>0){b().setTimeout(o,r)}else{o()}}function St(t,n,e){let i=false;oe(he,function(r){if(s(t,"hx-"+r)){const o=f(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){Et(t,e,n,function(e,t){const n=le(e);if(ft(n)){y(n);return}fe(r,o,n,t)})})}});return i}function Et(r,e,t,n){if(e.trigger==="revealed"){bt();mt(r,n,t,e);vt(le(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ce(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ut(le(r),n,e)}else{mt(r,n,t,e)}}function Ct(e){const t=le(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function qt(e){const t=Nt(e.target);const n=Lt(e);if(n){n.lastButtonClicked=t}}function At(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Nt(e){return g(le(e),"button, input[type='submit']")}function It(e){return e.form||g(e,"form")}function Lt(e){const t=Nt(e.target);if(!t){return}const n=It(t);if(!n){return}return re(n)}function kt(e){e.addEventListener("click",qt);e.addEventListener("focusin",qt);e.addEventListener("focusout",At)}function Dt(t,e,n){const r=re(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){Tn(t,function(){if(ft(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function Pt(t){ke(t);for(let e=0;eY.config.historyCacheSize){i.shift()}while(i.length>0){try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){ue(ee().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Kt(t){if(!U()){return null}t=j(t);const n=v(sessionStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){r.response=this.response;ae(ee().body,"htmx:historyCacheMissLoad",r);C(r.historyElt,r.response,n,{contextElement:r.historyElt,historyRequest:true});_t(r.path);ae(ee().body,"htmx:historyRestore",{path:e,cacheMiss:true,serverResponse:r.response})}else{ue(ee().body,"htmx:historyCacheMissLoadError",r)}};if(ae(ee().body,"htmx:historyCacheMiss",r)){t.send()}}function tn(e){Wt();e=e||location.pathname+location.search;const t=Kt(e);if(t){const n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0,scroll:t.scroll};const r={path:e,item:t,historyElt:zt(),swapSpec:n};if(ae(ee().body,"htmx:historyCacheHit",r)){C(r.historyElt,t.content,n,{contextElement:r.historyElt,title:t.title});_t(r.path);ae(ee().body,"htmx:historyRestore",r)}}else{if(Y.config.refreshOnHistoryMiss){Y.location.reload(true)}else{en(e)}}}function nn(e){let t=ve(e,"hx-indicator");if(t==null){t=[e]}oe(t,function(e){const t=re(e);t.requestCount=(t.requestCount||0)+1;w(e,Y.config.requestClass)});return t}function rn(e){let t=ve(e,"hx-disabled-elt");if(t==null){t=[]}oe(t,function(e){const t=re(e);t.requestCount=(t.requestCount||0)+1;if(!e.hasAttribute("disabled")){e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")}});return t}function on(e,t){oe(e.concat(t),function(e){const t=re(e);t.requestCount=(t.requestCount||1)-1});oe(e,function(e){const t=re(e);if(t.requestCount===0){S(e,Y.config.requestClass)}});oe(t,function(e){const t=re(e);if(t.requestCount===0&&e.hasAttribute("data-disabled-by-htmx")){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function sn(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);oe(e,e=>r.append(t,e))}}function an(e){if(e instanceof HTMLSelectElement&&e.multiple){return F(e.querySelectorAll("option:checked")).map(function(e){return e.value})}if(e instanceof HTMLInputElement&&e.files){return F(e.files)}return e.value}function fn(t,n,r,e,o){if(e==null||sn(t,e)){return}else{t.push(e)}if(ln(e)){const i=Q(e,"name");cn(i,an(e),n);if(o){hn(e,r)}}if(e instanceof HTMLFormElement){oe(e.elements,function(e){if(t.indexOf(e)>=0){un(e.name,an(e),n)}else{t.push(e)}if(o){hn(e,r)}});new FormData(e).forEach(function(e,t){if(e instanceof File&&e.name===""){return}cn(t,e,n)})}}function hn(e,t){const n=e;if(n.willValidate){ae(n,"htmx:validation:validate");if(!n.checkValidity()){if(ae(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})&&!t.length&&Y.config.reportValidityOfForms){n.reportValidity()}t.push({elt:n,message:n.validationMessage,validity:n.validity})}}}function dn(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function pn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=re(e);if(s.lastButtonClicked&&!ie(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||f(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){fn(n,o,i,It(e),l)}fn(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&Q(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=Q(u,"name");cn(a,u.value,o)}const c=ve(e,"hx-include");oe(c,function(e){fn(n,r,i,le(e),l);if(!h(e,"form")){oe(p(e).querySelectorAll(it),function(e){fn(n,r,i,e,l)})}});dn(r,o);return{errors:i,formData:r,values:Mn(r)}}function gn(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function mn(e){e=Dn(e);let n="";e.forEach(function(e,t){n=gn(n,t,e)});return n}function yn(e,t,n){const r={"HX-Request":"true","HX-Trigger":Q(e,"id"),"HX-Trigger-Name":Q(e,"name"),"HX-Target":f(t,"id"),"HX-Current-URL":location.href};On(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(re(e).boosted){r["HX-Boosted"]="true"}return r}function xn(n,e){const t=te(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){oe(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;oe(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function bn(e){return!!Q(e,"href")&&Q(e,"href").indexOf("#")>=0}function vn(e,t){const n=t||te(e,"hx-swap");const r={swapStyle:re(e).boosted?"innerHTML":Y.config.defaultSwapStyle,swapDelay:Y.config.defaultSwapDelay,settleDelay:Y.config.defaultSettleDelay};if(Y.config.scrollIntoViewOnBoost&&re(e).boosted&&!bn(e)){r.show="top"}if(n){const s=X(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{T("Unknown modifier in hx-swap: "+l)}}}}return r}function wn(e){return te(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&Q(e,"enctype")==="multipart/form-data"}function Sn(t,n,r){let o=null;Vt(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(wn(n)){return dn(new FormData,Dn(r))}else{return mn(r)}}}function En(e){return{tasks:[],elts:[e]}}function Cn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=le(ce(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}if(typeof t.scroll==="number"){b().setTimeout(function(){window.scrollTo(0,t.scroll)},0)}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=le(ce(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Y.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Y.config.scrollBehavior})}}}function On(r,e,o,i,s){if(i==null){i={}}if(r==null){return i}const l=f(r,e);if(l){let e=l.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=Tn(r,function(){if(s){return Function("event","return ("+e+")").call(r,s)}else{return Function("return ("+e+")").call(r)}},{})}else{n=v(e)}for(const c of Object.keys(n)){if(i[c]==null){i[c]=n[c]}}}return On(le(u(r)),e,o,i,s)}function Tn(e,t,n){if(Y.config.allowEval){return t()}else{ue(e,"htmx:evalDisallowedError");return n}}function Hn(e,t,n){return On(e,"hx-vars",true,n,t)}function Rn(e,t,n){return On(e,"hx-vals",false,n,t)}function qn(e,t){return se(Hn(e,t),Rn(e,t))}function An(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function Nn(t){if(t.responseURL){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){ue(ee().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function H(e,t){return e.getResponseHeader(t)!==null}function In(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return fe(t,n,null,null,{targetOverride:E(r)||be,returnPromise:true})}else{let e=E(r.target);if(r.target&&!e||r.source&&!e&&!E(r.source)){e=be}return fe(t,n,E(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true,push:r.push,replace:r.replace,selectOOB:r.selectOOB})}}else{return fe(t,n,null,null,{returnPromise:true})}}function Ln(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function kn(e,t,n){const r=new URL(t,location.protocol!=="about:"?location.href:window.origin);const o=location.protocol!=="about:"?location.origin:window.origin;const i=o===r.origin;if(Y.config.selfRequestsOnly){if(!i){return false}}return ae(e,"htmx:validateUrl",se({url:r,sameHost:i},n))}function Dn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n of Object.keys(e)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}return t}function Pn(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function Mn(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Pn(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function fe(t,n,r,o,i,P){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ee().body}const M=i.handler||Vn;const F=i.select||null;if(!ie(r)){ne(s);return e}const c=i.targetOverride||le(Se(r));if(c==null||c==be){ue(r,"htmx:targetError",{target:te(r,"hx-target")});ne(l);return e}let u=re(r);const a=u.lastButtonClicked;if(a){const A=Q(a,"formaction");if(A!=null){n=A}const N=Q(a,"formmethod");if(N!=null){if(he.includes(N.toLowerCase())){t=N}else{ne(s);return e}}}const f=te(r,"hx-confirm");if(P===undefined){const K=function(e){return fe(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(ae(r,"htmx:confirm",G)===false){ne(s);return e}}let h=r;let d=te(r,"hx-sync");let p=null;let B=false;if(d){const I=d.split(":");const L=I[0].trim();if(L==="this"){h=we(r,"hx-sync")}else{h=le(ce(r,L))}d=(I[1]||"drop").trim();u=re(h);if(d==="drop"&&u.xhr&&u.abortable!==true){ne(s);return e}else if(d==="abort"){if(u.xhr){ne(s);return e}else{B=true}}else if(d==="replace"){ae(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");p=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){ae(h,"htmx:abort")}else{if(p==null){if(o){const k=re(o);if(k&&k.triggerSpec&&k.triggerSpec.queue){p=k.triggerSpec.queue}}if(p==null){p="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(p==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){fe(t,n,r,o,i)})}else if(p==="all"){u.queuedRequests.push(function(){fe(t,n,r,o,i)})}else if(p==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){fe(t,n,r,o,i)})}ne(s);return e}}const g=new XMLHttpRequest;u.xhr=g;u.abortable=B;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const X=te(r,"hx-prompt");if(X){var y=prompt(X);if(y===null||!ae(r,"htmx:prompt",{prompt:y,target:c})){ne(s);m();return e}}if(f&&!P){if(!confirm(f)){ne(s);m();return e}}let x=yn(r,c,y);if(t!=="get"&&!wn(r)){x["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){x=se(x,i.headers)}const U=pn(r,t);let b=U.errors;const j=U.formData;if(i.values){dn(j,Dn(i.values))}const V=Dn(qn(r,o));const v=dn(j,V);let w=xn(v,r);if(Y.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",Q(c,"id")||"true")}if(n==null||n===""){n=location.href}const S=On(r,"hx-request");const $=re(r).boosted;let E=Y.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:$,useUrlParams:E,formData:w,parameters:Mn(w),unfilteredFormData:v,unfilteredParameters:Mn(v),headers:x,elt:r,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Y.config.withCredentials,timeout:i.timeout||S.timeout||Y.config.timeout,path:n,triggeringEvent:o};if(!ae(r,"htmx:configRequest",C)){ne(s);m();return e}n=C.path;t=C.verb;x=C.headers;w=Dn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){ae(r,"htmx:validation:halted",C);ne(s);m();return e}const _=n.split("#");const z=_[0];const O=_[1];let T=n;if(E){T=z;const Z=!w.keys().next().done;if(Z){if(T.indexOf("?")<0){T+="?"}else{T+="&"}T+=mn(w);if(O){T+="#"+O}}}if(!kn(r,T,C)){ue(r,"htmx:invalidPath",C);ne(l);m();return e}g.open(t.toUpperCase(),T,true);g.overrideMimeType("text/html");g.withCredentials=C.withCredentials;g.timeout=C.timeout;if(S.noHeaders){}else{for(const D of Object.keys(x)){An(g,D,x[D])}}const H={xhr:g,target:c,requestConfig:C,etc:i,boosted:$,select:F,pathInfo:{requestPath:n,finalRequestPath:T,responsePath:null,anchor:O}};g.onload=function(){try{const t=Ln(r);H.pathInfo.responsePath=Nn(g);M(r,H);if(H.keepIndicators!==true){on(R,q)}ae(r,"htmx:afterRequest",H);ae(r,"htmx:afterOnLoad",H);if(!ie(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(ie(n)){e=n}}if(e){ae(e,"htmx:afterRequest",H);ae(e,"htmx:afterOnLoad",H)}}ne(s)}catch(e){ue(r,"htmx:onLoadError",se({error:e},H));throw e}finally{m()}};g.onerror=function(){on(R,q);ue(r,"htmx:afterRequest",H);ue(r,"htmx:sendError",H);ne(l);m()};g.onabort=function(){on(R,q);ue(r,"htmx:afterRequest",H);ue(r,"htmx:sendAbort",H);ne(l);m()};g.ontimeout=function(){on(R,q);ue(r,"htmx:afterRequest",H);ue(r,"htmx:timeout",H);ne(l);m()};if(!ae(r,"htmx:beforeRequest",H)){ne(s);m();return e}var R=nn(r);var q=rn(r);oe(["loadstart","loadend","progress","abort"],function(t){oe([g,g.upload],function(e){e.addEventListener(t,function(e){ae(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ae(r,"htmx:beforeSend",H);const J=E?null:Sn(g,r,w);g.send(J);return e}function Fn(e,t){const n=t.xhr;let r=null;let o=null;if(H(n,"HX-Push")){r=n.getResponseHeader("HX-Push");o="push"}else if(H(n,"HX-Push-Url")){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(H(n,"HX-Replace-Url")){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=t.etc.push||te(e,"hx-push-url");let c=t.etc.replace||te(e,"hx-replace-url");if(c==="false")c=null;const u=re(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function Bn(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Xn(e){for(var t=0;t`+`.${t}{opacity:0;visibility: hidden} `+`.${n} .${t}, .${n}.${t}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`+"")}}function Yn(){const e=ee().querySelector('meta[name="htmx-config"]');if(e){return v(e.content)}else{return null}}function Qn(){const e=Yn();if(e){Y.config=se(Y.config,e)}}Wn(function(){Qn();Zn();let e=ee().body;Bt(e);const t=ee().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.detail.elt||e.target;const n=re(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){tn();oe(t,function(e){ae(e,"htmx:restored",{document:ee(),triggerEvent:ae})})}else{if(n){n(e)}}};b().setTimeout(function(){ae(e,"htmx:load",{});e=null},0)});return Y}(); \ No newline at end of file diff --git a/internal/web/static/htmx-LICENSE.txt b/internal/web/static/htmx-LICENSE.txt new file mode 100644 index 0000000..d3061a3 --- /dev/null +++ b/internal/web/static/htmx-LICENSE.txt @@ -0,0 +1,13 @@ +Zero-Clause BSD +============= + +Permission to use, copy, modify, and/or distribute this software for +any purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE +FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY +DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html new file mode 100644 index 0000000..b44f4d2 --- /dev/null +++ b/internal/web/templates/layout.html @@ -0,0 +1,24 @@ +{{define "layout"}} + + + + + {{.Title}} | TVer + + + + +
+ TVer + +
+
+

{{.Title}}

+
+ +{{end}} -- 2.54.0