@profullstack/builtindie 0.0.0-stage → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/OpenStack.md +74 -0
  3. package/README.md +63 -2
  4. package/bin/builtindie +3 -0
  5. package/package.json +35 -4
  6. package/public/app.js +35 -0
  7. package/public/favicon.svg +1 -0
  8. package/public/icon-192.png +0 -0
  9. package/public/icon-512.png +0 -0
  10. package/public/icons/openicon.json +574 -0
  11. package/public/icons/svg/apple.svg +1 -0
  12. package/public/icons/svg/arrow-up.svg +1 -0
  13. package/public/icons/svg/bluesky.svg +1 -0
  14. package/public/icons/svg/check.svg +1 -0
  15. package/public/icons/svg/chrome.svg +1 -0
  16. package/public/icons/svg/code.svg +1 -0
  17. package/public/icons/svg/discord.svg +1 -0
  18. package/public/icons/svg/dollar.svg +1 -0
  19. package/public/icons/svg/external-link.svg +1 -0
  20. package/public/icons/svg/github.svg +1 -0
  21. package/public/icons/svg/globe.svg +1 -0
  22. package/public/icons/svg/instagram.svg +1 -0
  23. package/public/icons/svg/layers.svg +1 -0
  24. package/public/icons/svg/link.svg +1 -0
  25. package/public/icons/svg/linkedin.svg +1 -0
  26. package/public/icons/svg/mail.svg +1 -0
  27. package/public/icons/svg/product-hunt.svg +1 -0
  28. package/public/icons/svg/reddit.svg +1 -0
  29. package/public/icons/svg/rocket.svg +1 -0
  30. package/public/icons/svg/search.svg +1 -0
  31. package/public/icons/svg/share.svg +1 -0
  32. package/public/icons/svg/star.svg +1 -0
  33. package/public/icons/svg/tag.svg +1 -0
  34. package/public/icons/svg/telegram.svg +1 -0
  35. package/public/icons/svg/terminal.svg +1 -0
  36. package/public/icons/svg/tiktok.svg +1 -0
  37. package/public/icons/svg/trophy.svg +1 -0
  38. package/public/icons/svg/user.svg +1 -0
  39. package/public/icons/svg/x.svg +1 -0
  40. package/public/icons/svg/youtube.svg +1 -0
  41. package/public/install.sh +44 -0
  42. package/public/manifest.webmanifest +20 -0
  43. package/public/moshpit-root-ca.crt +11 -0
  44. package/public/style.css +162 -0
  45. package/public/sw.js +30 -0
  46. package/src/auth.ts +96 -0
  47. package/src/cli.ts +203 -0
  48. package/src/client.ts +115 -0
  49. package/src/db.ts +412 -0
  50. package/src/fetch.ts +708 -0
  51. package/src/html.ts +525 -0
  52. package/src/icons.ts +56 -0
  53. package/src/mcp.ts +79 -0
  54. package/src/media.ts +160 -0
  55. package/src/ring.ts +86 -0
  56. package/src/server.ts +655 -0
  57. package/src/service.ts +369 -0
  58. package/src/tui.ts +127 -0
  59. package/src/version.ts +1 -0
