Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion internal/server/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -419,9 +419,11 @@ func (s *Server) handleSites(w http.ResponseWriter, r *http.Request) {
"updatedAt": updatedAt,
"bytes": s.siteBytes(e.Name()),
}
views, current, deploys := s.meta.stats(e.Name())
views, current, createdAt, deploys := s.meta.stats(e.Name())
entry["views"] = views.Total
if current != nil {
entry["createdAt"] = createdAt
entry["updatedAt"] = current.Time
entry["deployer"] = current.Deployer
entry["deploys"] = deploys
}
Expand Down
22 changes: 17 additions & 5 deletions internal/server/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ type viewStats struct {
}

type siteMeta struct {
Deploys []deployRecord `json:"deploys"`
Views viewStats `json:"views"`
CreatedAt string `json:"createdAt,omitempty"`
Deploys []deployRecord `json:"deploys"`
Views viewStats `json:"views"`
}

type pendingViews struct {
Expand Down Expand Up @@ -83,6 +84,12 @@ func (m *metaStore) load(site string) *siteMeta {
log.Printf("meta: could not read %s: %v", m.path(site), err)
}
m.cache[site] = sm
if sm.CreatedAt == "" && len(sm.Deploys) > 0 {
// Legacy metadata did not persist creation separately. Preserve the
// oldest timestamp still available rather than letting it advance again.
sm.CreatedAt = sm.Deploys[0].Time
m.persist(site)
}
return sm
}

