feat: started to make things work
This commit is contained in:
1 parent
9e21732d00
commit
de6480ba4e
8 files changed
+157
-106
No files matched your search
@@ -2,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -17,20 +16,15 @@ func registerAPI(mux *http.ServeMux, sqlite *sqliteStore, logger *zap.Logger) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ids, err := sqlite.listAllPublicIDs(r.Context())
|
summaries, err := sqlite.listAll(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("list flashcards", zap.Error(err))
|
logger.Error("list flashcards", zap.Error(err))
|
||||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
||||||
return
|
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")
|
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) {
|
mux.HandleFunc("/api/v1/flashcards/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"embed"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -21,9 +19,6 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed public/*
|
|
||||||
var publicFiles embed.FS
|
|
||||||
|
|
||||||
var version = "0.1.0"
|
var version = "0.1.0"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -77,8 +72,7 @@ func main() {
|
|||||||
})
|
})
|
||||||
mux.Handle("/mcp", authMiddleware(token, streamableServer))
|
mux.Handle("/mcp", authMiddleware(token, streamableServer))
|
||||||
registerAPI(mux, sqliteStore, logger)
|
registerAPI(mux, sqliteStore, logger)
|
||||||
publicFS, _ := fs.Sub(publicFiles, "public")
|
mux.Handle("/", publicHandler())
|
||||||
mux.Handle("/", http.FileServer(http.FS(publicFS)))
|
|
||||||
|
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: ":" + port,
|
Addr: ":" + port,
|
||||||
|
|||||||
+86
-52
@@ -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 = {
|
const List = {
|
||||||
oninit: async function (v) {
|
oninit: async function (v) {
|
||||||
v.state.ids = [];
|
v.state.cards = [];
|
||||||
v.state.loading = true;
|
v.state.loading = true;
|
||||||
try {
|
const res = await fetch(API);
|
||||||
const res = await fetch(API);
|
const data = await res.json();
|
||||||
const data = await res.json();
|
v.state.cards = data.flashcards || [];
|
||||||
v.state.ids = (data.flashcards || []).map(u => u.split('/').pop());
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
v.state.loading = false;
|
v.state.loading = false;
|
||||||
|
m.redraw();
|
||||||
},
|
},
|
||||||
view: function (v) {
|
view: function (v) {
|
||||||
if (v.state.loading) return m('.loading', m('i.nes-icon.nes-logo.is-small'));
|
if (v.state.loading) {
|
||||||
return [
|
return m("h1", "loading");
|
||||||
m('.header', [
|
}
|
||||||
m('h1', 'St\u2011Pierre Flashcards'),
|
const cards = m(
|
||||||
m('span.nes-badge', m('span', String(v.state.ids.length))),
|
".cards-list",
|
||||||
]),
|
v.state.cards.map((c) => m(Card, { card: c })),
|
||||||
v.state.ids.length === 0 && m('p', 'No flashcards yet.'),
|
);
|
||||||
v.state.ids.map(id => m('.card-preview.nes-container.is-rounded.with-title', {
|
return m("ol", cards);
|
||||||
onclick: () => m.route.set('/flashcard/' + id),
|
|
||||||
}, [
|
|
||||||
m('p.title', '#' + id),
|
|
||||||
m('p', 'Click to view details \u2192'),
|
|
||||||
])),
|
|
||||||
];
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const Detail = {
|
const Detail = {
|
||||||
oninit: async function (v) {
|
oninit: async function (v) {
|
||||||
v.state.entry = null;
|
v.state.flashcard = null;
|
||||||
v.state.loading = true;
|
v.state.loading = true;
|
||||||
try {
|
v.state.flashcard = await findFlashCardByID(v.attrs.id);
|
||||||
const res = await fetch(API + '/' + v.attrs.id);
|
|
||||||
if (res.ok) v.state.entry = await res.json();
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
v.state.loading = false;
|
v.state.loading = false;
|
||||||
|
m.redraw();
|
||||||
},
|
},
|
||||||
view: function (v) {
|
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;
|
const e = v.state.entry;
|
||||||
if (!e) return [
|
if (!e)
|
||||||
m('.back', m('button.nes-btn', { onclick: () => m.route.set('/') }, '\u2B05 Back')),
|
return [
|
||||||
m('p', 'Flashcard not found.'),
|
m(
|
||||||
];
|
".back",
|
||||||
|
m(
|
||||||
|
"button.nes-btn",
|
||||||
|
{ onclick: () => m.route.set("/") },
|
||||||
|
"\u2B05 Back",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
m("p", "Flashcard not found."),
|
||||||
|
];
|
||||||
return [
|
return [
|
||||||
m('.back', m('button.nes-btn.is-warning', { onclick: () => m.route.set('/') }, '\u2B05 Back')),
|
m(
|
||||||
m('.detail-card.nes-container.is-rounded.with-title', [
|
".back",
|
||||||
m('p.title', '#' + e.public_id + ' v' + e.version),
|
m(
|
||||||
m('.field', [ m('.field-label', 'Note'), m('p', e.note) ]),
|
"button.nes-btn.is-warning",
|
||||||
e.host ? m('.field', [ m('.field-label', 'Host'), m('p', e.host) ]) : null,
|
{ onclick: () => m.route.set("/") },
|
||||||
e.user ? m('.field', [ m('.field-label', 'User'), m('p', e.user) ]) : null,
|
"\u2B05 Back",
|
||||||
e.context ? m('.field', [ m('.field-label', 'Context'), m('p', e.context) ]) : null,
|
),
|
||||||
e.repos?.length ? m('.field', [
|
),
|
||||||
m('.field-label', 'Repos'),
|
m(".detail-card.nes-container.is-rounded.with-title", [
|
||||||
m('div', e.repos.map(r => m('span.nes-badge', m('span', r)))),
|
m("p.title", "#" + e.public_id + " v" + e.version),
|
||||||
]) : null,
|
m(".field", [m(".field-label", "Note"), m("p", e.note)]),
|
||||||
e.tags?.length ? m('.field', [
|
e.host
|
||||||
m('.field-label', 'Tags'),
|
? m(".field", [m(".field-label", "Host"), m("p", e.host)])
|
||||||
m('div', e.tags.map(t => m('span.nes-badge', m('span', t)))),
|
: null,
|
||||||
]) : null,
|
e.user
|
||||||
m('.field', [ m('.field-label', 'Created'), m('p', e.created) ]),
|
? m(".field", [m(".field-label", "User"), m("p", e.user)])
|
||||||
e.delete_timestamp ? m('.field', [ m('.field-label', 'Deleted'), m('p', e.delete_timestamp) ]) : null,
|
: 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'), '/', {
|
m.route(document.getElementById("app"), "/", {
|
||||||
'/': List,
|
"/": List,
|
||||||
'/flashcard/:id': Detail,
|
"/flashcard/:id": Detail,
|
||||||
});
|
});
|
||||||
+11
-29
@@ -1,31 +1,13 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>St‑Pierre Flashcards</title>
|
<title>St‑Pierre Flashcards</title>
|
||||||
<link rel="stylesheet" href="https://unpkg.com/nes.css@2.3.0/css/nes.min.css">
|
</head>
|
||||||
<style>
|
<body>
|
||||||
html, body { background: #212529; min-height: 100vh; }
|
<h1>coquelicot d'amour</h1>
|
||||||
body { padding: 1rem; }
|
<div class="flashcard-body" id="app"></div>
|
||||||
.flashcard-body { max-width: 800px; margin: 0 auto; }
|
<script type="module" src="/app.js"></script>
|
||||||
.card-preview { cursor: pointer; margin-bottom: .75rem; }
|
</body>
|
||||||
.card-preview:hover { filter: brightness(1.2); }
|
|
||||||
.card-preview .note { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
|
|
||||||
.tags-row { margin-top: .5rem; }
|
|
||||||
.meta-row { display: flex; flex-wrap: wrap; gap: .25rem .75rem; font-size: .85rem; margin-top: .25rem; }
|
|
||||||
.back { margin-bottom: 1.5rem; }
|
|
||||||
.detail-card .nes-badge { margin-right: .25rem; }
|
|
||||||
.detail-card .field { margin-bottom: 1rem; }
|
|
||||||
.detail-card .field-label { font-size: .75rem; opacity: .7; margin-bottom: .25rem; }
|
|
||||||
.loading { text-align: center; padding: 4rem 0; }
|
|
||||||
.header { margin-bottom: 2rem; display: flex; align-items: center; gap: .75rem; }
|
|
||||||
.nes-badge.is-icon span { position: static; }
|
|
||||||
.vote-summary { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #fff3; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="flashcard-body" id="app"></div>
|
|
||||||
<script type="module" src="/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build dev
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func publicHandler() http.Handler {
|
||||||
|
return http.FileServer(http.Dir("public"))
|
||||||
|
}
|
||||||
@@ -171,31 +171,40 @@ func (s *sqliteStore) getLatestByPublicID(ctx context.Context, publicID int64) (
|
|||||||
return &e, nil
|
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,
|
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)
|
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.delete_timestamp IS NULL
|
||||||
AND f.public_id != 0
|
AND f.public_id != 0
|
||||||
ORDER BY f.public_id`,
|
ORDER BY f.public_id`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list ids: %w", err)
|
return nil, fmt.Errorf("list: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var ids []int64
|
var summaries []flashcardSummary
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id int64
|
var s flashcardSummary
|
||||||
if err := rows.Scan(&id); err != nil {
|
var tagsJSON, reposJSON *string
|
||||||
return nil, fmt.Errorf("scan id: %w", err)
|
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 {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, fmt.Errorf("rows: %w", err)
|
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) {
|
func (s *sqliteStore) getByPublicIDAndVersion(ctx context.Context, publicID int64, version int) (*entry, error) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package main
|
|||||||
|
|
||||||
type entry struct {
|
type entry struct {
|
||||||
ID int64 `json:"_id"`
|
ID int64 `json:"_id"`
|
||||||
PublicID int64 `json:"public_id"`
|
PublicID int64 `json:"id"`
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
Note string `json:"note"`
|
Note string `json:"note"`
|
||||||
Repos []string `json:"repos,omitempty"`
|
Repos []string `json:"repos,omitempty"`
|
||||||
@@ -14,6 +14,18 @@ type entry struct {
|
|||||||
Created string `json:"created"`
|
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 {
|
type vote struct {
|
||||||
ID int64 `json:"_id"`
|
ID int64 `json:"_id"`
|
||||||
FlashcardID int64 `json:"flashcard_id"`
|
FlashcardID int64 `json:"flashcard_id"`
|
||||||
|
|||||||
Reference in new issue
Block a user