package/src/service.ts ADDED
@@ -0,0 +1,369 @@
1
+ /**
2
+ * What built.indie does, once, for every surface: the web pages, the JSON API,
3
+ * the CLI and the MCP server all end up here.
4
+ *
5
+ * submit URL in, a listing out: everything fetched, pictures saved. Free.
6
+ * Anonymous launches go live at once; a signed-in submitter checks a draft first.
7
+ * publish the draft goes live on today's board
8
+ * claim the domain's owner proves it with a DNS TXT record and takes the listing
9
+ * refetch read the homepage again (the maker changed their logo)
10
+ * verify the maker put our badge on their site: a followed link back
11
+ * feature pay in crypto (CoinPay) to sit on top for a week
12
+ *
13
+ * Products are described in the LogicSRC open specs: each one is an OpenSite
14
+ * record (logicsrc.com/docs/opensite) and an OpenProfile.md with Kind
15
+ * organization (logicsrc.com/docs/openprofile); a maker is an OpenProfile.md
16
+ * of their own, which we read and link to.
17
+ */
18
+ import { createHmac, timingSafeEqual } from 'node:crypto'
19
+ import { Resolver } from 'node:dns/promises'
20
+ import { keyedSection, listSection, makeOpenProfile, renderOpenProfile } from '@profullstack/openprofile'
21
+ import { autofetch, clip, FetchError, normalizeUrl, readProfile, safeFetch, SOCIAL_KINDS, videoFrom, type FetchOptions, type SiteInfo, type Video } from './fetch.js'
22
+ import { Media, screenshot } from './media.js'
23
+ import { dayOf, makerIdFor, newId, Store, type Product, type User } from './db.js'
24
+
25
+ export class InputError extends Error {
26
+ constructor(message: string, readonly field?: string) {
27
+ super(message)
28
+ }
29
+ }
30
+
31
+ export type CoinPayConfig = { baseUrl: string; apiKey: string; businessId: string; webhookSecret: string }
32
+ export type Config = {
33
+ origin: string
34
+ coinpay: CoinPayConfig | null
35
+ featureUsd: number
36
+ featureDays: number
37
+ chains: string[]
38
+ fetch?: FetchOptions
39
+ screenshots?: boolean
40
+ /** Secret behind each person's claim token. */
41
+ claimSecret?: string
42
+ /** TXT lookups; tests pass their own. */
43
+ resolveTxt?: (name: string) => Promise<string[][]>
44
+ }
45
+
46
+ /** The record an owner adds: `_built-indie.<domain> TXT "built-indie-verify=<token>"` (or the same on the domain itself). */
47
+ export const CLAIM_LABEL = '_built-indie'
48
+ export const CLAIM_PREFIX = 'built-indie-verify='
49
+
50
+ const resolver = new Resolver({ timeout: 4000, tries: 2 })
51
+ resolver.setServers(['1.1.1.1', '9.9.9.9'])
52
+ const publicTxt = (name: string) => resolver.resolveTxt(name)
53
+
54
+ export const CHAIN_LABELS: Record<string, string> = {
55
+ USDC_POL: 'USDC on Polygon', USDC_SOL: 'USDC on Solana', USDC_ETH: 'USDC on Ethereum', USDT_POL: 'USDT on Polygon',
56
+ BTC: 'Bitcoin', ETH: 'Ether', SOL: 'Solana', POL: 'Polygon', DOGE: 'Dogecoin', BCH: 'Bitcoin Cash', XRP: 'XRP', ADA: 'Cardano', BNB: 'BNB',
57
+ }
58
+ export const DEFAULT_CHAINS = Object.keys(CHAIN_LABELS)
59
+ export const SETTLED = new Set(['paid', 'completed', 'confirmed', 'forwarded', 'succeeded', 'settled'])
60
+
61
+ export type Edit = Partial<{ name: string; tagline: string; description: string; tags: string[] | string; makerName: string; makerProfile: string; videos: string[] | string; socials: Record<string, string>; remove: string[] | string }>
62
+
63
+ const list = (v: string[] | string | undefined) => (Array.isArray(v) ? v : String(v ?? '').split(/[\n,]+/)).map((s) => s.trim()).filter(Boolean)
64
+
65
+ export class BuiltIndie {
66
+ constructor(readonly store: Store, readonly media: Media, readonly config: Config) {}
67
+
68
+ /** A product page's public address. */
69
+ link(p: Pick<Product, 'slug'>): string {
70
+ return `${this.config.origin}/p/${p.slug}`
71
+ }
72
+
73
+ /** Read the URL and make a draft. A URL already live comes back as `existing` instead. */
74
+ async submit(input: { url: string; makerName?: string; makerProfile?: string }, submitter: string, user: User | null = null): Promise<{ product: Product; existing: boolean }> {
75
+ let url: URL
76
+ try {
77
+ url = normalizeUrl(input.url)
78
+ } catch (e) {
79
+ throw new InputError((e as Error).message, 'url')
80
+ }
81
+ const already = this.store.byUrl(url.href)
82
+ if (already?.status === 'live') return { product: already, existing: true }
83
+ if (this.store.submissionsFrom(submitter, Date.now() - 3600_000) >= 10) throw new InputError('That is a lot of launches in an hour. Try again later.')
84
+ const info = await this.read(url.href)
85
+ const id = newId()
86
+ const pictures = await this.pictures(id, info)
87
+ const product = this.store.create(
88
+ {
89
+ id,
90
+ slug: this.store.freeSlug(info.name),
91
+ url: info.url,
92
+ domain: info.domain,
93
+ name: info.name,
94
+ tagline: info.tagline,
95
+ description: info.description,
96
+ ...pictures,
97
+ videos: info.videos,
98
+ socials: info.socials,
99
+ tags: info.tags,
100
+ themeColor: info.themeColor,
101
+ llms: info.llms,
102
+ profile: info.profile,
103
+ record: this.recordFrom(info),
104
+ extras: { stack: info.stack, pricing: info.pricing, openicon: info.openicon },
105
+ makerName: clip(input.makerName ?? '', 60),
106
+ makerProfile: null,
107
+ submitterUser: user?.id ?? null,
108
+ status: user ? 'draft' : 'live',
109
+ },
110
+ submitter,
111
+ )
112
+ if (input.makerProfile?.trim()) await this.setMaker(product, input.makerProfile, input.makerName).catch(() => {})
113
+ return { product: this.store.product(id)!, existing: false }
114
+ }
115
+
116
+ /** The TXT value this person puts in DNS to claim this product. Stable, so a record added yesterday still counts. */
117
+ claimToken(p: Product, user: User): string {
118
+ return createHmac('sha256', this.config.claimSecret ?? 'built.indie').update(`claim:${user.id}:${p.domain}`).digest('base64url').slice(0, 24)
119
+ }
120
+
121
+ /** Look for the claim record at _built-indie.<domain>, then on the domain itself (and its apex for www). */
122
+ async claim(p: Product, user: User): Promise<{ ok: boolean; looked: string[] }> {
123
+ if (p.status === 'hidden') throw new InputError('This product was taken down.')
124
+ if (p.ownerId === user.id) return { ok: true, looked: [] }
125
+ const want = `${CLAIM_PREFIX}${this.claimToken(p, user)}`
126
+ const bare = p.domain.replace(/^www\./, '')
127
+ const names = [...new Set([`${CLAIM_LABEL}.${bare}`, bare, p.domain])]
128
+ const lookup = this.config.resolveTxt ?? publicTxt
129
+ for (const name of names) {
130
+ const records = await lookup(name).catch(() => [] as string[][])
131
+ if (records.some((chunks) => chunks.join('').trim() === want)) {
132
+ this.store.claim(p.id, user.id)
133
+ return { ok: true, looked: names }
134
+ }
135
+ }
136
+ return { ok: false, looked: names }
137
+ }
138
+
139
+ private async read(url: string): Promise<SiteInfo> {
140
+ try {
141
+ return await autofetch(url, this.config.fetch)
142
+ } catch (e) {
143
+ throw new InputError(e instanceof FetchError ? e.message : `Could not read that site: ${e instanceof Error ? e.message : e}`, 'url')
144
+ }
145
+ }
146
+
147
+ /** Logo, card image, up to four screenshots from the page, and our own screenshot of the homepage. */
148
+ private async pictures(id: string, info: SiteInfo) {
149
+ const o = this.config.fetch ?? {}
150
+ const [logo, og, shot, ...found] = await Promise.all([
151
+ this.media.save(id, 'logo', info.logo, o),
152
+ this.media.save(id, 'card', info.ogImage, o),
153
+ this.config.screenshots === false ? null : screenshot(info.url).then((png) => (png ? this.media.write(id, 'home.png', png, { width: 1280, height: 800 }) : null)),
154
+ ...info.images.slice(0, 6).map((src, i) => this.media.save(id, `shot-${i + 1}`, src, { ...o, minSide: 200 })),
155
+ ])
156
+ return {
157
+ logo: logo?.file ?? null,
158
+ ogImage: og?.file ?? null,
159
+ screenshot: shot?.file ?? null,
160
+ images: found.filter((f): f is NonNullable<typeof f> => Boolean(f)).slice(0, 4).map((f) => f.file),
161
+ }
162
+ }
163
+
164
+ publish(p: Product): Product {
165
+ if (p.status === 'hidden') throw new InputError('This product was taken down.')
166
+ if (!p.name.trim()) throw new InputError('Give it a name', 'name')
167
+ return this.store.update(p.id, { status: 'live' })!
168
+ }
169
+
170
+ async refetch(p: Product): Promise<Product> {
171
+ const info = await this.read(p.url)
172
+ await this.media.remove(p.id)
173
+ const pictures = await this.pictures(p.id, info)
174
+ return this.store.update(p.id, { ...pictures, videos: info.videos, socials: info.socials, llms: info.llms, profile: info.profile, record: this.recordFrom(info), extras: { stack: info.stack, pricing: info.pricing, openicon: info.openicon }, themeColor: info.themeColor, ...(info.tags.length && !p.tags.length ? { tags: info.tags } : {}) })!
175
+ }
176
+
177
+ async edit(p: Product, e: Edit): Promise<Product> {
178
+ const patch: Parameters<Store['update']>[1] = {}
179
+ if (e.name !== undefined) {
180
+ if (!e.name.trim()) throw new InputError('Give it a name', 'name')
181
+ patch.name = clip(e.name, 60)
182
+ }
183
+ if (e.tagline !== undefined) patch.tagline = clip(e.tagline, 90)
184
+ if (e.description !== undefined) patch.description = clip(e.description, 1200)
185
+ if (e.tags !== undefined) patch.tags = [...new Set(list(e.tags).map((t) => t.toLowerCase().replace(/^#/, '').replace(/[^a-z0-9 +#.-]/g, '').slice(0, 24)).filter((t) => t.length >= 2))].slice(0, 6)
186
+ if (e.makerName !== undefined) patch.makerName = clip(e.makerName, 60)
187
+ if (e.videos !== undefined) {
188
+ const videos: Video[] = []
189
+ for (const v of list(e.videos)) {
190
+ const video = videoFrom(v.startsWith('http') ? v : `https://${v}`)
191
+ if (!video) throw new InputError(`Not a YouTube, Vimeo, Loom or .mp4 link: ${v}`, 'videos')
192
+ if (!videos.some((x) => x.url === video.url)) videos.push(video)
193
+ }
194
+ patch.videos = videos.slice(0, 4)
195
+ }
196
+ if (e.socials !== undefined) {
197
+ const socials: Record<string, string> = {}
198
+ for (const [k, v] of Object.entries(e.socials)) if (SOCIAL_KINDS.includes(k) && /^https:\/\/\S+$/.test(String(v ?? '').trim())) socials[k] = String(v).trim().slice(0, 300)
199
+ patch.socials = socials
200
+ }
201
+ if (e.remove !== undefined) {
202
+ const drop = new Set(list(e.remove))
203
+ if (p.logo && drop.has(p.logo)) patch.logo = null
204
+ if (p.ogImage && drop.has(p.ogImage)) patch.ogImage = null
205
+ if (p.screenshot && drop.has(p.screenshot)) patch.screenshot = null
206
+ patch.images = p.images.filter((f) => !drop.has(f))
207
+ }
208
+ let updated = this.store.update(p.id, patch)!
209
+ if (e.makerProfile !== undefined) updated = e.makerProfile.trim() ? await this.setMaker(updated, e.makerProfile, e.makerName) : this.store.update(p.id, { makerProfile: null, makerId: null, makerVerified: false })!
210
+ return updated
211
+ }
212
+
213
+ /**
214
+ * The maker is an OpenProfile.md. We read it; it is verified when it names
215
+ * the product (Web or an account on the product's domain) or lives there.
216
+ */
217
+ async setMaker(p: Product, profileUrl: string, name?: string): Promise<Product> {
218
+ let url: URL
219
+ try {
220
+ url = normalizeUrl(profileUrl)
221
+ } catch (e) {
222
+ throw new InputError(`Maker profile: ${(e as Error).message}`, 'makerProfile')
223
+ }
224
+ const r = await safeFetch(url, { ...this.config.fetch, accept: 'text/markdown,text/plain;q=0.9', maxBytes: 256 * 1024, timeoutMs: 8000 }).catch((e) => {
225
+ throw new InputError(`Could not read the maker profile: ${(e as Error).message}`, 'makerProfile')
226
+ })
227
+ const profile = r.status < 300 ? readProfile(new TextDecoder().decode(r.body), r.url.href) : null
228
+ if (!profile) throw new InputError('That is not an OpenProfile.md (logicsrc.com/openprofile): a Markdown file starting with "# Your Name".', 'makerProfile')
229
+ const host = (u: string | null) => {
230
+ try {
231
+ return u ? new URL(u).hostname.replace(/^www\./, '') : ''
232
+ } catch {
233
+ return ''
234
+ }
235
+ }
236
+ const verified = host(r.url.href) === p.domain || host(profile.web) === p.domain || profile.accounts.some((a) => host(a) === p.domain)
237
+ const maker = this.store.upsertMaker({ name: profile.name, profileUrl: r.url.href, headline: profile.headline, avatar: profile.avatar, web: profile.web, accounts: profile.accounts })
238
+ return this.store.update(p.id, { makerProfile: r.url.href, makerId: maker.id, makerVerified: verified, makerName: clip(name?.trim() || profile.name, 60) })!
239
+ }
240
+
241
+ /** Has the maker linked back to us from their homepage? */
242
+ async verify(p: Product): Promise<boolean> {
243
+ const r = await safeFetch(p.url, { ...this.config.fetch, accept: 'text/html' }).catch(() => null)
244
+ const html = r && r.status < 300 ? new TextDecoder().decode(r.body) : ''
245
+ const ours = new URL(this.config.origin).hostname
246
+ const ok = new RegExp(`href=["']?https?://(${ours.replace(/\./g, '\\.')}|built\\.indie)(/|["'])`, 'i').test(html)
247
+ this.store.setVerified(p.id, ok ? Date.now() : null)
248
+ return ok
249
+ }
250
+
251
+ /** The product as an OpenSite 0.1 record: what we read, kept so anyone can reuse it. */
252
+ recordFrom(info: SiteInfo): Record<string, unknown> {
253
+ return {
254
+ opensite: '0.1',
255
+ url: info.url,
256
+ canonical: info.canonical,
257
+ site: { name: info.name, web: new URL(info.url).origin },
258
+ kind: info.kind,
259
+ title: info.name,
260
+ description: info.description,
261
+ ...(info.ogImage ? { image: { url: info.ogImage } } : {}),
262
+ language: info.language,
263
+ ...(info.profile ? { author: { name: info.profile.name, profile: info.profile.url } } : {}),
264
+ tags: info.tags,
265
+ feeds: info.feeds,
266
+ published_at: null,
267
+ modified_at: null,
268
+ fetched_at: new Date().toISOString(),
269
+ status: 'live',
270
+ source: info.declared ? 'declared' : 'read',
271
+ cards: info.cards,
272
+ jsonld: info.jsonld,
273
+ }
274
+ }
275
+
276
+ /** The product as an OpenProfile.md, Kind organization. */
277
+ openProfile(p: Product): string {
278
+ const logo = p.logo ? `${this.config.origin}/m/${p.id}/${p.logo}` : null
279
+ const accounts = [...Object.values(p.socials), p.llms?.url, this.link(p)].filter(Boolean) as string[]
280
+ return renderOpenProfile(
281
+ makeOpenProfile({
282
+ name: p.name,
283
+ identity: { Kind: 'organization', Web: p.url, Avatar: logo, Handle: p.slug },
284
+ headline: p.tagline || null,
285
+ prose: p.description || null,
286
+ sections: [
287
+ listSection('Accounts', accounts),
288
+ listSection('Topics', p.tags),
289
+ p.makerProfile ? keyedSection('Operator', { Name: p.makerName || null, Profile: p.makerProfile }) : p.makerName ? keyedSection('Operator', { Name: p.makerName }) : null,
290
+ keyedSection('Launch', { Launched: p.launchedAt ? dayOf(p.launchedAt) : null, Votes: p.votes, On: this.link(p) }),
291
+ ],
292
+ }),
293
+ )
294
+ }
295
+
296
+ /** A maker as an OpenProfile.md: their own profile's essentials plus what they launched here. */
297
+ makerProfile(makerId: string): string | null {
298
+ const m = this.store.maker(makerId)
299
+ if (!m) return null
300
+ const products = this.store.byMaker(makerId)
301
+ return renderOpenProfile(
302
+ makeOpenProfile({
303
+ name: m.name,
304
+ identity: { Kind: 'person', Web: m.web, Avatar: m.avatar, Profile: m.profileUrl },
305
+ headline: m.headline || null,
306
+ sections: [listSection('Accounts', [m.profileUrl, ...m.accounts]), listSection('Projects', products.map((p) => `[${p.name}](${this.link(p)}): ${p.tagline}`))],
307
+ }),
308
+ )
309
+ }
310
+
311
+ /** Start a crypto payment to feature a product; returns where to send the buyer. */
312
+ async feature(p: Product, chain: string, fetcher: typeof fetch = fetch): Promise<{ checkoutUrl: string; ref: string }> {
313
+ const cp = this.config.coinpay
314
+ if (!cp) throw new InputError('Featuring is not set up on this server yet.')
315
+ if (p.status !== 'live') throw new InputError('Publish it first.')
316
+ if (!this.config.chains.includes(chain)) throw new InputError('Pick a currency from the list', 'chain')
317
+ const res = await fetcher(`${cp.baseUrl}/api/payments/create`, {
318
+ method: 'POST',
319
+ headers: { authorization: `Bearer ${cp.apiKey}`, 'content-type': 'application/json' },
320
+ body: JSON.stringify({
321
+ business_id: cp.businessId,
322
+ amount: this.config.featureUsd,
323
+ currency: 'USD',
324
+ blockchain: chain,
325
+ payment_method: 'crypto',
326
+ description: `built.indie: feature ${p.name} for ${this.config.featureDays} days`,
327
+ metadata: { product_id: p.id, product: 'feature' },
328
+ success_url: `${this.link(p)}?featured=1`,
329
+ cancel_url: this.link(p),
330
+ }),
331
+ signal: AbortSignal.timeout(20_000),
332
+ })
333
+ if (!res.ok) throw new Error(`CoinPay said ${res.status}: ${(await res.text()).slice(0, 200)}`)
334
+ const body = (await res.json()) as any
335
+ const payment = body?.payment ?? body
336
+ const ref = String(payment?.id ?? payment?.payment_id ?? '')
337
+ if (!ref) throw new Error('CoinPay returned no payment reference')
338
+ // Written before the buyer leaves: a webhook can beat the redirect.
339
+ this.store.addPayment({ ref, productId: p.id, amountUsd: this.config.featureUsd, chain, days: this.config.featureDays })
340
+ return { checkoutUrl: `${cp.baseUrl}/pay/${ref}`, ref }
341
+ }
342
+
343
+ /** CoinPay's webhook: `t=<unix>,v1=<hmac of "t.body">`; the payload is { data: { payment_id, status } }. */
344
+ webhook(raw: string, header: string | undefined, nowSeconds = Date.now() / 1000): { ok: boolean; status?: number } {
345
+ const cp = this.config.coinpay
346
+ if (!cp?.webhookSecret || !header) return { ok: false, status: 401 }
347
+ const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=').map((s) => s.trim())))
348
+ const t = Number(parts.t)
349
+ if (!Number.isFinite(t) || !parts.v1 || Math.abs(nowSeconds - t) > 300) return { ok: false, status: 401 }
350
+ const expected = Buffer.from(createHmac('sha256', cp.webhookSecret).update(`${parts.t}.${raw}`).digest('hex'), 'hex')
351
+ const given = Buffer.from(String(parts.v1), 'hex')
352
+ if (expected.length !== given.length || !timingSafeEqual(expected, given)) return { ok: false, status: 401 }
353
+ let payload: any
354
+ try {
355
+ payload = JSON.parse(raw)
356
+ } catch {
357
+ return { ok: false, status: 400 }
358
+ }
359
+ const data = payload?.data ?? payload
360
+ const ref = String(data?.payment_id ?? data?.id ?? '')
361
+ const status = String(data?.status ?? payload?.type?.replace(/^payment\./, '') ?? '').toLowerCase()
362
+ if (!ref || !this.store.payment(ref)) return { ok: true }
363
+ if (SETTLED.has(status)) this.store.settle(ref)
364
+ else if (status) this.store.setPaymentStatus(ref, status)
365
+ return { ok: true }
366
+ }
367
+ }
368
+
369
+ export { makerIdFor }
package/src/tui.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * `builtindie` with no arguments: the board in the terminal.
3
+ *
4
+ * ←/→ switch board (today, week, month, all, new), ↑/↓ pick, Enter shows it,
5
+ * v upvotes, l launches a URL you type, q quits. Glyphs come from the OpenIcon
6
+ * set: Nerd Font, Unicode or ASCII, whichever this terminal can draw.
7
+ */
8
+ const UP = glyph('arrow-up') || '^'
9
+ const OK = glyph('check') || '+'
10
+ import { createApp } from '@profullstack/hqtui'
11
+ import { BuiltIndieClient, type ApiProduct } from './client.js'
12
+ import { glyph } from './icons.js'
13
+
14
+ export const WINDOWS = ['today', 'week', 'month', 'all', 'new'] as const
15
+
16
+ export type TuiState = {
17
+ window: number
18
+ products: ApiProduct[]
19
+ at: number
20
+ open: ApiProduct | null
21
+ typing: string | null
22
+ busy: string | null
23
+ flash: string | null
24
+ }
25
+
26
+ export const initialState = (): TuiState => ({ window: 0, products: [], at: 0, open: null, typing: null, busy: null, flash: null })
27
+
28
+ /** Pure key handling, testable without a terminal. */
29
+ export function onKey(s: TuiState, key: { name: string; key: string; char?: string }): 'load' | 'vote' | 'launch' | 'quit' | null {
30
+ if (key.key === 'ctrl+c') return 'quit'
31
+ if (s.typing !== null) {
32
+ if (key.name === 'escape') s.typing = null
33
+ else if (key.name === 'enter') return s.typing.trim() ? 'launch' : ((s.typing = null), null)
34
+ else if (key.name === 'backspace') s.typing = s.typing.slice(0, -1)
35
+ else if (key.char && key.char.length === 1 && !key.key.startsWith('ctrl+')) s.typing += key.char
36
+ return null
37
+ }
38
+ if (key.name === 'escape') return s.open ? ((s.open = null), null) : 'quit'
39
+ if (key.char === 'q') return 'quit'
40
+ if (key.char === 'l') return (s.typing = ''), null
41
+ if (key.char === 'v') return s.products[s.at] ? 'vote' : null
42
+ if (key.name === 'right' || key.name === 'left') {
43
+ s.window = (s.window + (key.name === 'right' ? 1 : WINDOWS.length - 1)) % WINDOWS.length
44
+ s.at = 0
45
+ s.open = null
46
+ return 'load'
47
+ }
48
+ if (key.name === 'down') s.at = Math.min(s.at + 1, Math.max(0, s.products.length - 1))
49
+ if (key.name === 'up') s.at = Math.max(0, s.at - 1)
50
+ if (key.name === 'enter') s.open = s.products[s.at] ?? null
51
+ return null
52
+ }
53
+
54
+ export async function runTui(client = new BuiltIndieClient()): Promise<void> {
55
+ const s = initialState()
56
+ const app = await createApp({ title: 'built.indie', quitKeys: [] })
57
+ const run = async (label: string, fn: () => Promise<void>) => {
58
+ s.busy = label
59
+ s.flash = null
60
+ app.invalidate()
61
+ try {
62
+ await fn()
63
+ } catch (e) {
64
+ s.flash = e instanceof Error ? e.message : String(e)
65
+ } finally {
66
+ s.busy = null
67
+ app.invalidate()
68
+ }
69
+ }
70
+ const load = () => run('Loading…', async () => void (s.products = (await client.list(WINDOWS[s.window]!, 50)).products))
71
+ app.on('key', (event: any) => {
72
+ const action = onKey(s, event)
73
+ if (action === 'quit') app.quit()
74
+ else if (action === 'load') void load()
75
+ else if (action === 'vote') {
76
+ const p = s.products[s.at]!
77
+ void run('Voting…', async () => {
78
+ const r = await client.vote(p.slug)
79
+ p.votes = r.votes
80
+ s.flash = r.counted ? `▲ ${p.name}: thanks!` : `You already voted for ${p.name}.`
81
+ })
82
+ } else if (action === 'launch') {
83
+ const url = s.typing!.trim()
84
+ s.typing = null
85
+ void run(`Reading ${url}…`, async () => {
86
+ const r = await client.launch(url)
87
+ s.flash = r.existing ? `${r.product.name} is already here.` : `Launched ${r.product.name}. Yours? builtindie claim ${r.product.slug}`
88
+ s.window = WINDOWS.indexOf('new')
89
+ s.products = (await client.list('new', 50)).products
90
+ s.at = 0
91
+ })
92
+ }
93
+ app.invalidate()
94
+ })
95
+ app.render(({ ui, theme }: any) => {
96
+ ui.row({ size: 1 }, (h: any) => {
97
+ h.text(' built.indie', { fg: theme.accent, bold: true, size: 13 })
98
+ h.text(WINDOWS.map((w, i) => (i === s.window ? `[${w}]` : w)).join(' '), { fg: theme.text })
99
+ h.text(`${client.base.replace(/^https?:\/\//, '')} `, { fg: theme.muted, align: 'right' })
100
+ })
101
+ if (s.open) {
102
+ const p = s.open
103
+ ui.panel({ title: `${p.name} ${UP} ${p.votes}` }, (b: any) => {
104
+ b.text(p.tagline, { fg: theme.accent, bold: true })
105
+ b.text(p.description || '', { fg: theme.text })
106
+ b.text(`site ${p.url}`, { fg: theme.muted })
107
+ b.text(`page ${p.page}`, { fg: theme.muted })
108
+ if (p.tags.length) b.text(`tags ${p.tags.join(', ')}`, { fg: theme.muted })
109
+ if (p.videos.length) b.text(`video ${p.videos[0]!.url}`, { fg: theme.muted })
110
+ if (p.maker) b.text(`maker ${p.maker.name}${p.maker.verified ? ' ✓' : ''}`, { fg: theme.muted })
111
+ })
112
+ } else {
113
+ ui.panel({ title: s.busy ?? `${WINDOWS[s.window]} (${s.products.length})` }, (b: any) => {
114
+ if (!s.products.length) b.text(s.busy ? '' : 'Nothing here yet. Press l to launch yours.', { fg: theme.muted })
115
+ s.products.slice(Math.max(0, s.at - 15), Math.max(0, s.at - 15) + 30).forEach((p) => {
116
+ const i = s.products.indexOf(p)
117
+ b.text(`${i === s.at ? '›' : ' '} ${String(i + 1).padStart(2)} ${UP}${String(p.votes).padStart(4)} ${p.name}${p.verified ? ` ${OK}` : ''} ${p.tagline}`, { fg: i === s.at ? theme.accent : theme.text, bold: i === s.at })
118
+ })
119
+ })
120
+ }
121
+ if (s.typing !== null) ui.panel({ title: 'Launch a URL', size: 3 }, (b: any) => b.text(`${s.typing}▏`, { fg: theme.accent }))
122
+ else if (s.flash) ui.panel({ title: 'built.indie', size: 3 }, (b: any) => b.text(s.flash, { fg: theme.accent }))
123
+ ui.statusBar({ items: s.typing !== null ? [{ key: 'Enter', label: 'launch' }, { key: 'Esc', label: 'cancel' }] : [{ key: '←→', label: 'board' }, { key: '↑↓', label: 'pick' }, { key: 'Enter', label: 'open' }, { key: 'v', label: 'upvote' }, { key: 'l', label: 'launch' }, { key: 'q', label: 'quit' }], right: [{ label: 'built.indie' }] })
124
+ })
125
+ void load()
126
+ await app.start()
127
+ }
package/src/version.ts ADDED
@@ -0,0 +1 @@
1
+ export const VERSION = '0.1.3'