feat: Go HTTP server with tree / detail / new / classify
cmd/projax/main.go boots a pgxpool against PROJAX_DB_URL (falls back to
SUPABASE_DATABASE_URL), auto-applies embedded migrations on start
(disable with PROJAX_AUTO_MIGRATE=off), and serves on PROJAX_LISTEN_ADDR
(default :8080).
store package wraps the unified view + projax.items writes. Item has
helper methods for templates: IsArea, Editable, SourceRefDeref. The
Promote() flow runs the insert + item_links link inside a single
transaction so the source row drops out of items_unified atomically.
web package: per-page html/template instances parsed against a shared
layout.tmpl, embedded static/style.css, HTMX from CDN. Pages:
GET / tree of items_unified
GET /i/{path} detail (editable for projax, read-only +
promote form for mai.projects)
POST /i/{path} update projax-native item
POST /i/{path}/promote one-page promote (HTMX-aware fragment for
inline classify)
GET /new?parent={path} create form
POST /new create projax-native item
GET /admin/classify orphan list with inline HTMX promote
GET /healthz DB ping
GET /static/* embedded assets
Auth is intentionally out of scope for v1 — service binds to whatever
PROJAX_LISTEN_ADDR points at, deploy guidance pins it to the Tailscale
interface (covered in 1d README).
Tests (skip when DB env is unset):
TestTreeRenders, TestHealthz,
TestDetailProjaxNativeEditable, TestDetailMaiProjectsReadOnly,
TestClassifyListsOrphans, TestPromoteRoundTrip.
This commit is contained in:
85
cmd/projax/main.go
Normal file
85
cmd/projax/main.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/m/projax/db"
|
||||
"github.com/m/projax/store"
|
||||
"github.com/m/projax/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
|
||||
dbURL := os.Getenv("PROJAX_DB_URL")
|
||||
if dbURL == "" {
|
||||
dbURL = os.Getenv("SUPABASE_DATABASE_URL")
|
||||
}
|
||||
if dbURL == "" {
|
||||
logger.Error("startup: set PROJAX_DB_URL (or SUPABASE_DATABASE_URL)")
|
||||
os.Exit(1)
|
||||
}
|
||||
listen := os.Getenv("PROJAX_LISTEN_ADDR")
|
||||
if listen == "" {
|
||||
listen = ":8080"
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
pool, err := pgxpool.New(ctx, dbURL)
|
||||
if err != nil {
|
||||
logger.Error("db pool", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
logger.Error("db ping", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if os.Getenv("PROJAX_AUTO_MIGRATE") != "off" {
|
||||
if err := db.ApplyMigrations(ctx, pool); err != nil {
|
||||
logger.Error("apply migrations", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("migrations applied")
|
||||
}
|
||||
|
||||
srv, err := web.New(store.New(pool), logger)
|
||||
if err != nil {
|
||||
logger.Error("server init", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: listen,
|
||||
Handler: srv.Routes(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = httpServer.Shutdown(shutdownCtx)
|
||||
}()
|
||||
|
||||
logger.Info("listening", "addr", listen)
|
||||
if err := httpServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.Error("listen", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("shutdown clean")
|
||||
}
|
||||
274
store/store.go
Normal file
274
store/store.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Item is the unified row shape: projax-native items and mai.projects rows
|
||||
// both render through this struct.
|
||||
type Item struct {
|
||||
ID string
|
||||
Kind []string
|
||||
Title string
|
||||
Slug string
|
||||
Path string
|
||||
ParentID *string
|
||||
ContentMD string
|
||||
Aliases []string
|
||||
Metadata map[string]any
|
||||
Status string
|
||||
Pinned bool
|
||||
Archived bool
|
||||
StartTime *time.Time
|
||||
EndTime *time.Time
|
||||
Source string // "projax" or "mai.projects"
|
||||
SourceRefID *string // mai.projects.id when Source = "mai.projects"
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// IsArea reports whether this item should be treated as a top-level container.
|
||||
func (it *Item) IsArea() bool { return slices.Contains(it.Kind, "area") }
|
||||
|
||||
// Editable reports whether the UI may edit this row directly.
|
||||
// mai.projects rows are read-only — they must be promoted first.
|
||||
func (it *Item) Editable() bool { return it.Source == "projax" }
|
||||
|
||||
// SourceRefDeref returns the source ref id (empty string if nil) for templates.
|
||||
func (it *Item) SourceRefDeref() string {
|
||||
if it.SourceRefID == nil {
|
||||
return ""
|
||||
}
|
||||
return *it.SourceRefID
|
||||
}
|
||||
|
||||
// Store wraps a pgx pool with the queries projax needs.
|
||||
type Store struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool) *Store { return &Store{Pool: pool} }
|
||||
|
||||
var ErrNotFound = errors.New("projax: item not found")
|
||||
|
||||
const itemsUnifiedCols = `id, kind, title, slug, path, parent_id, content_md, aliases,
|
||||
metadata, status, pinned, archived, start_time, end_time, source, source_ref_id,
|
||||
created_at, updated_at`
|
||||
|
||||
func scanItem(row pgx.Row) (*Item, error) {
|
||||
var it Item
|
||||
if err := row.Scan(
|
||||
&it.ID, &it.Kind, &it.Title, &it.Slug, &it.Path, &it.ParentID, &it.ContentMD,
|
||||
&it.Aliases, &it.Metadata, &it.Status, &it.Pinned, &it.Archived,
|
||||
&it.StartTime, &it.EndTime, &it.Source, &it.SourceRefID,
|
||||
&it.CreatedAt, &it.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &it, nil
|
||||
}
|
||||
|
||||
func scanItems(rows pgx.Rows) ([]*Item, error) {
|
||||
defer rows.Close()
|
||||
var out []*Item
|
||||
for rows.Next() {
|
||||
var it Item
|
||||
if err := rows.Scan(
|
||||
&it.ID, &it.Kind, &it.Title, &it.Slug, &it.Path, &it.ParentID, &it.ContentMD,
|
||||
&it.Aliases, &it.Metadata, &it.Status, &it.Pinned, &it.Archived,
|
||||
&it.StartTime, &it.EndTime, &it.Source, &it.SourceRefID,
|
||||
&it.CreatedAt, &it.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &it)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListAll returns every visible row from items_unified. Caller groups by tree.
|
||||
func (s *Store) ListAll(ctx context.Context) ([]*Item, error) {
|
||||
rows, err := s.Pool.Query(ctx,
|
||||
`select `+itemsUnifiedCols+` from projax.items_unified
|
||||
order by source = 'projax' desc, path`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanItems(rows)
|
||||
}
|
||||
|
||||
// GetByPath looks up a single item by path. Projax-native wins on collision.
|
||||
func (s *Store) GetByPath(ctx context.Context, path string) (*Item, error) {
|
||||
row := s.Pool.QueryRow(ctx,
|
||||
`select `+itemsUnifiedCols+` from projax.items_unified
|
||||
where path = $1
|
||||
order by source = 'projax' desc
|
||||
limit 1`, path)
|
||||
it, err := scanItem(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return it, nil
|
||||
}
|
||||
|
||||
// GetByID looks up a single projax-native item by uuid.
|
||||
func (s *Store) GetByID(ctx context.Context, id string) (*Item, error) {
|
||||
row := s.Pool.QueryRow(ctx,
|
||||
`select `+itemsUnifiedCols+` from projax.items_unified where id = $1`, id)
|
||||
it, err := scanItem(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return it, nil
|
||||
}
|
||||
|
||||
// Areas lists all root-level area items, ordered by slug.
|
||||
func (s *Store) Areas(ctx context.Context) ([]*Item, error) {
|
||||
rows, err := s.Pool.Query(ctx,
|
||||
`select `+itemsUnifiedCols+` from projax.items_unified
|
||||
where source = 'projax' and parent_id is null and 'area' = any(kind)
|
||||
order by slug`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanItems(rows)
|
||||
}
|
||||
|
||||
// MaiOrphans lists rows that originated in mai.projects and have not been
|
||||
// promoted yet. Used by /admin/classify.
|
||||
func (s *Store) MaiOrphans(ctx context.Context) ([]*Item, error) {
|
||||
rows, err := s.Pool.Query(ctx,
|
||||
`select `+itemsUnifiedCols+` from projax.items_unified
|
||||
where source = 'mai.projects'
|
||||
order by path`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanItems(rows)
|
||||
}
|
||||
|
||||
// CreateInput captures the editable surface of a projax-native item.
|
||||
type CreateInput struct {
|
||||
Kind []string
|
||||
Title string
|
||||
Slug string
|
||||
ParentID *string
|
||||
ContentMD string
|
||||
Status string
|
||||
Pinned bool
|
||||
StartTime *time.Time
|
||||
EndTime *time.Time
|
||||
}
|
||||
|
||||
func (s *Store) Create(ctx context.Context, in CreateInput) (*Item, error) {
|
||||
if len(in.Kind) == 0 {
|
||||
return nil, errors.New("kind required")
|
||||
}
|
||||
if in.Title == "" {
|
||||
return nil, errors.New("title required")
|
||||
}
|
||||
if in.Slug == "" {
|
||||
return nil, errors.New("slug required")
|
||||
}
|
||||
if in.Status == "" {
|
||||
in.Status = "active"
|
||||
}
|
||||
var id string
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
insert into projax.items
|
||||
(kind, title, slug, parent_id, content_md, status, pinned, start_time, end_time)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
returning id`,
|
||||
in.Kind, in.Title, in.Slug, in.ParentID, in.ContentMD, in.Status, in.Pinned, in.StartTime, in.EndTime,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert: %w", err)
|
||||
}
|
||||
return s.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// UpdateInput captures the editable surface of an existing projax-native item.
|
||||
type UpdateInput struct {
|
||||
Title string
|
||||
Slug string
|
||||
ParentID *string
|
||||
ContentMD string
|
||||
Status string
|
||||
Pinned bool
|
||||
Archived bool
|
||||
StartTime *time.Time
|
||||
EndTime *time.Time
|
||||
}
|
||||
|
||||
func (s *Store) Update(ctx context.Context, id string, in UpdateInput) (*Item, error) {
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
update projax.items
|
||||
set title=$2, slug=$3, parent_id=$4, content_md=$5,
|
||||
status=$6, pinned=$7, archived=$8, start_time=$9, end_time=$10
|
||||
where id=$1 and deleted_at is null`,
|
||||
id, in.Title, in.Slug, in.ParentID, in.ContentMD,
|
||||
in.Status, in.Pinned, in.Archived, in.StartTime, in.EndTime,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update: %w", err)
|
||||
}
|
||||
return s.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// Promote turns a mai.projects orphan into a projax-native project under the
|
||||
// chosen parent. The promotion link causes items_unified to hide the source.
|
||||
func (s *Store) Promote(ctx context.Context, maiID, parentID, slug, title string) (*Item, error) {
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var newID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
insert into projax.items (kind, title, slug, parent_id, content_md, metadata, status)
|
||||
select array['project']::text[], $1, $2, $3,
|
||||
coalesce(goal, ''),
|
||||
coalesce(metadata, '{}'::jsonb),
|
||||
case status
|
||||
when 'sleeping' then 'archived'
|
||||
when 'archived' then 'archived'
|
||||
when 'done' then 'done'
|
||||
else 'active' end
|
||||
from mai.projects where id = $4
|
||||
returning id`,
|
||||
title, slug, parentID, maiID,
|
||||
).Scan(&newID); err != nil {
|
||||
return nil, fmt.Errorf("promote insert: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
insert into projax.item_links (item_id, ref_type, ref_id, rel)
|
||||
values ($1, 'mai-project', $2, 'derived-from')`,
|
||||
newID, maiID,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("promote link: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetByID(ctx, newID)
|
||||
}
|
||||
|
||||
// SoftDelete marks a projax-native item deleted_at = now().
|
||||
func (s *Store) SoftDelete(ctx context.Context, id string) error {
|
||||
_, err := s.Pool.Exec(ctx, `update projax.items set deleted_at = now() where id = $1`, id)
|
||||
return err
|
||||
}
|
||||
364
web/server.go
Normal file
364
web/server.go
Normal file
@@ -0,0 +1,364 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/m/projax/store"
|
||||
)
|
||||
|
||||
//go:embed templates/*.tmpl
|
||||
var templatesFS embed.FS
|
||||
|
||||
//go:embed static/*
|
||||
var staticFS embed.FS
|
||||
|
||||
// Server bundles handlers, templates, and the store.
|
||||
type Server struct {
|
||||
Store *store.Store
|
||||
pages map[string]*template.Template
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// New builds a Server. Each page is parsed alongside the layout into its own
|
||||
// Template so per-page `define "content"` blocks don't shadow each other.
|
||||
func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
funcs := template.FuncMap{
|
||||
"deref": func(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
},
|
||||
}
|
||||
pages := map[string]*template.Template{}
|
||||
for _, name := range []string{"tree", "detail", "new", "classify", "error"} {
|
||||
t, err := template.New(name).Funcs(funcs).ParseFS(templatesFS,
|
||||
"templates/layout.tmpl",
|
||||
"templates/"+name+".tmpl",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", name, err)
|
||||
}
|
||||
pages[name] = t
|
||||
}
|
||||
return &Server{Store: s, pages: pages, Logger: logger}, nil
|
||||
}
|
||||
|
||||
// Routes wires every URL to a handler and returns the mux.
|
||||
func (s *Server) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /", s.handleTree)
|
||||
mux.HandleFunc("GET /i/", s.handleDetail)
|
||||
mux.HandleFunc("POST /i/", s.handleDetailWrite)
|
||||
mux.HandleFunc("GET /new", s.handleNewForm)
|
||||
mux.HandleFunc("POST /new", s.handleNewSubmit)
|
||||
mux.HandleFunc("GET /admin/classify", s.handleClassify)
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.Store.Pool.Ping(r.Context()); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "ok")
|
||||
})
|
||||
|
||||
static, _ := fs.Sub(staticFS, "static")
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
|
||||
|
||||
return logging(s.Logger, mux)
|
||||
}
|
||||
|
||||
// --- handlers ---
|
||||
|
||||
type treeNode struct {
|
||||
Item *store.Item
|
||||
Children []*treeNode
|
||||
}
|
||||
|
||||
func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
items, err := s.Store.ListAll(r.Context())
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
areas, orphans, projaxN, maiN := buildForest(items)
|
||||
s.render(w, "tree", map[string]any{
|
||||
"Title": "tree",
|
||||
"Areas": areas,
|
||||
"Orphans": orphans,
|
||||
"ProjaxCount": projaxN,
|
||||
"MaiCount": maiN,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/i/")
|
||||
if path == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
it, err := s.Store.GetByPath(r.Context(), path)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
parents, err := s.parentOptions(r.Context())
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
s.render(w, "detail", map[string]any{
|
||||
"Title": it.Title,
|
||||
"Item": it,
|
||||
"ParentOptions": parents,
|
||||
"StatusOptions": []string{"active", "done", "archived"},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/i/")
|
||||
if base, ok := strings.CutSuffix(path, "/promote"); ok {
|
||||
s.handlePromote(w, r, base)
|
||||
return
|
||||
}
|
||||
it, err := s.Store.GetByPath(r.Context(), path)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
if !it.Editable() {
|
||||
http.Error(w, "read-only source — use /promote", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
var parentID *string
|
||||
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
|
||||
parentID = &v
|
||||
}
|
||||
in := store.UpdateInput{
|
||||
Title: strings.TrimSpace(r.FormValue("title")),
|
||||
Slug: strings.TrimSpace(r.FormValue("slug")),
|
||||
ParentID: parentID,
|
||||
ContentMD: r.FormValue("content_md"),
|
||||
Status: strings.TrimSpace(r.FormValue("status")),
|
||||
Pinned: r.FormValue("pinned") == "1",
|
||||
Archived: r.FormValue("archived") == "1",
|
||||
}
|
||||
updated, err := s.Store.Update(r.Context(), it.ID, in)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/i/"+updated.Path, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request, path string) {
|
||||
it, err := s.Store.GetByPath(r.Context(), path)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
if it.Source != "mai.projects" || it.SourceRefID == nil {
|
||||
http.Error(w, "promote: not a mai.projects row", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
parentID := strings.TrimSpace(r.FormValue("parent_id"))
|
||||
slug := strings.TrimSpace(r.FormValue("slug"))
|
||||
title := strings.TrimSpace(r.FormValue("title"))
|
||||
if parentID == "" || slug == "" || title == "" {
|
||||
http.Error(w, "promote: parent_id, slug, title required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
newItem, err := s.Store.Promote(r.Context(), *it.SourceRefID, parentID, slug, title)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
// HTMX inline-promote on /admin/classify expects a fragment; full-page promote redirects.
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
fmt.Fprintf(w, `<tr class="promoted"><td colspan="6">Promoted to <a href="/i/%s">%s</a></td></tr>`,
|
||||
template.HTMLEscapeString(newItem.Path), template.HTMLEscapeString(newItem.Path))
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/i/"+newItem.Path, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleNewForm(w http.ResponseWriter, r *http.Request) {
|
||||
parentPath := r.URL.Query().Get("parent")
|
||||
var parent *store.Item
|
||||
if parentPath != "" {
|
||||
p, err := s.Store.GetByPath(r.Context(), parentPath)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
if !p.Editable() {
|
||||
http.Error(w, "parent must be a projax-native item", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
parent = p
|
||||
}
|
||||
s.render(w, "new", map[string]any{
|
||||
"Title": "new",
|
||||
"Parent": parent,
|
||||
"StatusOptions": []string{"active", "done", "archived"},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleNewSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
kind := strings.TrimSpace(r.FormValue("kind"))
|
||||
if kind == "" {
|
||||
kind = "project"
|
||||
}
|
||||
var parentID *string
|
||||
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
|
||||
parentID = &v
|
||||
}
|
||||
if kind == "project" && parentID == nil {
|
||||
http.Error(w, "project requires a parent", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
in := store.CreateInput{
|
||||
Kind: []string{kind},
|
||||
Title: strings.TrimSpace(r.FormValue("title")),
|
||||
Slug: strings.TrimSpace(r.FormValue("slug")),
|
||||
ParentID: parentID,
|
||||
ContentMD: r.FormValue("content_md"),
|
||||
Status: strings.TrimSpace(r.FormValue("status")),
|
||||
}
|
||||
it, err := s.Store.Create(r.Context(), in)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/i/"+it.Path, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) {
|
||||
orphans, err := s.Store.MaiOrphans(r.Context())
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
parents, err := s.parentOptions(r.Context())
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
s.render(w, "classify", map[string]any{
|
||||
"Title": "classify",
|
||||
"Orphans": orphans,
|
||||
"ParentOptions": parents,
|
||||
})
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
// ParentOption is a flat option for the parent <select>.
|
||||
type ParentOption struct {
|
||||
ID string
|
||||
Path string
|
||||
}
|
||||
|
||||
func (s *Server) parentOptions(ctx context.Context) ([]ParentOption, error) {
|
||||
items, err := s.Store.ListAll(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []ParentOption
|
||||
for _, it := range items {
|
||||
if it.Source != "projax" {
|
||||
continue
|
||||
}
|
||||
out = append(out, ParentOption{ID: it.ID, Path: it.Path})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildForest(items []*store.Item) (areas []*treeNode, orphans []*store.Item, projaxN, maiN int) {
|
||||
byID := make(map[string]*treeNode, len(items))
|
||||
for _, it := range items {
|
||||
if it.Source == "projax" {
|
||||
projaxN++
|
||||
byID[it.ID] = &treeNode{Item: it}
|
||||
} else {
|
||||
maiN++
|
||||
orphans = append(orphans, it)
|
||||
}
|
||||
}
|
||||
for _, n := range byID {
|
||||
if n.Item.ParentID == nil {
|
||||
areas = append(areas, n)
|
||||
continue
|
||||
}
|
||||
if parent, ok := byID[*n.Item.ParentID]; ok {
|
||||
parent.Children = append(parent.Children, n)
|
||||
}
|
||||
}
|
||||
sort.Slice(areas, func(i, j int) bool { return areas[i].Item.Slug < areas[j].Item.Slug })
|
||||
for _, n := range byID {
|
||||
sort.Slice(n.Children, func(i, j int) bool { return n.Children[i].Item.Slug < n.Children[j].Item.Slug })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) render(w http.ResponseWriter, name string, data map[string]any) {
|
||||
t, ok := s.pages[name]
|
||||
if !ok {
|
||||
http.Error(w, "unknown page: "+name, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
s.Logger.Error("render", "page", name, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
s.render(w, "error", map[string]any{
|
||||
"Title": "error",
|
||||
"Message": err.Error(),
|
||||
})
|
||||
s.Logger.Error("handler", "path", r.URL.Path, "err", err)
|
||||
}
|
||||
|
||||
// logging wraps the mux with a tiny access log.
|
||||
func logging(logger *slog.Logger, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
next.ServeHTTP(w, r)
|
||||
logger.Info("req", "method", r.Method, "path", r.URL.Path, "remote", r.RemoteAddr)
|
||||
})
|
||||
}
|
||||
190
web/server_test.go
Normal file
190
web/server_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/m/projax/db"
|
||||
"github.com/m/projax/store"
|
||||
"github.com/m/projax/web"
|
||||
)
|
||||
|
||||
var (
|
||||
migrateOnce sync.Once
|
||||
migrateErr error
|
||||
)
|
||||
|
||||
func mustServer(t *testing.T) (*web.Server, *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
dbURL := os.Getenv("PROJAX_DB_URL")
|
||||
if dbURL == "" {
|
||||
dbURL = os.Getenv("SUPABASE_DATABASE_URL")
|
||||
}
|
||||
if dbURL == "" {
|
||||
t.Skip("no PROJAX_DB_URL / SUPABASE_DATABASE_URL set — skipping HTTP integration test")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
pool, err := pgxpool.New(ctx, dbURL)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
t.Skipf("DB unreachable: %v", err)
|
||||
}
|
||||
migrateOnce.Do(func() { migrateErr = db.ApplyMigrations(ctx, pool) })
|
||||
if migrateErr != nil {
|
||||
t.Fatalf("migrate: %v", migrateErr)
|
||||
}
|
||||
srv, err := web.New(store.New(pool), slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatalf("server: %v", err)
|
||||
}
|
||||
return srv, pool
|
||||
}
|
||||
|
||||
func get(t *testing.T, h http.Handler, url string) (int, string) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
body, _ := io.ReadAll(w.Result().Body)
|
||||
return w.Result().StatusCode, string(body)
|
||||
}
|
||||
|
||||
func TestTreeRenders(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
code, body := get(t, h, "/")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET / status %d body=%s", code, body)
|
||||
}
|
||||
for _, want := range []string{"<h1>Tree</h1>", "/i/dev", "/i/home", "/admin/classify"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("body missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
code, body := get(t, srv.Routes(), "/healthz")
|
||||
if code != 200 || strings.TrimSpace(body) != "ok" {
|
||||
t.Fatalf("healthz: %d %q", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailProjaxNativeEditable(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
code, body := get(t, srv.Routes(), "/i/dev")
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d", code)
|
||||
}
|
||||
if !strings.Contains(body, `form method="post" action="/i/dev"`) {
|
||||
t.Errorf("editable form missing for /i/dev")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailMaiProjectsReadOnly(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
code, body := get(t, srv.Routes(), "/i/mai.dotfiles")
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d", code)
|
||||
}
|
||||
if !strings.Contains(body, "Promote to projax") {
|
||||
t.Errorf("Promote section missing for mai.projects row")
|
||||
}
|
||||
if !strings.Contains(body, `action="/i/mai.dotfiles/promote"`) {
|
||||
t.Errorf("promote form missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyListsOrphans(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
code, body := get(t, srv.Routes(), "/admin/classify")
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d", code)
|
||||
}
|
||||
if !strings.Contains(body, "unclassified rows") {
|
||||
t.Errorf("classify missing summary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteRoundTrip(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
|
||||
// Pick an orphan to promote.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var maiID, maiPath string
|
||||
if err := pool.QueryRow(ctx,
|
||||
`select source_ref_id, path from projax.items_unified where source='mai.projects' limit 1`,
|
||||
).Scan(&maiID, &maiPath); err != nil {
|
||||
t.Fatalf("pick orphan: %v", err)
|
||||
}
|
||||
if maiID == "" {
|
||||
t.Skip("no mai.projects orphans available")
|
||||
}
|
||||
|
||||
var devID string
|
||||
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and parent_id is null`).Scan(&devID); err != nil {
|
||||
t.Fatalf("dev: %v", err)
|
||||
}
|
||||
|
||||
promoSlug := "test-promo-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||
form := url.Values{}
|
||||
form.Set("parent_id", devID)
|
||||
form.Set("slug", promoSlug)
|
||||
form.Set("title", "Promo "+maiID)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/i/"+maiPath+"/promote", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Result().StatusCode != http.StatusSeeOther {
|
||||
body, _ := io.ReadAll(w.Result().Body)
|
||||
t.Fatalf("promote status %d body=%s", w.Result().StatusCode, body)
|
||||
}
|
||||
loc := w.Result().Header.Get("Location")
|
||||
wantLoc := "/i/dev." + promoSlug
|
||||
if loc != wantLoc {
|
||||
t.Errorf("redirect Location = %q, want %q", loc, wantLoc)
|
||||
}
|
||||
|
||||
// The mai row should be hidden from items_unified now.
|
||||
var still int
|
||||
if err := pool.QueryRow(ctx,
|
||||
`select count(*) from projax.items_unified where source='mai.projects' and source_ref_id=$1`, maiID,
|
||||
).Scan(&still); err != nil {
|
||||
t.Fatalf("post-promote count: %v", err)
|
||||
}
|
||||
if still != 0 {
|
||||
t.Errorf("expected mai source row hidden after promote, got count=%d", still)
|
||||
}
|
||||
|
||||
// Clean up to keep test idempotent.
|
||||
if _, err := pool.Exec(ctx, `delete from projax.item_links where ref_type='mai-project' and ref_id=$1`, maiID); err != nil {
|
||||
t.Fatalf("cleanup link: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `delete from projax.items where slug=$1 and parent_id=$2`, promoSlug, devID); err != nil {
|
||||
t.Fatalf("cleanup item: %v", err)
|
||||
}
|
||||
}
|
||||
58
web/static/style.css
Normal file
58
web/static/style.css
Normal file
@@ -0,0 +1,58 @@
|
||||
/* projax — minimal style. Desktop browser only. */
|
||||
:root {
|
||||
--fg: #1a1a1a;
|
||||
--muted: #6a6a6a;
|
||||
--bg: #fafafa;
|
||||
--bg-alt: #f0efe8;
|
||||
--border: #d8d4c8;
|
||||
--accent: #2f5d9e;
|
||||
--warn: #b35900;
|
||||
--ok: #2b7a4b;
|
||||
--bad: #a02929;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--fg); background: var(--bg); }
|
||||
body { margin: 0; }
|
||||
header { background: var(--bg-alt); border-bottom: 1px solid var(--border); padding: 8px 16px; }
|
||||
header nav { display: flex; gap: 16px; align-items: center; }
|
||||
header .brand { font-weight: 600; font-size: 1.1em; color: var(--fg); text-decoration: none; }
|
||||
header a { color: var(--accent); text-decoration: none; }
|
||||
header a:hover { text-decoration: underline; }
|
||||
main { padding: 16px 24px; max-width: 1100px; margin: 0 auto; }
|
||||
h1 { font-size: 1.4em; margin: 0 0 8px; }
|
||||
h2 { font-size: 1.1em; margin: 24px 0 8px; }
|
||||
.counts { color: var(--muted); margin: 0 0 16px; }
|
||||
.tree ul { list-style: none; padding-left: 18px; margin: 4px 0; border-left: 1px dotted var(--border); }
|
||||
.tree > ul.forest { padding-left: 0; border-left: none; }
|
||||
.node { margin: 2px 0; }
|
||||
.node.area > a { font-weight: 600; }
|
||||
.slug { color: var(--muted); font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.85em; margin-left: 8px; }
|
||||
.status { display: inline-block; font-size: 0.75em; padding: 1px 6px; border-radius: 999px; border: 1px solid var(--border); background: #fff; margin-left: 8px; }
|
||||
.status-active { color: var(--ok); }
|
||||
.status-done { color: var(--accent); }
|
||||
.status-archived { color: var(--muted); }
|
||||
.source { display: inline-block; font-size: 0.75em; padding: 1px 6px; border-radius: 4px; background: var(--bg-alt); border: 1px solid var(--border); }
|
||||
.source-mai\.projects { color: var(--warn); }
|
||||
.add { margin-left: 6px; color: var(--accent); text-decoration: none; }
|
||||
.add:hover { text-decoration: underline; }
|
||||
.orphans { margin-top: 32px; }
|
||||
.flat { list-style: none; padding: 0; }
|
||||
.flat li { padding: 4px 0; border-bottom: 1px dashed var(--border); }
|
||||
.edit, .promote, .inline-promote { display: grid; gap: 12px; max-width: 720px; }
|
||||
.inline-promote { display: contents; }
|
||||
form label { display: flex; flex-direction: column; gap: 4px; font-size: 0.9em; color: var(--muted); }
|
||||
form label.checkbox { flex-direction: row; align-items: center; gap: 8px; }
|
||||
form input[type="text"], form input:not([type]), form select, form textarea {
|
||||
font: inherit; padding: 6px 8px; border: 1px solid var(--border); background: #fff; border-radius: 4px;
|
||||
}
|
||||
form textarea { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 0.92em; }
|
||||
form .actions { display: flex; gap: 12px; align-items: center; }
|
||||
button { font: inherit; padding: 6px 14px; border: 1px solid var(--accent); background: var(--accent); color: #fff; border-radius: 4px; cursor: pointer; }
|
||||
button:hover { filter: brightness(0.92); }
|
||||
.cancel { color: var(--muted); text-decoration: none; }
|
||||
.cancel:hover { text-decoration: underline; color: var(--bad); }
|
||||
.readonly pre { background: var(--bg-alt); padding: 12px; border: 1px solid var(--border); border-radius: 4px; white-space: pre-wrap; }
|
||||
table.classify { width: 100%; border-collapse: collapse; margin-top: 16px; }
|
||||
table.classify th, table.classify td { padding: 8px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; }
|
||||
table.classify input, table.classify select { width: 100%; }
|
||||
.error { color: var(--bad); }
|
||||
37
web/templates/classify.tmpl
Normal file
37
web/templates/classify.tmpl
Normal file
@@ -0,0 +1,37 @@
|
||||
{{define "content"}}
|
||||
<h1>Classify mai.projects orphans</h1>
|
||||
<p>{{len .Orphans}} unclassified rows. Pick an area (or any projax item) per row and click Promote — keeps the original mai.projects row untouched and links back via item_links.</p>
|
||||
|
||||
<table class="classify">
|
||||
<thead>
|
||||
<tr><th>Mai ID / Title</th><th>Status</th><th>Parent</th><th>Slug</th><th>Title</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Orphans}}
|
||||
<tr id="row-{{.SourceRefDeref}}">
|
||||
<td>
|
||||
<a href="/i/{{.Path}}">{{.Title}}</a>
|
||||
<br><small class="slug">{{.SourceRefDeref}}</small>
|
||||
</td>
|
||||
<td><span class="status status-{{.Status}}">{{.Status}}</span></td>
|
||||
<td>
|
||||
<form
|
||||
hx-post="/i/{{.Path}}/promote"
|
||||
hx-target="#row-{{.SourceRefDeref}}"
|
||||
hx-swap="outerHTML"
|
||||
class="inline-promote">
|
||||
<select name="parent_id" required>
|
||||
<option value="">— pick parent —</option>
|
||||
{{range $.ParentOptions}}<option value="{{.ID}}">{{.Path}}</option>{{end}}
|
||||
</select>
|
||||
</td>
|
||||
<td><input name="slug" value="{{.Slug}}" required pattern="[^.]+"></td>
|
||||
<td><input name="title" value="{{.Title}}" required></td>
|
||||
<td><button type="submit">Promote</button></form></td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="6"><em>No orphans. Everything is classified.</em></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
71
web/templates/detail.tmpl
Normal file
71
web/templates/detail.tmpl
Normal file
@@ -0,0 +1,71 @@
|
||||
{{define "content"}}
|
||||
<h1>{{.Item.Title}}</h1>
|
||||
<p class="meta">
|
||||
<span class="source source-{{.Item.Source}}">{{.Item.Source}}</span>
|
||||
<span class="slug">{{.Item.Path}}</span>
|
||||
<span class="status status-{{.Item.Status}}">{{.Item.Status}}</span>
|
||||
{{if .Item.Pinned}}<span class="pin">pinned</span>{{end}}
|
||||
{{if .Item.Archived}}<span class="archived">archived</span>{{end}}
|
||||
</p>
|
||||
|
||||
{{if .Item.Editable}}
|
||||
<form method="post" action="/i/{{.Item.Path}}" class="edit">
|
||||
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
|
||||
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>
|
||||
<label>Parent
|
||||
<select name="parent_id">
|
||||
{{if .Item.IsArea}}
|
||||
<option value="" selected>(root area)</option>
|
||||
{{else}}
|
||||
{{range .ParentOptions}}
|
||||
<option value="{{.ID}}" {{if and $.Item.ParentID (eq .ID (deref $.Item.ParentID))}}selected{{end}}>{{.Path}}</option>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Status
|
||||
<select name="status">
|
||||
{{range $opt := .StatusOptions}}
|
||||
<option value="{{$opt}}" {{if eq $opt $.Item.Status}}selected{{end}}>{{$opt}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="pinned" value="1" {{if .Item.Pinned}}checked{{end}}> pinned
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="archived" value="1" {{if .Item.Archived}}checked{{end}}> archived
|
||||
</label>
|
||||
<label>Content
|
||||
<textarea name="content_md" rows="14">{{.Item.ContentMD}}</textarea>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="submit">Save</button>
|
||||
<a class="cancel" href="/">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{{else}}
|
||||
<div class="readonly">
|
||||
<p><em>Read-only: this row is sourced from {{.Item.Source}}.</em></p>
|
||||
<pre class="content">{{.Item.ContentMD}}</pre>
|
||||
|
||||
<h2>Promote to projax</h2>
|
||||
<p>Pick the area or project this should live under. mai.projects row stays untouched; the projax item links back to it via item_links.</p>
|
||||
<form method="post" action="/i/{{.Item.Path}}/promote" class="promote">
|
||||
<label>Parent
|
||||
<select name="parent_id" required>
|
||||
{{range .ParentOptions}}
|
||||
<option value="{{.ID}}">{{.Path}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>
|
||||
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
|
||||
<div class="actions">
|
||||
<button type="submit">Promote</button>
|
||||
<a class="cancel" href="/">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
5
web/templates/error.tmpl
Normal file
5
web/templates/error.tmpl
Normal file
@@ -0,0 +1,5 @@
|
||||
{{define "content"}}
|
||||
<h1>Error</h1>
|
||||
<p class="error">{{.Message}}</p>
|
||||
<p><a href="/">Back to tree</a></p>
|
||||
{{end}}
|
||||
20
web/templates/layout.tmpl
Normal file
20
web/templates/layout.tmpl
Normal file
@@ -0,0 +1,20 @@
|
||||
{{define "layout"}}<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{.Title}} — projax</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12" integrity="sha384-ujb1lZYygJmzgSwoxRggbCHcjc0rB2XoQrxeTUQyRjrOnlCoYta87iKBWq3EsdM2" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<a href="/" class="brand">projax</a>
|
||||
<a href="/admin/classify">classify orphans</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
28
web/templates/new.tmpl
Normal file
28
web/templates/new.tmpl
Normal file
@@ -0,0 +1,28 @@
|
||||
{{define "content"}}
|
||||
<h1>New item</h1>
|
||||
<p class="meta">Parent: <strong>{{if .Parent}}{{.Parent.Path}}{{else}}(root area){{end}}</strong></p>
|
||||
|
||||
<form method="post" action="/new" class="edit">
|
||||
{{if .Parent}}<input type="hidden" name="parent_id" value="{{.Parent.ID}}">{{end}}
|
||||
<label>Kind
|
||||
<select name="kind">
|
||||
{{if not .Parent}}<option value="area" selected>area</option>{{end}}
|
||||
<option value="project" {{if .Parent}}selected{{end}}>project</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Title <input name="title" required></label>
|
||||
<label>Slug <input name="slug" required pattern="[^.]+" placeholder="lowercase, no dots"></label>
|
||||
<label>Status
|
||||
<select name="status">
|
||||
{{range $opt := .StatusOptions}}<option value="{{$opt}}">{{$opt}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Content
|
||||
<textarea name="content_md" rows="10"></textarea>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="submit">Create</button>
|
||||
<a class="cancel" href="{{if .Parent}}/i/{{.Parent.Path}}{{else}}/{{end}}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
52
web/templates/tree.tmpl
Normal file
52
web/templates/tree.tmpl
Normal file
@@ -0,0 +1,52 @@
|
||||
{{define "content"}}
|
||||
<h1>Tree</h1>
|
||||
<p class="counts">
|
||||
<strong>{{.ProjaxCount}}</strong> projax · <strong>{{.MaiCount}}</strong> mai.projects orphans
|
||||
{{if .MaiCount}}<a href="/admin/classify">→ classify</a>{{end}}
|
||||
</p>
|
||||
|
||||
<section class="tree">
|
||||
<h2>Areas + projax items</h2>
|
||||
<ul class="forest">
|
||||
{{range .Areas}}
|
||||
<li class="node area">
|
||||
<a href="/i/{{.Item.Path}}">{{.Item.Title}}</a>
|
||||
<span class="slug">{{.Item.Path}}</span>
|
||||
<a class="add" href="/new?parent={{.Item.Path}}">+</a>
|
||||
{{template "children" .}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{{if .Orphans}}
|
||||
<section class="orphans">
|
||||
<h2>mai.projects orphans <small>(unclassified)</small></h2>
|
||||
<ul class="flat">
|
||||
{{range .Orphans}}
|
||||
<li class="node orphan">
|
||||
<a href="/i/{{.Path}}">{{.Title}}</a>
|
||||
<span class="slug">{{.Path}}</span>
|
||||
<span class="status status-{{.Status}}">{{.Status}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "children"}}
|
||||
{{if .Children}}
|
||||
<ul>
|
||||
{{range .Children}}
|
||||
<li class="node project">
|
||||
<a href="/i/{{.Item.Path}}">{{.Item.Title}}</a>
|
||||
<span class="slug">{{.Item.Path}}</span>
|
||||
<span class="status status-{{.Item.Status}}">{{.Item.Status}}</span>
|
||||
<a class="add" href="/new?parent={{.Item.Path}}">+</a>
|
||||
{{template "children" .}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user