diff --git a/api.go b/api.go index 726577f..390a290 100644 --- a/api.go +++ b/api.go @@ -2,7 +2,6 @@ package main import ( "encoding/json" - "fmt" "net/http" "strconv" "strings" @@ -17,20 +16,15 @@ func registerAPI(mux *http.ServeMux, sqlite *sqliteStore, logger *zap.Logger) { return } - ids, err := sqlite.listAllPublicIDs(r.Context()) + summaries, err := sqlite.listAll(r.Context()) if err != nil { logger.Error("list flashcards", zap.Error(err)) http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } - urls := make([]string, 0, len(ids)) - for _, id := range ids { - urls = append(urls, fmt.Sprintf("/api/v1/flashcards/%d", id)) - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string][]string{"flashcards": urls}) + json.NewEncoder(w).Encode(map[string][]flashcardSummary{"flashcards": summaries}) }) mux.HandleFunc("/api/v1/flashcards/", func(w http.ResponseWriter, r *http.Request) { diff --git a/main.go b/main.go index 39d084b..0f8726d 100644 --- a/main.go +++ b/main.go @@ -2,10 +2,8 @@ package main import ( "context" - "embed" "encoding/json" "fmt" - "io/fs" "net/http" "os" "os/signal" @@ -21,9 +19,6 @@ import ( "go.uber.org/zap" ) -//go:embed public/* -var publicFiles embed.FS - var version = "0.1.0" func main() { @@ -77,8 +72,7 @@ func main() { }) mux.Handle("/mcp", authMiddleware(token, streamableServer)) registerAPI(mux, sqliteStore, logger) - publicFS, _ := fs.Sub(publicFiles, "public") - mux.Handle("/", http.FileServer(http.FS(publicFS))) + mux.Handle("/", publicHandler()) httpServer := &http.Server{ Addr: ":" + port, diff --git a/public/app.js b/public/app.js index ea78f3a..a6e747d 100644 --- a/public/app.js +++ b/public/app.js @@ -1,77 +1,111 @@ -import m from 'https://unpkg.com/mithril@2/mithril.esm.js'; +import m from "https://esm.sh/mithril@2.3.8"; -const API = '/api/v1/flashcards'; +const API = "/api/v1/flashcards"; + +const Card = { + view: function (v) { + return m("li", v.attrs.card.id); + }, +}; const List = { oninit: async function (v) { - v.state.ids = []; + v.state.cards = []; v.state.loading = true; - try { - const res = await fetch(API); - const data = await res.json(); - v.state.ids = (data.flashcards || []).map(u => u.split('/').pop()); - } catch { /* ignore */ } + const res = await fetch(API); + const data = await res.json(); + v.state.cards = data.flashcards || []; v.state.loading = false; + m.redraw(); }, view: function (v) { - if (v.state.loading) return m('.loading', m('i.nes-icon.nes-logo.is-small')); - return [ - m('.header', [ - m('h1', 'St\u2011Pierre Flashcards'), - m('span.nes-badge', m('span', String(v.state.ids.length))), - ]), - v.state.ids.length === 0 && m('p', 'No flashcards yet.'), - v.state.ids.map(id => m('.card-preview.nes-container.is-rounded.with-title', { - onclick: () => m.route.set('/flashcard/' + id), - }, [ - m('p.title', '#' + id), - m('p', 'Click to view details \u2192'), - ])), - ]; + if (v.state.loading) { + return m("h1", "loading"); + } + const cards = m( + ".cards-list", + v.state.cards.map((c) => m(Card, { card: c })), + ); + return m("ol", cards); }, }; const Detail = { oninit: async function (v) { - v.state.entry = null; + v.state.flashcard = null; v.state.loading = true; - try { - const res = await fetch(API + '/' + v.attrs.id); - if (res.ok) v.state.entry = await res.json(); - } catch { /* ignore */ } + v.state.flashcard = await findFlashCardByID(v.attrs.id); v.state.loading = false; + m.redraw(); }, view: function (v) { - if (v.state.loading) return m('.loading', m('i.nes-icon.nes-logo.is-small')); + if (v.state.loading) + return m(".loading", m("i.nes-icon.nes-logo.is-small")); const e = v.state.entry; - if (!e) return [ - m('.back', m('button.nes-btn', { onclick: () => m.route.set('/') }, '\u2B05 Back')), - m('p', 'Flashcard not found.'), - ]; + if (!e) + return [ + m( + ".back", + m( + "button.nes-btn", + { onclick: () => m.route.set("/") }, + "\u2B05 Back", + ), + ), + m("p", "Flashcard not found."), + ]; return [ - m('.back', m('button.nes-btn.is-warning', { onclick: () => m.route.set('/') }, '\u2B05 Back')), - m('.detail-card.nes-container.is-rounded.with-title', [ - m('p.title', '#' + e.public_id + ' v' + e.version), - m('.field', [ m('.field-label', 'Note'), m('p', e.note) ]), - e.host ? m('.field', [ m('.field-label', 'Host'), m('p', e.host) ]) : null, - e.user ? m('.field', [ m('.field-label', 'User'), m('p', e.user) ]) : null, - e.context ? m('.field', [ m('.field-label', 'Context'), m('p', e.context) ]) : null, - e.repos?.length ? m('.field', [ - m('.field-label', 'Repos'), - m('div', e.repos.map(r => m('span.nes-badge', m('span', r)))), - ]) : null, - e.tags?.length ? m('.field', [ - m('.field-label', 'Tags'), - m('div', e.tags.map(t => m('span.nes-badge', m('span', t)))), - ]) : null, - m('.field', [ m('.field-label', 'Created'), m('p', e.created) ]), - e.delete_timestamp ? m('.field', [ m('.field-label', 'Deleted'), m('p', e.delete_timestamp) ]) : null, + m( + ".back", + m( + "button.nes-btn.is-warning", + { onclick: () => m.route.set("/") }, + "\u2B05 Back", + ), + ), + m(".detail-card.nes-container.is-rounded.with-title", [ + m("p.title", "#" + e.public_id + " v" + e.version), + m(".field", [m(".field-label", "Note"), m("p", e.note)]), + e.host + ? m(".field", [m(".field-label", "Host"), m("p", e.host)]) + : null, + e.user + ? m(".field", [m(".field-label", "User"), m("p", e.user)]) + : null, + e.context + ? m(".field", [m(".field-label", "Context"), m("p", e.context)]) + : null, + e.repos?.length + ? m(".field", [ + m(".field-label", "Repos"), + m( + "div", + e.repos.map((r) => m("span.nes-badge", m("span", r))), + ), + ]) + : null, + e.tags?.length + ? m(".field", [ + m(".field-label", "Tags"), + m( + "div", + e.tags.map((t) => m("span.nes-badge", m("span", t))), + ), + ]) + : null, + m(".field", [m(".field-label", "Created"), m("p", e.created)]), + e.delete_timestamp + ? m(".field", [ + m(".field-label", "Deleted"), + m("p", e.delete_timestamp), + ]) + : null, ]), ]; }, }; -m.route(document.getElementById('app'), '/', { - '/': List, - '/flashcard/:id': Detail, +m.route(document.getElementById("app"), "/", { + "/": List, + "/flashcard/:id": Detail, }); diff --git a/public/index.html b/public/index.html index 6fe7d9f..5e6ac2d 100644 --- a/public/index.html +++ b/public/index.html @@ -1,31 +1,13 @@ - + - - - - St‑Pierre Flashcards - - - - -
- - + + + + St‑Pierre Flashcards + + +

coquelicot d'amour

+
+ + diff --git a/public_fs.go b/public_fs.go new file mode 100644 index 0000000..d89b109 --- /dev/null +++ b/public_fs.go @@ -0,0 +1,17 @@ +//go:build !dev + +package main + +import ( + "embed" + "io/fs" + "net/http" +) + +//go:embed public/* +var publicFiles embed.FS + +func publicHandler() http.Handler { + sub, _ := fs.Sub(publicFiles, "public") + return http.FileServer(http.FS(sub)) +} diff --git a/public_fs_dev.go b/public_fs_dev.go new file mode 100644 index 0000000..bb914fa --- /dev/null +++ b/public_fs_dev.go @@ -0,0 +1,9 @@ +//go:build dev + +package main + +import "net/http" + +func publicHandler() http.Handler { + return http.FileServer(http.Dir("public")) +} diff --git a/sqlite.go b/sqlite.go index 9f680a9..aef2add 100644 --- a/sqlite.go +++ b/sqlite.go @@ -171,31 +171,40 @@ func (s *sqliteStore) getLatestByPublicID(ctx context.Context, publicID int64) ( return &e, nil } -func (s *sqliteStore) listAllPublicIDs(ctx context.Context) ([]int64, error) { +func (s *sqliteStore) listAll(ctx context.Context) ([]flashcardSummary, error) { rows, err := s.db.QueryContext(ctx, - `SELECT f.public_id FROM flashcard f + `SELECT f.public_id, f.version, f.note, f.host, f.user, f.tags, f.repos, f.created + FROM flashcard f WHERE f.version = (SELECT MAX(f2.version) FROM flashcard f2 WHERE f2.public_id = f.public_id) AND f.delete_timestamp IS NULL AND f.public_id != 0 ORDER BY f.public_id`, ) if err != nil { - return nil, fmt.Errorf("list ids: %w", err) + return nil, fmt.Errorf("list: %w", err) } defer rows.Close() - var ids []int64 + var summaries []flashcardSummary for rows.Next() { - var id int64 - if err := rows.Scan(&id); err != nil { - return nil, fmt.Errorf("scan id: %w", err) + var s flashcardSummary + var tagsJSON, reposJSON *string + if err := rows.Scan(&s.PublicID, &s.Version, &s.Note, &s.Host, &s.User, &tagsJSON, &reposJSON, &s.Created); err != nil { + return nil, fmt.Errorf("scan: %w", err) } - ids = append(ids, id) + if tagsJSON != nil { + json.Unmarshal([]byte(*tagsJSON), &s.Tags) + } + if reposJSON != nil { + json.Unmarshal([]byte(*reposJSON), &s.Repos) + } + s.URL = fmt.Sprintf("/api/v1/flashcards/%d", s.PublicID) + summaries = append(summaries, s) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("rows: %w", err) } - return ids, nil + return summaries, nil } func (s *sqliteStore) getByPublicIDAndVersion(ctx context.Context, publicID int64, version int) (*entry, error) { diff --git a/types.go b/types.go index cb5f4f0..0b0b7b6 100644 --- a/types.go +++ b/types.go @@ -2,7 +2,7 @@ package main type entry struct { ID int64 `json:"_id"` - PublicID int64 `json:"public_id"` + PublicID int64 `json:"id"` Version int `json:"version"` Note string `json:"note"` Repos []string `json:"repos,omitempty"` @@ -14,6 +14,18 @@ type entry struct { Created string `json:"created"` } +type flashcardSummary struct { + URL string `json:"url"` + PublicID int64 `json:"id"` + Version int `json:"version"` + Note string `json:"note"` + Repos []string `json:"repos,omitempty"` + Host string `json:"host,omitempty"` + Tags []string `json:"tags,omitempty"` + User string `json:"user,omitempty"` + Created string `json:"created"` +} + type vote struct { ID int64 `json:"_id"` FlashcardID int64 `json:"flashcard_id"`