|
| 1 | +/** |
| 2 | + * apps-catalog.js |
| 3 | + * --------------- |
| 4 | + * The single source of truth for what /apps and the home-page |
| 5 | + * AppsPreview render. Joins live download / version / store data |
| 6 | + * from `data/app-downloads.json` (refreshed weekday mornings by |
| 7 | + * `.github/workflows/app-downloads.yml`) with hand-curated display |
| 8 | + * metadata (taglines, icons, website categories) keyed by the app's |
| 9 | + * GitHub repo slug. |
| 10 | + * |
| 11 | + * Structure: |
| 12 | + * |
| 13 | + * PRESENTATION = id → {tagline, icon, categories, href, hidden?, sortKey?} |
| 14 | + * downloadsJson = generated, see scripts/app_downloads.py |
| 15 | + * getApps() = merged list for AppsGrid / AppsPreview, sorted by |
| 16 | + * install count descending; entries that aren't in |
| 17 | + * the store and have no downloads are filtered out |
| 18 | + * so scaffold repos (app-template, app_versions) |
| 19 | + * don't surface on the public site. |
| 20 | + * |
| 21 | + * Adding a new app: drop a row into PRESENTATION below. The next |
| 22 | + * GitHub-Actions refresh will fill in the live numbers automatically. |
| 23 | + */ |
| 24 | + |
| 25 | +import React from 'react'; |
| 26 | +import downloadsJson from '../../../../data/app-downloads.json'; |
| 27 | + |
| 28 | +/* Hand-curated display metadata. Keys are the GitHub repo slug |
| 29 | + (matches `apps[].id` in app-downloads.json). */ |
| 30 | +const PRESENTATION = { |
| 31 | + opencatalogi: { |
| 32 | + name: 'OpenCatalogi', |
| 33 | + tagline: 'Public software catalog. Every app, dataset, API in your organisation, searchable in one place.', |
| 34 | + href: '/apps/opencatalogi', |
| 35 | + categories: ['Data'], |
| 36 | + icon: <svg viewBox="0 0 24 24"><path d="M3 7l9-4 9 4-9 4-9-4z"/><path d="M3 12l9 4 9-4"/><path d="M3 17l9 4 9-4"/></svg>, |
| 37 | + }, |
| 38 | + openregister: { |
| 39 | + name: 'OpenRegister', |
| 40 | + tagline: 'Schemas, registers, structured data objects, the typed-data backbone for every app.', |
| 41 | + href: '/apps/openregister', |
| 42 | + categories: ['Data'], |
| 43 | + icon: <svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M9 4v16"/></svg>, |
| 44 | + }, |
| 45 | + openconnector: { |
| 46 | + name: 'OpenConnector', |
| 47 | + tagline: <>Connect <span className="next-blue">Nextcloud</span> to anything, REST, SOAP, GraphQL, file drops, message queues.</>, |
| 48 | + href: '/apps/openconnector', |
| 49 | + categories: ['Connectors'], |
| 50 | + icon: <svg viewBox="0 0 24 24"><circle cx="6" cy="12" r="3"/><circle cx="18" cy="6" r="3"/><circle cx="18" cy="18" r="3"/><path d="M9 12h9M9 12l9-6M9 12l9 6"/></svg>, |
| 51 | + }, |
| 52 | + docudesk: { |
| 53 | + name: 'DocuDesk', |
| 54 | + tagline: 'Auto-classify, anonymise, and route inbound documents. Drop them in a folder, get them filed.', |
| 55 | + href: '/apps/docudesk', |
| 56 | + categories: ['Documents'], |
| 57 | + icon: <svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/></svg>, |
| 58 | + }, |
| 59 | + mydash: { |
| 60 | + name: 'MyDash', |
| 61 | + tagline: 'Personal and team dashboards built from your registers, no separate BI tool, no extra login.', |
| 62 | + href: '/apps/mydash', |
| 63 | + categories: ['Dashboards'], |
| 64 | + icon: <svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>, |
| 65 | + }, |
| 66 | + openwoo: { |
| 67 | + name: 'OpenWoo', |
| 68 | + tagline: 'WOO-compliant publication flow. Active disclosure, queryable archive, citation-stable URLs.', |
| 69 | + href: '/apps/openwoo', |
| 70 | + categories: ['Processes'], |
| 71 | + icon: <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg>, |
| 72 | + }, |
| 73 | + zaakafhandelapp: { |
| 74 | + name: 'ZaakAfhandelApp', |
| 75 | + tagline: 'Citizen-facing case-status portal. ZGW APIs, archief koppelvlakken, audit trail.', |
| 76 | + href: '/apps/zaakafhandelapp', |
| 77 | + categories: ['Processes'], |
| 78 | + icon: <svg viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M9 9h6v6H9z"/></svg>, |
| 79 | + }, |
| 80 | + pipelinq: { |
| 81 | + name: 'PipelinQ', |
| 82 | + tagline: 'CRM with quotes, contacts, and deal-flow. Built on registers, no separate sales database.', |
| 83 | + href: '/apps/pipelinq', |
| 84 | + categories: ['Processes'], |
| 85 | + icon: <svg viewBox="0 0 24 24"><path d="M3 12h4l3-9 4 18 3-9h4"/></svg>, |
| 86 | + }, |
| 87 | + procest: { |
| 88 | + name: 'Procest', |
| 89 | + tagline: 'Case-management for VTH and citizen processes. Workflow engine plus typed registers.', |
| 90 | + href: '/apps/procest', |
| 91 | + categories: ['Processes'], |
| 92 | + icon: <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>, |
| 93 | + }, |
| 94 | + decidesk: { |
| 95 | + name: 'DeciDesk', |
| 96 | + tagline: 'Decision-support and board management. Agenda, dossiers, motions, voting, audit.', |
| 97 | + href: '/apps/decidesk', |
| 98 | + categories: ['Processes'], |
| 99 | + icon: <svg viewBox="0 0 24 24"><path d="M9 11l3 3 8-8"/><path d="M20 12v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h9"/></svg>, |
| 100 | + }, |
| 101 | + softwarecatalog: { |
| 102 | + name: 'SoftwareCatalog', |
| 103 | + tagline: 'IT-asset management, software inventory, licenses, contracts, dependencies.', |
| 104 | + href: '/apps/softwarecatalog', |
| 105 | + categories: ['Data'], |
| 106 | + icon: <svg viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>, |
| 107 | + }, |
| 108 | + larpingapp: { |
| 109 | + name: 'LarpingApp', |
| 110 | + tagline: 'Workflow and process orchestration for live-action role-play events. Visual designer, audit-logged.', |
| 111 | + href: '/apps/larpingapp', |
| 112 | + categories: ['Processes'], |
| 113 | + icon: <svg viewBox="0 0 24 24"><path d="M3 12h6l3-7 3 14 3-7h3"/></svg>, |
| 114 | + }, |
| 115 | + nldesign: { |
| 116 | + name: 'NLDesign', |
| 117 | + tagline: 'Drop-in NLDS theme for Nextcloud, with the Conduction component variants on top.', |
| 118 | + href: '/apps/nldesign', |
| 119 | + categories: ['Documents'], |
| 120 | + icon: <svg viewBox="0 0 24 24"><path d="M4 4h16v6H4z"/><path d="M4 14h7v6H4z"/><path d="M14 14h6v6h-6z"/></svg>, |
| 121 | + }, |
| 122 | +}; |
| 123 | + |
| 124 | +/* Categories the website filters by, in the order the chips render. */ |
| 125 | +export const APP_CATEGORIES = ['All', 'Data', 'Processes', 'Connectors', 'Documents', 'AI', 'Dashboards']; |
| 126 | + |
| 127 | +/* Pick a status label from the version string. The Nextcloud app |
| 128 | + store leaves the latest_version as-is, so "0.7.9-beta.8" reads as |
| 129 | + beta and "1.6.0" reads as stable. Falls back to GitHub's tag when |
| 130 | + store metadata is missing. */ |
| 131 | +function statusFor(record) { |
| 132 | + const ver = (record.store && record.store.latest_version) || (record.github && record.github.latest_release) || ''; |
| 133 | + const v = ver.toLowerCase(); |
| 134 | + if (!ver) return 'COMING SOON'; |
| 135 | + if (v.includes('beta') || v.includes('alpha') || v.includes('rc')) return 'BETA'; |
| 136 | + if (v.startsWith('0.') || v.startsWith('v0.')) return 'BETA'; |
| 137 | + return 'STABLE'; |
| 138 | +} |
| 139 | + |
| 140 | +/* Format the version into a short label "v1.6 · 5,740 installs". */ |
| 141 | +function versionLabel(record) { |
| 142 | + const raw = (record.store && record.store.latest_version) || (record.github && record.github.latest_release) || ''; |
| 143 | + const stripped = raw.replace(/^v/, '').replace(/-(beta|alpha|rc).*$/i, ''); |
| 144 | + const short = stripped.split('.').slice(0, 2).join('.'); |
| 145 | + const dl = record.github && record.github.downloads; |
| 146 | + const dlLabel = dl && dl > 0 ? `${dl.toLocaleString('en-US')} installs` : null; |
| 147 | + return [short && `v${short}`, dlLabel].filter(Boolean).join(' · '); |
| 148 | +} |
| 149 | + |
| 150 | +/* Joined catalogue: hand-curated metadata + live numbers. Apps not in |
| 151 | + PRESENTATION (scaffolds, library repos, ExApp wrappers) are dropped |
| 152 | + so the public surface stays curated. */ |
| 153 | +export function getApps() { |
| 154 | + const seen = new Set(); |
| 155 | + const out = []; |
| 156 | + for (const record of downloadsJson.apps) { |
| 157 | + const meta = PRESENTATION[record.id]; |
| 158 | + if (!meta) continue; |
| 159 | + if (seen.has(record.id)) continue; |
| 160 | + seen.add(record.id); |
| 161 | + out.push({ |
| 162 | + ...meta, |
| 163 | + status: statusFor(record), |
| 164 | + version: versionLabel(record), |
| 165 | + downloads: (record.github && record.github.downloads) || 0, |
| 166 | + }); |
| 167 | + } |
| 168 | + /* Apps in PRESENTATION but not in the JSON yet (e.g. brand-new repos |
| 169 | + before the next workflow run) — surface them with COMING SOON. */ |
| 170 | + for (const id of Object.keys(PRESENTATION)) { |
| 171 | + if (seen.has(id)) continue; |
| 172 | + out.push({ |
| 173 | + ...PRESENTATION[id], |
| 174 | + status: 'COMING SOON', |
| 175 | + version: '', |
| 176 | + downloads: 0, |
| 177 | + }); |
| 178 | + } |
| 179 | + /* Sort: stable apps with most installs first, beta apps next, coming- |
| 180 | + soon last. Keeps the page anchored on what's actually shippable. */ |
| 181 | + const statusRank = {STABLE: 0, BETA: 1, 'COMING SOON': 2}; |
| 182 | + out.sort((a, b) => { |
| 183 | + const r = (statusRank[a.status] ?? 9) - (statusRank[b.status] ?? 9); |
| 184 | + if (r !== 0) return r; |
| 185 | + return (b.downloads || 0) - (a.downloads || 0); |
| 186 | + }); |
| 187 | + return out; |
| 188 | +} |
| 189 | + |
| 190 | +/* Convenience: the totals strip ("12 apps in the store · 22,400 installs"). */ |
| 191 | +export function getCatalogTotals() { |
| 192 | + const apps = getApps(); |
| 193 | + const installs = apps.reduce((n, a) => n + (a.downloads || 0), 0); |
| 194 | + return { |
| 195 | + apps: apps.filter(a => a.status !== 'COMING SOON').length, |
| 196 | + installs, |
| 197 | + generatedAt: downloadsJson.generated_at, |
| 198 | + }; |
| 199 | +} |
0 commit comments