Expand Down Expand Up @@ -131,13 +138,17 @@ func (m *metaStore) record(site, deployer, source string) int64 {
m.mu.Lock()
defer m.mu.Unlock()
sm := m.load(site)
now := time.Now().UTC().Format(time.RFC3339)
seq := int64(1)
if n := len(sm.Deploys); n > 0 {
seq = sm.Deploys[n-1].Seq + 1
}
if sm.CreatedAt == "" {
sm.CreatedAt = now
}
sm.Deploys = append(sm.Deploys, deployRecord{
Seq: seq,
Time: time.Now().UTC().Format(time.RFC3339),
Time: now,
Deployer: deployer,
Source: source,
})
Expand All @@ -148,20 +159,21 @@ func (m *metaStore) record(site, deployer, source string) int64 {
return seq
}

func (m *metaStore) stats(site string) (views viewStats, current *deployRecord, deploys int64) {
func (m *metaStore) stats(site string) (views viewStats, current *deployRecord, createdAt string, deploys int64) {
m.mu.Lock()
defer m.mu.Unlock()
sm := m.load(site)
views = sm.Views
if p := m.views[site]; p != nil {
views.Total += p.count
}
createdAt = sm.CreatedAt
if n := len(sm.Deploys); n > 0 {
rec := sm.Deploys[n-1]
current = &rec
deploys = rec.Seq
}
return views, current, deploys
return views, current, createdAt, deploys
}

func (m *metaStore) drop(site string) {
Expand Down
39 changes: 39 additions & 0 deletions internal/server/meta_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package server

import "testing"

func TestCreationTimeSurvivesDeployHistoryRetention(t *testing.T) {
dir := t.TempDir()
m, err := newMetaStore(dir)
if err != nil {
t.Fatal(err)
}

const createdAt = "2001-02-03T04:05:06Z"
m.mu.Lock()
m.cache["site"] = &siteMeta{
CreatedAt: createdAt,
Deploys: []deployRecord{{
Seq: 1,
Time: createdAt,
}},
}
m.persist("site")
m.mu.Unlock()

for range maxDeployHistory {
m.record("site", "alice", "update")
}

reopened, err := newMetaStore(dir)
if err != nil {
t.Fatal(err)
}
_, _, got, deploys := reopened.stats("site")
if got != createdAt {
t.Fatalf("creation time changed after history trimming: got %q, want %q", got, createdAt)
}
if deploys != maxDeployHistory+1 {
t.Fatalf("deploy count = %d, want %d", deploys, maxDeployHistory+1)
}
}
5 changes: 5 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"

"github.com/sdelcore/shared/internal/store"
"github.com/sdelcore/shared/internal/web"
)

type Server struct {
Expand Down Expand Up @@ -78,6 +79,10 @@ func (s *Server) ListenAndServe() error {
s.api.ServeHTTP(w, r)
case r.URL.Path == "/shared.js":
s.handleSharedJS(w, r)
case r.URL.Path == "/favicon.svg" && siteFromHost(r.Host, s.BaseHost) == "":
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Write(web.FaviconSVG)
// Base host only: on a site host this path belongs to the site, and
// intercepting it would shadow a deployed file of the same name.
case r.URL.Path == "/skill.md" && siteFromHost(r.Host, s.BaseHost) == "":
Expand Down
5 changes: 5 additions & 0 deletions internal/web/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
83 changes: 78 additions & 5 deletions internal/web/home.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>shared</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<style>
:root {
--paper: #f4f1ea;
Expand Down Expand Up @@ -50,6 +51,19 @@
}
h2::before { content: "§ "; color: var(--faint); }

.site-heading { display: flex; align-items: end; justify-content: space-between; gap: 1rem; margin-bottom: 1.25rem; }
.site-heading h2 { margin-bottom: 0; }
.controls { display: flex; gap: .65rem; flex-wrap: wrap; }
.controls input, .controls select {
border: 1px solid var(--rule);
border-radius: 0;
background: transparent;
color: var(--ink);
font: .75rem ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
padding: .55rem .65rem;
}
.controls input { width: 13rem; }
.controls input:focus, .controls select:focus { outline: 2px solid var(--rust); outline-offset: 1px; }
.sites { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 1px; background: var(--rule); border: 1px solid var(--rule); }
.cell { position: relative; background: var(--paper); }
.del {
Expand Down Expand Up @@ -87,6 +101,11 @@
font-size: .72rem;
color: var(--faint);
}
.site .byline {
margin-top: .65rem;
font-size: .78rem;
color: var(--rust);
}

.empty {
border: 1px dashed var(--rule);
Expand Down Expand Up @@ -116,6 +135,15 @@
padding: .1em .35em;
}
.note { margin-top: 1rem; font-size: .95rem; color: var(--faint); max-width: 60ch; }
.skill-link { color: var(--rust); text-underline-offset: .18em; }
.skill-link:hover { color: var(--ink); }

@media (max-width: 620px) {
.site-heading { align-items: stretch; flex-direction: column; }
.controls { display: grid; grid-template-columns: 1fr; }
.controls input { width: 100%; }
.del { opacity: 1; }
}

footer {
margin-top: 4rem;
Expand All @@ -136,7 +164,18 @@ <h1>shared</h1>
</header>

<section>
<h2>Sites</h2>
<div class="site-heading">
<h2>Sites</h2>
<div class="controls">
<input id="site-filter" type="search" placeholder="Filter sites or creators" aria-label="Filter sites or creators">
<select id="site-sort" aria-label="Sort sites">
<option value="updated">Recently updated</option>
<option value="created">Recently created</option>
<option value="name">Name</option>
<option value="views">Most viewed</option>
</select>
</div>
</div>
<div id="sites"><div class="empty">Consulting the index…</div></div>
</section>

Expand All @@ -153,6 +192,7 @@ <h2>Getting started</h2>
Every site can include <code class="inline">&lt;script src="/shared.js"&gt;&lt;/script&gt;</code>
to get the <code class="inline">shared</code> API for free: a document database with realtime
subscriptions, an AI chat proxy, file uploads, websocket channels, and identity.
<a class="skill-link" href="/skill.md" download="shared-sites-SKILL.md">Download the agent skill →</a>
</p>
</div>
</section>
Expand All @@ -164,6 +204,9 @@ <h2>Getting started</h2>
(function () {
const host = location.hostname;
const port = location.port ? ':' + location.port : '';
const filter = document.getElementById('site-filter');
const sort = document.getElementById('site-sort');
let allSites = [];

document.getElementById('example-url').textContent =
'http://mysite.' + host + port + '/';
Expand All @@ -180,10 +223,12 @@ <h2>Getting started</h2>
return n.toFixed(1) + ' ' + units[i];
}

function render(sites) {
function render(sites, filtered) {
const root = document.getElementById('sites');
if (!sites.length) {
root.innerHTML = '<div class="empty">Nothing here yet. Your first deploy is one command away — see below.</div>';
root.innerHTML = '<div class="empty">' + (filtered
? 'No sites match that filter.'
: 'Nothing here yet. Your first deploy is one command away — see below.') + '</div>';
return;
}
const grid = document.createElement('div');
Expand All @@ -208,6 +253,12 @@ <h2>Getting started</h2>
}
meta.textContent = parts.join(' · ');
a.append(name, meta);
if (site.deployer) {
const byline = document.createElement('div');
byline.className = 'byline';
byline.textContent = 'by ' + site.deployer;
a.append(byline);
}
const del = document.createElement('button');
del.className = 'del';
del.type = 'button';
Expand All @@ -219,7 +270,8 @@ <h2>Getting started</h2>
.then(function (res) {
if (!res.ok) throw new Error('delete failed (' + res.status + ')');
cell.remove();
if (!grid.children.length) render([]);
allSites = allSites.filter(function (item) { return item.name !== site.name; });
update();
})
.catch(function (err) { alert(err.message); });
});
Expand All @@ -229,12 +281,33 @@ <h2>Getting started</h2>
root.replaceChildren(grid);
}

function update() {
const query = filter.value.trim().toLocaleLowerCase();
const sites = allSites.filter(function (site) {
return !query || site.name.toLocaleLowerCase().includes(query) ||
(site.deployer || '').toLocaleLowerCase().includes(query);
});
sites.sort(function (a, b) {
if (sort.value === 'name') return a.name.localeCompare(b.name);
if (sort.value === 'views') return (b.views || 0) - (a.views || 0) || a.name.localeCompare(b.name);
const field = sort.value === 'created' ? 'createdAt' : 'updatedAt';
return Date.parse(b[field] || 0) - Date.parse(a[field] || 0) || a.name.localeCompare(b.name);
});
render(sites, Boolean(query));
}

filter.addEventListener('input', update);
sort.addEventListener('change', update);

fetch('/api/sites')
.then(function (res) {
if (!res.ok) throw new Error('sites request failed (' + res.status + ')');
return res.json();
})
.then(function (body) { render(body.sites || []); })
.then(function (body) {
allSites = body.sites || [];
update();
})
.catch(function (err) {
document.getElementById('sites').innerHTML =
'<div class="empty error">Could not load sites: ' + err.message + '</div>';
Expand Down
3 changes: 3 additions & 0 deletions internal/web/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,8 @@ var SharedJS []byte
//go:embed home.html
var HomeHTML []byte

//go:embed favicon.svg
var FaviconSVG []byte

//go:embed init/index.html
var InitIndexHTML []byte
Loading