feat(release): v0.1.0
commit06ed2c3cbeAuthor: adminoo <git@kadath.corp> Date: Tue Feb 3 11:34:24 2026 +0100 fix: changed detected by scanner but no updated by render layer commit01dcaf882aAuthor: adminoo <git@kadath.corp> Date: Tue Feb 3 10:19:05 2026 +0100 feat: VERSION bumb commit229223f77aAuthor: adminoo <git@kadath.corp> Date: Tue Feb 3 09:53:08 2026 +0100 feat: filter and search by tag commitcb11e34798Author: adminoo <git@kadath.corp> Date: Tue Feb 3 09:41:03 2026 +0100 feat: tag system commit3f5cf0d673Author: adminoo <git@kadath.corp> Date: Tue Feb 3 09:15:29 2026 +0100 feat: sqlite storage draft commitd6617cec02Author: adminoo <git@kadath.corp> Date: Tue Feb 3 09:04:11 2026 +0100 feat: metadata draft commit7238d02a13Author: adminoo <git@kadath.corp> Date: Mon Feb 2 10:18:42 2026 +0100 fix: body overflowing commit16ff836274Author: adminoo <git@kadath.corp> Date: Mon Feb 2 10:09:01 2026 +0100 feat: tests for http handlers and render package commit36ac3f03aaAuthor: adminoo <git@kadath.corp> Date: Mon Feb 2 09:45:29 2026 +0100 feat: Dark theme, placeholder metadata panel commite6923fa4f5Author: adminoo <git@kadath.corp> Date: Sun Feb 1 18:26:59 2026 +0100 fix: uneeded func + uneeded bogus note creation logic commit4458ba2d15Author: adminoo <git@kadath.corp> Date: Sun Feb 1 18:26:21 2026 +0100 feat: log when changing note states commit92a6f84540Author: adminoo <git@kadath.corp> Date: Sun Feb 1 16:55:40 2026 +0100 possibly first working draft commite27aadc603Author: adminoo <git@kadath.corp> Date: Sun Feb 1 11:55:16 2026 +0100 draft shits
This commit is contained in:
250
internal/web/handler.go
Normal file
250
internal/web/handler.go
Normal file
@ -0,0 +1,250 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"donniemarko/internal/note"
|
||||
"donniemarko/internal/render"
|
||||
"donniemarko/internal/service"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
notesService *service.NotesService
|
||||
templates *render.TemplateManager
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
func NewHandler(ns *service.NotesService, tm *render.TemplateManager) *Handler {
|
||||
return &Handler{
|
||||
notesService: ns,
|
||||
templates: tm,
|
||||
mux: http.NewServeMux(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
|
||||
// Handle root and note list
|
||||
if path == "/" {
|
||||
h.handleRoot(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle individual notes
|
||||
if strings.HasPrefix(path, "/notes/") {
|
||||
if strings.Contains(path, "/tags") {
|
||||
h.handleTags(w, r)
|
||||
return
|
||||
}
|
||||
h.handleNotes(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle 404 for other paths
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
// ViewState is built per-request, not shared
|
||||
type ViewState struct {
|
||||
Notes []*note.Note
|
||||
Note *note.Note
|
||||
RenderedNote template.HTML
|
||||
SortBy string
|
||||
SearchTerm string
|
||||
TagFilter string
|
||||
LastActive string
|
||||
}
|
||||
|
||||
func (h *Handler) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
// Build view state from query params
|
||||
state, err := h.buildViewState(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Render with state
|
||||
h.templates.Render(w, "index", state)
|
||||
}
|
||||
|
||||
func (h *Handler) buildViewState(r *http.Request) (*ViewState, error) {
|
||||
query := r.URL.Query()
|
||||
|
||||
// Extract params
|
||||
sortBy := query.Get("sort")
|
||||
if sortBy == "" {
|
||||
sortBy = "recent"
|
||||
}
|
||||
|
||||
searchTerm := query.Get("search")
|
||||
tagFilter := query.Get("tag")
|
||||
|
||||
// Get notes from service
|
||||
var notes []*note.Note
|
||||
var err error
|
||||
|
||||
if searchTerm != "" {
|
||||
opts := service.QueryOptions{
|
||||
SearchTerm: searchTerm,
|
||||
SortBy: sortBy,
|
||||
}
|
||||
notes, err = h.notesService.QueryNotes(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
notes = h.notesService.GetNotes()
|
||||
|
||||
// Apply sorting
|
||||
switch sortBy {
|
||||
case "recent":
|
||||
service.SortByDate(notes)
|
||||
case "oldest":
|
||||
service.SortByDateAsc(notes)
|
||||
case "alpha":
|
||||
service.SortByTitle(notes)
|
||||
case "ralpha":
|
||||
service.SortByTitleAsc(notes)
|
||||
default:
|
||||
service.SortByDate(notes)
|
||||
}
|
||||
}
|
||||
|
||||
if tagFilter != "" {
|
||||
notes = filterNotesByTag(notes, tagFilter)
|
||||
}
|
||||
|
||||
return &ViewState{
|
||||
Notes: notes,
|
||||
SortBy: sortBy,
|
||||
SearchTerm: searchTerm,
|
||||
TagFilter: tagFilter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) SetupRoutes() {
|
||||
// Set the handler as the main handler for http.DefaultServeMux
|
||||
http.Handle("/", h)
|
||||
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("internal/web/static"))))
|
||||
}
|
||||
|
||||
func extractHash(path string) string {
|
||||
// Extract hash from /notes/{hash}
|
||||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||
if len(parts) < 2 || parts[0] != "notes" {
|
||||
return ""
|
||||
}
|
||||
return parts[1]
|
||||
}
|
||||
|
||||
func (h *Handler) handleNotes(w http.ResponseWriter, r *http.Request) {
|
||||
// Build base state
|
||||
state, err := h.buildViewState(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract hash from URL
|
||||
hash := extractHash(r.URL.Path)
|
||||
|
||||
// Get specific note
|
||||
note, err := h.notesService.GetNoteByHash(hash)
|
||||
if err != nil {
|
||||
http.Error(w, "Note not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert markdown to HTML
|
||||
htmlContent, err := render.RenderMarkdown([]byte(note.Content))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to render markdown", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Add to state
|
||||
state.Note = note
|
||||
state.RenderedNote = htmlContent
|
||||
state.LastActive = hash
|
||||
|
||||
h.templates.Render(w, "index", state)
|
||||
}
|
||||
|
||||
func filterNotesByTag(notes []*note.Note, tag string) []*note.Note {
|
||||
tag = strings.ToLower(strings.TrimSpace(tag))
|
||||
if tag == "" {
|
||||
return notes
|
||||
}
|
||||
|
||||
filtered := make([]*note.Note, 0, len(notes))
|
||||
for _, n := range notes {
|
||||
for _, t := range n.Tags {
|
||||
if strings.EqualFold(t, tag) {
|
||||
filtered = append(filtered, n)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (h *Handler) handleTags(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
noteID, tag, isRemove := parseTagRoute(r.URL.Path)
|
||||
if noteID == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if isRemove {
|
||||
if tag == "" {
|
||||
http.Error(w, "Missing tag", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.notesService.RemoveTag(noteID, tag); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tag := r.FormValue("tag")
|
||||
if tag == "" {
|
||||
http.Error(w, "Missing tag", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.notesService.AddTag(noteID, tag); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/notes/"+noteID, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func parseTagRoute(path string) (noteID string, tag string, isRemove bool) {
|
||||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||
if len(parts) < 3 || parts[0] != "notes" || parts[2] != "tags" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
noteID = parts[1]
|
||||
if len(parts) >= 4 {
|
||||
decoded, err := url.PathUnescape(parts[3])
|
||||
if err != nil {
|
||||
return noteID, "", true
|
||||
}
|
||||
return noteID, decoded, true
|
||||
}
|
||||
|
||||
return noteID, "", false
|
||||
}
|
||||
307
internal/web/handler_test.go
Normal file
307
internal/web/handler_test.go
Normal file
@ -0,0 +1,307 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"donniemarko/internal/note"
|
||||
"donniemarko/internal/render"
|
||||
"donniemarko/internal/service"
|
||||
"donniemarko/internal/storage"
|
||||
)
|
||||
|
||||
type testEnv struct {
|
||||
handler *Handler
|
||||
storage *storage.NoteStorage
|
||||
}
|
||||
|
||||
func newTestEnv(t *testing.T) *testEnv {
|
||||
t.Helper()
|
||||
|
||||
ns := storage.NewNoteStorage()
|
||||
|
||||
notes := []*note.Note{
|
||||
{
|
||||
ID: "a1",
|
||||
Title: "Alpha",
|
||||
Content: "# Alpha\ncontent",
|
||||
UpdatedAt: time.Date(2025, 12, 31, 10, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
ID: "b2",
|
||||
Title: "Beta",
|
||||
Content: "# Beta\nRust tips",
|
||||
UpdatedAt: time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
ID: "z9",
|
||||
Title: "Zulu",
|
||||
Content: "# Zulu\nRust book",
|
||||
UpdatedAt: time.Date(2026, 1, 2, 10, 0, 0, 0, time.UTC),
|
||||
},
|
||||
}
|
||||
|
||||
for _, n := range notes {
|
||||
if err := ns.Create(n); err != nil {
|
||||
t.Fatalf("create note: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
svc := service.NewNoteService()
|
||||
svc.SetStorage(ns)
|
||||
|
||||
tm := render.NewTemplateManager("templates")
|
||||
handler := NewHandler(svc, tm)
|
||||
|
||||
return &testEnv{
|
||||
handler: handler,
|
||||
storage: ns,
|
||||
}
|
||||
}
|
||||
|
||||
func assertOrder(t *testing.T, body string, want []string) {
|
||||
t.Helper()
|
||||
|
||||
prev := -1
|
||||
for _, title := range want {
|
||||
idx := strings.Index(body, title)
|
||||
if idx == -1 {
|
||||
t.Fatalf("expected to find %q in response body", title)
|
||||
}
|
||||
if idx <= prev {
|
||||
t.Fatalf("expected %q to appear after previous title", title)
|
||||
}
|
||||
prev = idx
|
||||
}
|
||||
}
|
||||
|
||||
func noteMarker(id string) string {
|
||||
return `data-hash="` + id + `"`
|
||||
}
|
||||
|
||||
func TestHandlerRoot_DefaultSortRecent(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
env.handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" {
|
||||
t.Fatalf("expected content-type text/html; charset=utf-8, got %q", ct)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
assertOrder(t, body, []string{noteMarker("z9"), noteMarker("b2"), noteMarker("a1")})
|
||||
}
|
||||
|
||||
func TestHandlerRoot_SortAlpha(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/?sort=alpha", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
env.handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
assertOrder(t, body, []string{noteMarker("a1"), noteMarker("b2"), noteMarker("z9")})
|
||||
}
|
||||
|
||||
func TestHandlerRoot_Search(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/?search=rust&sort=alpha", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
env.handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "Matching results for query 'rust'") {
|
||||
t.Fatalf("expected search header to be rendered")
|
||||
}
|
||||
|
||||
if strings.Contains(body, `data-hash="a1"`) {
|
||||
t.Fatalf("expected non-matching note to be excluded")
|
||||
}
|
||||
|
||||
assertOrder(t, body, []string{noteMarker("b2"), noteMarker("z9")})
|
||||
}
|
||||
|
||||
func TestHandlerNotes_Success(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/notes/b2", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
env.handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "<h1>Beta</h1>") {
|
||||
t.Fatalf("expected rendered markdown for note content")
|
||||
}
|
||||
if !strings.Contains(body, `class="active-note"`) {
|
||||
t.Fatalf("expected active note class in list")
|
||||
}
|
||||
if !strings.Contains(body, `data-hash="b2"`) {
|
||||
t.Fatalf("expected active note hash in list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerNotes_NotFound(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/notes/missing", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
env.handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected status 404, got %d", rec.Code)
|
||||
}
|
||||
|
||||
if !strings.Contains(rec.Body.String(), "Note not found") {
|
||||
t.Fatalf("expected not found message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_NotFoundPath(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/nope", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
env.handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected status 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractHash(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "valid note path",
|
||||
path: "/notes/abcd",
|
||||
want: "abcd",
|
||||
},
|
||||
{
|
||||
name: "missing hash",
|
||||
path: "/notes/",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "wrong prefix",
|
||||
path: "/other/abcd",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := extractHash(tc.path); got != tc.want {
|
||||
t.Fatalf("expected %q, got %q", tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerTags_Add(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("tag", " Go ")
|
||||
req := httptest.NewRequest(http.MethodPost, "/notes/b2/tags", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
env.handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected status 303, got %d", rec.Code)
|
||||
}
|
||||
|
||||
n, err := env.storage.Get("b2")
|
||||
if err != nil {
|
||||
t.Fatalf("get note: %v", err)
|
||||
}
|
||||
if len(n.Tags) != 1 || n.Tags[0] != "go" {
|
||||
t.Fatalf("expected normalized tag on note, got %+v", n.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerTags_Remove(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
|
||||
if err := env.storage.AddTag("b2", "go"); err != nil {
|
||||
t.Fatalf("seed tag: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/notes/b2/tags/go", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
env.handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected status 303, got %d", rec.Code)
|
||||
}
|
||||
|
||||
n, err := env.storage.Get("b2")
|
||||
if err != nil {
|
||||
t.Fatalf("get note: %v", err)
|
||||
}
|
||||
if len(n.Tags) != 0 {
|
||||
t.Fatalf("expected tag to be removed, got %+v", n.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerRoot_TagFilter(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
|
||||
if err := env.storage.AddTag("a1", "go"); err != nil {
|
||||
t.Fatalf("seed tag: %v", err)
|
||||
}
|
||||
if err := env.storage.AddTag("b2", "rust"); err != nil {
|
||||
t.Fatalf("seed tag: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/?tag=go", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
env.handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
if strings.Contains(body, noteMarker("b2")) {
|
||||
t.Fatalf("expected non-matching note to be excluded")
|
||||
}
|
||||
if !strings.Contains(body, noteMarker("a1")) {
|
||||
t.Fatalf("expected matching note to be included")
|
||||
}
|
||||
}
|
||||
361
internal/web/static/css/main.css
Normal file
361
internal/web/static/css/main.css
Normal file
@ -0,0 +1,361 @@
|
||||
:root {
|
||||
--background1: #1e1e1e; /* Main background */
|
||||
--background2: #252526; /* Code blocks, input backgrounds */
|
||||
--background3: #333333; /* Active items */
|
||||
--background-hover: #2d2d2d;
|
||||
|
||||
--heading1: #ffffff; /* Primary headings */
|
||||
--heading2: #cccccc; /* Secondary headings */
|
||||
--heading3: #999999;
|
||||
|
||||
--text1: #d4d4d4; /* Main text */
|
||||
--code1: #c5c5c5; /* Inline code text */
|
||||
--url: #4ea1ff; /* Link color */
|
||||
|
||||
--border-color: #3a3a3a; /* Subtle border */
|
||||
|
||||
--font-main: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
--font-size-base: 16px;
|
||||
--line-height: 1.6;
|
||||
|
||||
--padding-main: 1cm;
|
||||
--max-width-main: 210mm;
|
||||
}
|
||||
|
||||
@page {
|
||||
size: A4;
|
||||
}
|
||||
|
||||
/* PRINT MODE */
|
||||
@media print {
|
||||
body {
|
||||
margin: 2cm 1.5cm 2cm 1.5cm;
|
||||
background: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
aside,
|
||||
.note-metadata {
|
||||
display: none;
|
||||
}
|
||||
|
||||
main {
|
||||
margin-top: 0;
|
||||
break-after: always;
|
||||
color: black;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
|
||||
/* SCREEN MODE */
|
||||
@media screen {
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
aside,
|
||||
main {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
margin-top: 1.5em;
|
||||
}
|
||||
}
|
||||
|
||||
/* UNIVERSAL */
|
||||
@media all {
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
background: var(--background1);
|
||||
margin: 0;
|
||||
font-family: var(--font-main);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height);
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--url);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: var(--background2);
|
||||
padding: 0.25em 0.45em;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
color: var(--code1);
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--background2);
|
||||
padding: 0.8em;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
color: var(--heading1);
|
||||
font-size: 2em;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 1em;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* SIDEBAR */
|
||||
aside {
|
||||
flex: 0 0 350px;
|
||||
width: 250px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
padding: var(--padding-main);
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
|
||||
aside ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
aside ul li {
|
||||
padding: 0.5em 0.3em;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: background 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
aside ul li:hover {
|
||||
background: var(--background-hover);
|
||||
}
|
||||
|
||||
.active-note {
|
||||
background: var(--background3);
|
||||
}
|
||||
|
||||
.note-title a {
|
||||
padding-left: 0.7em;
|
||||
font-size: 0.95em;
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
.last-modified {
|
||||
text-align: right;
|
||||
font-size: 0.75em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* SEARCH BAR */
|
||||
.search-form {
|
||||
display: flex;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
width: 100%;
|
||||
padding: 0.45em;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--background2);
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
.search-bar:focus {
|
||||
outline: none;
|
||||
border-color: var(--url);
|
||||
box-shadow: 0 0 6px #4ea1ff55;
|
||||
}
|
||||
|
||||
/* MAIN CONTENT */
|
||||
main {
|
||||
flex: 1;
|
||||
margin: 0 auto;
|
||||
padding: var(--padding-main);
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
main h1 {
|
||||
color: var(--heading1);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 0.3em;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
main h2 {
|
||||
color: var(--heading1);
|
||||
border-bottom: 2px dotted var(--border-color);
|
||||
padding-bottom: 0.5em;
|
||||
padding-top: 1em;
|
||||
}
|
||||
|
||||
main h3,
|
||||
main h4 {
|
||||
color: var(--heading3);
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 1em 0;
|
||||
padding: 0 1em;
|
||||
border-left: 3px solid var(--border-color);
|
||||
color: var(--heading2);
|
||||
}
|
||||
|
||||
/* METADATA PANEL (right side) */
|
||||
.metadata-panel {
|
||||
flex: 0 0 300px; /* fixed width panel */
|
||||
padding: var(--padding-main);
|
||||
border-left: 1px solid var(--border-color);
|
||||
background: #1a1a1a;
|
||||
color: var(--text1);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Titles inside metadata panel */
|
||||
.metadata-panel h3 {
|
||||
color: var(--heading1);
|
||||
font-size: 1em;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
/* Metadata block wrapper */
|
||||
.meta-block {
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
|
||||
/* Tags UI */
|
||||
.tag-form {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
margin-bottom: 0.8em;
|
||||
}
|
||||
|
||||
.tag-input {
|
||||
flex: 1;
|
||||
padding: 0.35em 0.5em;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--background2);
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
.tag-submit {
|
||||
padding: 0.35em 0.6em;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--background2);
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
.meta-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4em;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35em;
|
||||
background: var(--background2);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 999px;
|
||||
padding: 0.2em 0.45em;
|
||||
}
|
||||
|
||||
.tag-link {
|
||||
color: var(--url);
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.tag-remove-form {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.tag-remove {
|
||||
border: 0;
|
||||
background: var(--background3);
|
||||
color: var(--text1);
|
||||
width: 1.4em;
|
||||
height: 1.4em;
|
||||
border-radius: 999px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tag-remove:hover {
|
||||
background: var(--background-hover);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Category display */
|
||||
.meta-category {
|
||||
background: var(--background2);
|
||||
padding: 0.4em 0.6em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
border: 1px solid var(--border-color);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Lists for metadata fields */
|
||||
.meta-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.meta-list li {
|
||||
margin: 0.3em 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Placeholder for future stats visualizations */
|
||||
.meta-stats-placeholder {
|
||||
margin-top: 1em;
|
||||
padding: 1em;
|
||||
text-align: center;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.6;
|
||||
background: var(--background2);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
}
|
||||
14
internal/web/templates/base.tmpl
Normal file
14
internal/web/templates/base.tmpl
Normal file
@ -0,0 +1,14 @@
|
||||
{{ define "base" }}
|
||||
<!doctype html>
|
||||
<html class="no-js" lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Donnie Marko</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="/static/css/main.css" type="text/css">
|
||||
</head>
|
||||
<body>
|
||||
{{ template "content" . }}
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
9
internal/web/templates/index.tmpl
Normal file
9
internal/web/templates/index.tmpl
Normal file
@ -0,0 +1,9 @@
|
||||
{{ define "content" }}
|
||||
{{/* List of notes and searching utilities */}}
|
||||
{{ template "noteList" . }}
|
||||
{{/* Markdown notes rendering area */}}
|
||||
<main>
|
||||
{{ .RenderedNote }}
|
||||
</main>
|
||||
{{ template "metadata" . }}
|
||||
{{ end }}
|
||||
52
internal/web/templates/metadata.tmpl
Normal file
52
internal/web/templates/metadata.tmpl
Normal file
@ -0,0 +1,52 @@
|
||||
{{ define "metadata" }}
|
||||
|
||||
{{ if .RenderedNote }}
|
||||
<aside class="metadata-panel">
|
||||
|
||||
<section class="meta-block">
|
||||
<h3>Tags</h3>
|
||||
<form method="POST" action="/notes/{{ .Note.ID }}/tags" class="tag-form">
|
||||
<input type="text" name="tag" class="tag-input" placeholder="Add tag">
|
||||
<input type="submit" value="add" class="tag-submit" />
|
||||
</form>
|
||||
<div class="meta-tags">
|
||||
{{ range .Note.Tags }}
|
||||
<span class="tag-chip">
|
||||
<a class="tag-link" href="/?tag={{ . | urlquery }}">{{ . }}</a>
|
||||
<form method="POST" action="/notes/{{ $.Note.ID }}/tags/{{ . | urlquery }}" class="tag-remove-form">
|
||||
<button type="submit" class="tag-remove" aria-label="Remove tag {{ . }}">×</button>
|
||||
</form>
|
||||
</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="meta-block">
|
||||
<h3>File Info</h3>
|
||||
<ul class="meta-list">
|
||||
<li><strong>Last Modified:</strong>{{ .Note.GetUpdateDateRep }}</li>
|
||||
<li><strong>Size:</strong> {{ .Note.Size }}</li>
|
||||
<li><strong>Hash:</strong> {{ .Note.ID }}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="meta-block">
|
||||
<h3>Category</h3>
|
||||
<div class="meta-category">Software Engineering</div>
|
||||
</section>
|
||||
|
||||
<section class="meta-block">
|
||||
<h3>Document Stats</h3>
|
||||
<ul class="meta-list">
|
||||
<li><strong>Word Count:</strong> 542</li>
|
||||
<li><strong>Unique Words:</strong> 211</li>
|
||||
</ul>
|
||||
|
||||
<!-- Placeholder for future stats such as word cloud -->
|
||||
<div class="meta-stats-placeholder">
|
||||
<p>Word cloud / stats visualization<br>(future)</p>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
55
internal/web/templates/noteList.tmpl
Normal file
55
internal/web/templates/noteList.tmpl
Normal file
@ -0,0 +1,55 @@
|
||||
{{ define "noteList" }}
|
||||
<aside>
|
||||
<header>
|
||||
<h1 class="main-logo"><a href="/">Donnie Marko</a></h1>
|
||||
<form method="GET" action="/" class="search-form">
|
||||
<input type="text" name="search" class="search-bar" placeholder="Search notes or tags... (empty query to clear)">
|
||||
<input type="submit" value="ok"/>
|
||||
</form>
|
||||
<form method="GET" action="/">
|
||||
<select name="sort" value="sort" class="sort-dropdown">
|
||||
<option value="" disabled {{ if eq "" .SortBy }}selected{{ end }}>Sort by</option>
|
||||
<option value="recent" {{ if eq "recent" .SortBy }}selected{{ end }}>Recent</option>
|
||||
<option value="oldest" {{ if eq "oldest" .SortBy }}selected{{ end }}>Oldest</option>
|
||||
<option value="alpha" {{ if eq "alpha" .SortBy }}selected{{ end }}>Alphabetical</option>
|
||||
<option value="ralpha" {{ if eq "ralpha" .SortBy }}selected{{ end }}>Reverse Alphabetical</option>
|
||||
</select>
|
||||
<input type="submit" value="sort" />
|
||||
</form>
|
||||
</header>
|
||||
{{ template "renderSearch" . }}
|
||||
</aside>
|
||||
{{ end }}
|
||||
|
||||
{{ define "renderSearch" }}
|
||||
{{ if ne .SearchTerm "" }}<h2>Matching results for query '{{ .SearchTerm }}' (notes or tags)</h2>{{ end }}
|
||||
{{ if ne .TagFilter "" }}<h2>Filtered by tag '{{ .TagFilter }}'</h2>{{ end }}
|
||||
<ul class="search-results">
|
||||
{{ range .Notes }}
|
||||
<li {{ if eq .ID $.LastActive }}class="active-note"{{ end }}>
|
||||
<div class="note-title">
|
||||
<a href="/notes/{{ .ID }}" data-hash="{{ .ID }}">{{ if ge (len .Title) 30 }}{{printf "%.30s" .Title }}[...]{{ else }} {{ .Title }}{{ end }}</a>
|
||||
</div>
|
||||
<span class="last-modified">{{ .GetUpdateDateRep }}</span>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
|
||||
|
||||
{{/* not used for now, the opposition between flat list from hashmap and tree structure is confusing */}}
|
||||
{{ define "renderTree" }}
|
||||
<ul>
|
||||
{{ range . }}
|
||||
{{ if .IsEnd }}
|
||||
<li><input type="checkbox"/><a href="/notes/{{ .Hash }}" data-hash="{{ .Hash }}">{{ .Title }}</a><span class="last-modified">{{ .LastModified }}</span></li>
|
||||
{{ else }}
|
||||
{{ if .Children }}
|
||||
<li><div class="folder"><input type="checkbox"/><span class="folder">{{ .Path }}</span></folder>
|
||||
{{ template "renderTree" .Children }}
|
||||
</li>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
Reference in New Issue
Block a user