Removes all indigo/emerald/amber/red accent colors — everything now uses the slate ramp. Strips rounded corners from containers, cards, buttons, inputs, badges; the only remaining rounded element is the circular logo image itself. Affordances preserved without color: - Primary buttons (Publish/Log in/Unlock/Copy link flash): bg-slate-100 on dark background for high contrast - Secondary buttons (Copy/Open): bg-slate-800 - Destructive (Delete): outlined with border-slate-500 so it reads as different from secondary - Error messages: uppercase "ERROR" prefix + left border accent - Success flash / ready drop state: uppercase "PUBLISHED"/"READY" labels and solid border-slate-200 vs. the dashed/empty state - Protected lock: amber badge swapped for a slate-100 badge overlay - Chart: slate-200 line on slate-400/20 fill instead of indigo Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
137 lines
3.6 KiB
Go
137 lines
3.6 KiB
Go
package server
|
|
|
|
import (
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"git.hubris.network/dtoro/artifacto/internal/store"
|
|
)
|
|
|
|
type detailData struct {
|
|
BaseURL string
|
|
Artifact *store.Artifact
|
|
Series []store.DayPoint
|
|
Referrers []store.ReferrerCount
|
|
ChartSVG template.HTML
|
|
}
|
|
|
|
func (s *Server) getArtifactDetail(w http.ResponseWriter, r *http.Request) {
|
|
slug := chi.URLParam(r, "slug")
|
|
art, err := s.store.GetArtifact(slug)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
series, err := s.store.SeriesFor(slug, 30)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
refs, err := s.store.TopReferrers(slug, 10)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.render(w, "detail", detailData{
|
|
BaseURL: s.cfg.BaseURL,
|
|
Artifact: art,
|
|
Series: series,
|
|
Referrers: refs,
|
|
ChartSVG: template.HTML(renderChart(series)),
|
|
})
|
|
}
|
|
|
|
// renderChart produces a tiny SVG line+area chart with day labels.
|
|
func renderChart(series []store.DayPoint) string {
|
|
const w = 720
|
|
const h = 160
|
|
const padLeft = 30
|
|
const padBottom = 20
|
|
const padTop = 10
|
|
const padRight = 10
|
|
|
|
innerW := w - padLeft - padRight
|
|
innerH := h - padTop - padBottom
|
|
|
|
var max int64
|
|
for _, p := range series {
|
|
if p.Views > max {
|
|
max = p.Views
|
|
}
|
|
}
|
|
if max < 1 {
|
|
max = 1
|
|
}
|
|
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, `<svg viewBox="0 0 %d %d" class="w-full h-40 text-slate-400" xmlns="http://www.w3.org/2000/svg">`, w, h)
|
|
// axes
|
|
fmt.Fprintf(&b, `<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="currentColor" stroke-width="1"/>`,
|
|
padLeft, padTop, padLeft, padTop+innerH)
|
|
fmt.Fprintf(&b, `<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="currentColor" stroke-width="1"/>`,
|
|
padLeft, padTop+innerH, padLeft+innerW, padTop+innerH)
|
|
// horizontal gridline at max
|
|
fmt.Fprintf(&b, `<text x="%d" y="%d" font-size="10" fill="currentColor" text-anchor="end">%d</text>`,
|
|
padLeft-4, padTop+6, max)
|
|
fmt.Fprintf(&b, `<text x="%d" y="%d" font-size="10" fill="currentColor" text-anchor="end">0</text>`,
|
|
padLeft-4, padTop+innerH)
|
|
|
|
n := len(series)
|
|
if n == 0 {
|
|
b.WriteString(`</svg>`)
|
|
return b.String()
|
|
}
|
|
|
|
step := float64(innerW) / float64(maxInt(n-1, 1))
|
|
|
|
// build polyline path
|
|
var pts strings.Builder
|
|
for i, p := range series {
|
|
x := float64(padLeft) + float64(i)*step
|
|
y := float64(padTop+innerH) - float64(p.Views)/float64(max)*float64(innerH)
|
|
if i > 0 {
|
|
pts.WriteByte(' ')
|
|
}
|
|
fmt.Fprintf(&pts, "%.1f,%.1f", x, y)
|
|
}
|
|
|
|
// area under curve
|
|
var area strings.Builder
|
|
fmt.Fprintf(&area, "M%d,%d ", padLeft, padTop+innerH)
|
|
for i, p := range series {
|
|
x := float64(padLeft) + float64(i)*step
|
|
y := float64(padTop+innerH) - float64(p.Views)/float64(max)*float64(innerH)
|
|
fmt.Fprintf(&area, "L%.1f,%.1f ", x, y)
|
|
}
|
|
fmt.Fprintf(&area, "L%.1f,%d Z", float64(padLeft)+float64(n-1)*step, padTop+innerH)
|
|
|
|
fmt.Fprintf(&b, `<path d="%s" fill="rgb(148 163 184 / 0.2)" stroke="none"/>`, area.String())
|
|
fmt.Fprintf(&b, `<polyline fill="none" stroke="rgb(226 232 240)" stroke-width="2" points="%s"/>`, pts.String())
|
|
|
|
// x-axis tick labels: first, middle, last day
|
|
labelIdx := []int{0, n / 2, n - 1}
|
|
for _, i := range labelIdx {
|
|
if i < 0 || i >= n {
|
|
continue
|
|
}
|
|
x := float64(padLeft) + float64(i)*step
|
|
fmt.Fprintf(&b, `<text x="%.1f" y="%d" font-size="10" fill="currentColor" text-anchor="middle">%s</text>`,
|
|
x, padTop+innerH+14, shortDay(series[i].Day))
|
|
}
|
|
|
|
b.WriteString(`</svg>`)
|
|
return b.String()
|
|
}
|
|
|
|
func shortDay(iso string) string {
|
|
// yyyy-mm-dd → mm-dd
|
|
if len(iso) >= 10 {
|
|
return iso[5:10]
|
|
}
|
|
return iso
|
|
}
|