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
6 changes: 6 additions & 0 deletions apps/web/public/store.css
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,12 @@ section.block { padding: 36px 0; }
.card .by { font-size: 13px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card .sum { font-size: 13.5px; color: var(--ink-2); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.card .meta { margin-top: auto; padding-top: 6px; display: flex; gap: 10px; font: 11.5px var(--mono); color: var(--muted); }
.tag {
display: inline-flex; align-items: center; gap: 4px; align-self: flex-start; padding: 2px 8px; border-radius: 999px;
font: 600 11px var(--mono); border: 1px solid var(--line); color: var(--ink-2); background: var(--paper-2); white-space: nowrap;
}
.tag.offline::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: #3f8f5a; }
.card .tag { margin-top: 4px; }
.icon { border-radius: 22%; background: var(--paper-2); object-fit: cover; border: 1px solid var(--line); }
.icon.ph { display: grid; place-items: center; font: 400 24px var(--serif); color: var(--paper); background: var(--ink-2); }
.feature-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 14px; }
Expand Down
2 changes: 1 addition & 1 deletion apps/web/public/sw.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// pwamart service worker. Static assets: cache first, refreshed in the background.
// Pages and the API: network first, so a listing is never stale; the last copy of a
// page is the offline fallback. Bump VERSION when an asset changes shape.
const VERSION = 'pwamart-v4';
const VERSION = 'pwamart-v5';

// Push: "Get notified" releases (handlers from @profullstack/notifications).
importScripts('/assets/push-sw.js');
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import { applyPurchase, billingState, currentPlan, quote, setRenew } from './bil
import { checkClaim, shapeClaim, startClaim } from './imports.js';
import { follow as followFn } from './releases.js';
import { vapidKeysFromEnv } from '@profullstack/notifications/server';
import { PUBLISHER_FILTERS, PUBLISHER_SORTS, categoryCounts, getApp, getPublisher, listApps, listPublishers, recordInstall, reviewsFor, shape } from './catalog.js';
import { FEATURES, PUBLISHER_FILTERS, PUBLISHER_SORTS, categoryCounts, featureKey, getApp, getPublisher, listApps, listPublishers, recordInstall, reviewsFor, shape } from './catalog.js';
import { CATEGORIES, CATEGORY_NAMES, PLANS, config } from './config.js';
import { InspectError, inspect, verifyOrigin } from './inspect.js';
import { InspectError, featureColumns, inspect, verifyOrigin } from './inspect.js';
import { sharedHost } from './shared-hosts.js';
import { send, sendLoginLink, sendOrgInvite } from './mail.js';
import * as newsletter from './newsletter.js';
Expand Down Expand Up @@ -257,12 +257,16 @@ api.get('/plans', (c) =>

api.get('/apps', async (c) => {
const q = c.req.query();
const raw = q.feature || (q.offline === '1' || q.offline === 'true' ? 'offline' : null);
const feature = featureKey(raw);
if (raw && !feature) return c.json({ error: `unknown feature; one of: ${Object.keys(FEATURES).join(', ')}` }, 400);
return c.json(
await listApps({
q: q.q,
category: q.category,
publisher: q.publisher,
featured: q.featured === '1' || q.featured === 'true',
feature,
sort: q.sort,
limit: q.limit,
offset: q.offset,
Expand Down Expand Up @@ -483,6 +487,7 @@ function fromReport(r) {
background_color: r.app.backgroundColor,
display: r.app.display,
check_report: sql.json({ installable: r.installable, score: r.score, checks: r.checks }),
...featureColumns(r),
};
}

Expand Down
18 changes: 11 additions & 7 deletions apps/web/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import * as auth from './auth.js';
import { getCookie } from 'hono/cookie';
import { mountOAuth } from './oauth.js';
import { mountCoinPaySignin } from './coinpay-signin.js';
import { PUBLISHER_FILTERS, PUBLISHER_SORTS, categoryCounts, getApp, getPublisher, listApps, listPublishers, reviewsFor, shape } from './catalog.js';
import { PUBLISHER_FILTERS, PUBLISHER_SORTS, categoryCounts, featureCounts, featureKey, getApp, getPublisher, listApps, listPublishers, reviewsFor, shape } from './catalog.js';
import { CATEGORIES, config } from './config.js';
import { handleRpc } from '@profullstack/pwamart-mcp/core';
import { buildProfile } from './mobileconfig.js';
Expand Down Expand Up @@ -80,14 +80,15 @@ const html = (c, body, status = 200) => c.html(body, status, { 'cache-control':
/* ---------------------------------------------------------------- pages -- */

app.get('/', async (c) => {
const [featured, top, fresh, counts, s] = await Promise.all([
const [featured, top, fresh, counts, features, s] = await Promise.all([
listApps({ featured: true, limit: 4 }),
listApps({ sort: 'top', limit: 12 }),
listApps({ sort: 'new', limit: 8 }),
categoryCounts(),
featureCounts(),
stats(),
]);
return html(c, homePage({ featured: featured.apps, top: top.apps, fresh: fresh.apps, counts, stats: s }));
return html(c, homePage({ featured: featured.apps, top: top.apps, fresh: fresh.apps, counts, features, stats: s }));
});

app.get('/apps', async (c) => {
Expand All @@ -97,12 +98,14 @@ app.get('/apps', async (c) => {
const offset = Math.max(0, Number(c.req.query('offset')) || 0);
const limit = 24;
const publisher = c.req.query('publisher') ? await getPublisher(c.req.query('publisher')) : null;
const [list, counts, s] = await Promise.all([
listApps({ q, category, publisher: publisher?.slug, sort: sort ?? 'top', limit, offset }),
const feature = featureKey(c.req.query('feature')) ?? (c.req.query('offline') === '1' ? 'offline' : null);
const [list, counts, features, s] = await Promise.all([
listApps({ q, category, publisher: publisher?.slug, feature, sort: sort ?? 'top', limit, offset }),
categoryCounts(),
featureCounts(),
stats(),
]);
return html(c, browsePage({ q, category, sort, list, counts, offset, limit, stats: s, publisher }));
return html(c, browsePage({ q, category, sort, list, counts, offset, limit, stats: s, publisher, feature, features }));
});

app.get('/publishers', async (c) => {
Expand Down Expand Up @@ -492,6 +495,7 @@ app.get('/sitemap.xml', async (c) => {
u('/publishers'),
u('/newsletter'),
...CATEGORIES.map(([k]) => u(`/apps?category=${k}`)),
u('/apps?feature=offline'),
...rows.map((r) => u(`/apps/${r.slug}`, r.updated_at)),
...pubs.map((p) => u(`/publishers/${p.slug}`)),
].join('');
Expand All @@ -511,7 +515,7 @@ Unlimited $199/year (unlimited publishers and apps, shared orgs, teams, projects

## API (${config.siteUrl}/api/v1)
Reads are public. Writes take \`Authorization: Bearer pm_live_...\` (create one in the console under API keys).
- GET /apps?q=&category=&publisher=&sort=top|new|rating|name&limit=&offset=
- GET /apps?q=&category=&publisher=&feature=offline&sort=top|new|rating|name&limit=&offset= feature=offline: a service worker that answers from a cache
- GET /apps/:slug details, publisher, reviews
- POST /apps/:slug/installs {method} count an install
- GET /categories, GET /plans, GET /publishers?q=&filter=all|claimed|imported|verified&sort=apps|name|new, GET /publishers/:slug
Expand Down
23 changes: 21 additions & 2 deletions apps/web/src/catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { CATEGORY_NAMES } from './config.js';
const PUBLIC_FIELDS = (sql) => sql`
a.id, a.slug, a.name, a.summary, a.description, a.url, a.origin, a.start_url, a.manifest_id,
a.manifest_url, a.icon_url, a.screenshots, a.theme_color, a.background_color, a.display,
a.category, a.tags, a.status, a.featured, a.installs, a.rating_count,
a.category, a.tags, a.features, a.offline_reason, a.status, a.featured, a.installs, a.rating_count,
case when a.rating_count > 0 then round(a.rating_sum::numeric / a.rating_count, 1) else null end as rating,
a.verified_at is not null as verified, a.check_report, a.checked_at, a.published_at, a.updated_at,
p.slug as publisher_slug, p.name as publisher_name, p.avatar_url as publisher_avatar, p.verified as publisher_verified, p.claimable as publisher_claimable, p.claim_domain as publisher_claim_domain`;
Expand All @@ -35,6 +35,9 @@ export function shape(row) {
category: row.category,
category_name: CATEGORY_NAMES[row.category] ?? row.category,
tags: row.tags ?? [],
features: row.features ?? [],
offline: (row.features ?? []).includes('offline'),
offline_reason: row.offline_reason ?? null,
status: row.status,
featured: row.featured,
installs: row.installs,
Expand Down Expand Up @@ -63,8 +66,23 @@ const SORTS = {
name: (sql) => sql`lower(a.name)`,
};

/** What the inspector can detect about an app, filterable everywhere as `feature=<key>`. */
export const FEATURES = { offline: 'Works offline' };
export const featureKey = (v) => (v && Object.hasOwn(FEATURES, String(v)) ? String(v) : null);

/** Published apps per feature: { offline: 42 }. */
export async function featureCounts() {
const sql = db();
const out = {};
for (const k of Object.keys(FEATURES)) {
const [r] = await sql`select count(*)::int as n from apps where status = 'published' and features @> array[${k}]::text[]`;
out[k] = r.n;
}
return out;
}

/** Published apps, searchable. `q` is websearch syntax, so raw input never errors. */
export async function listApps({ q, category, publisher, featured, sort = 'top', limit = 24, offset = 0 } = {}) {
export async function listApps({ q, category, publisher, featured, feature, sort = 'top', limit = 24, offset = 0 } = {}) {
const sql = db();
const lim = Math.min(Math.max(Number(limit) || 24, 1), 100);
const off = Math.max(Number(offset) || 0, 0);
Expand All @@ -79,6 +97,7 @@ export async function listApps({ q, category, publisher, featured, sort = 'top',
${category ? sql`and a.category = ${category}` : sql``}
${publisher ? sql`and p.slug = ${publisher}` : sql``}
${featured ? sql`and a.featured` : sql``}
${feature ? sql`and a.features @> array[${String(feature)}]::text[]` : sql``}
${
query
? sql`and (to_tsvector('english', coalesce(a.name, '') || ' ' || coalesce(a.summary, '') || ' ' || coalesce(a.description, ''))
Expand Down
37 changes: 37 additions & 0 deletions apps/web/src/daemon.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,43 @@ addJob('release-notify', 60_000, async () => {
await sendDeliveries();
});

/* ------------------------------------------------------ feature tags -- */

/**
* Listings never inspected for features (they predate the offline check) are
* re-inspected a few per turn, one site at a time, so the backlog clears in an hour
* or two without hammering anyone. A site that is down is marked checked anyway;
* release-detect's daily re-read tags it when it is back.
*/
export async function runFeatureBackfill({ batch = 6, inspectFn, sql = db() } = {}) {
const run = inspectFn ?? (await import('./inspect.js')).inspect;
const { featureColumns } = await import('./inspect.js');
const due = await sql`
select id, url, origin from apps
where status in ('published', 'unlisted') and features_checked_at is null
order by published_at nulls last limit ${batch}`;
let offline = 0;
for (const a of due) {
try {
const r = await run(a.url);
if (r.origin !== a.origin) throw new Error(`now lands on ${r.origin}`);
const f = featureColumns(r);
if (f.features.includes('offline')) offline++;
await sql`update apps set features = ${f.features}::text[], offline_reason = ${f.offline_reason}, features_checked_at = now(),
check_report = ${sql.json({ installable: r.installable, score: r.score, checks: r.checks })}
where id = ${a.id}`;
} catch (err) {
await sql`update apps set features_checked_at = now(), offline_reason = ${`not checked: ${String(err?.message ?? err)}`.slice(0, 200)} where id = ${a.id}`;
}
}
return { checked: due.length, offline };
}

addJob('feature-backfill', 2 * 60_000, async () => {
const r = await runFeatureBackfill();
if (r.checked) console.log(`[daemon] feature-backfill ${JSON.stringify(r)}`);
});

/* ------------------------------------------------- featured + newsletter -- */

// A paid feature comes off the home page when its week is up (staff picks never do).
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/imports.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto';
import { Resolver } from 'node:dns/promises';
import { db, orgs } from '@pwamart/db';
import { config } from './config.js';
import { inspect, verifyOrigin } from './inspect.js';
import { featureColumns, inspect, verifyOrigin } from './inspect.js';
import { sharedHost } from './shared-hosts.js';
import { sendClaimVerified } from './mail.js';

Expand Down Expand Up @@ -184,6 +184,7 @@ export async function importProduct(product, { source = 'saasrow', inspectFn = i
status: 'published',
verify_token: randomBytes(12).toString('hex'),
check_report: sqlj.json({ installable: report.installable, score: report.score, checks: report.checks }),
...featureColumns(report),
checked_at: new Date(),
published_at: new Date(),
source,
Expand Down
Loading
Loading