feat: landing redesign in resources aesthetic; fix KB search crash

Landing:
- Rebuilt app/(landing)/page.tsx from a 539-line client monolith to a
  ~150-line server component in the /resources editorial style.
- New scoped app/(landing)/landing.css mirrors the resources palette
  (#0c0c0c / lime #d4ff00 / #252525), IBM Plex Mono + Libre
  Baskerville loaded in (landing)/layout.tsx, faint grid overlay.
- Removed the animated Mini* previews and inline Ico* set (clashed
  with the editorial look). Split into components/landing/icons.tsx
  and components/landing/resource-showcase.tsx for separation.
- Resource showcase: one row of six square tiles with the real
  project SVG logos linking to atlas/space-after-dark/proposal-duel/
  zine/learner/governance. Responsive 6 -> 3 -> 2.
- BrandMark accepts an optional style prop.

Fix:
- KB search crashed the Ops page. filteredLeads referenced
  l.member_name (the column is lead_handle), so undefined.toLowerCase()
  threw on every keystroke since the filter memos recompute on shared
  search state across tabs. All Ops filter predicates are now
  null-safe via a shared has() helper; filteredSecrets no longer
  assumes a non-null title.

Docs:
- docs/PERMISSIONS_DESIGN.md: concrete plan for customizable
  permissions, per-item Ops ACLs, and area-lead roles. That work is
  security-sensitive (RLS rewrites on secrets/knowledge_base) and is
  scoped as its own next pass, not bundled here.

266/266 tests pass. Production build clean. No migration this pass.
This commit is contained in:
Moheeb Zara
2026-05-15 16:15:56 -07:00
parent 71d5e243cf
commit 43f9c09d70
10 changed files with 534 additions and 506 deletions
+9
View File
@@ -4,7 +4,16 @@ All notable changes to this project are documented here. Format follows [Keep a
## [Unreleased] ## [Unreleased]
### Changed
- Landing page rebuilt in the `/resources` editorial aesthetic: scoped dark theme (IBM Plex Mono + Libre Baskerville, lime accent, faint grid), a server component (~150 lines, down from a 539-line client monolith), and split into `components/landing/*`. Added a single-row resource showcase of six square tiles with the project SVG logos.
### Fixed
- Knowledge base search no longer crashes the Ops page. `filteredLeads` referenced a non-existent `member_name` column; the resulting `undefined.toLowerCase()` threw on every keystroke because the filter memos recompute on shared search state across tabs. All Ops filter predicates are now null-safe.
### Added ### Added
- `docs/PERMISSIONS_DESIGN.md`: design for customizable permissions, per-item Ops ACLs, and area-lead roles (next focused pass; security-sensitive RLS work, intentionally not bundled with UI changes).
### Added (continued)
- **Customize hub** at `/customize` (admin/board): a dedicated home for per-space customization with a section rail (Roles, Membership tiers, Areas, Invite codes, Onboarding), split into one focused module per panel. Replaces the overloaded Settings tab strip. - **Customize hub** at `/customize` (admin/board): a dedicated home for per-space customization with a section rail (Roles, Membership tiers, Areas, Invite codes, Onboarding), split into one focused module per panel. Replaces the overloaded Settings tab strip.
- **Roles editor**: rename and recolor the five built-in roles, and create/edit/delete custom org-structure roles. Renamed roles now display (sidebar user card; helper `lib/role-labels.ts`). - **Roles editor**: rename and recolor the five built-in roles, and create/edit/delete custom org-structure roles. Renamed roles now display (sidebar user card; helper `lib/role-labels.ts`).
- **Invite share links**: copy a one-click `/signup?invite=CODE` link in addition to the bare code; the signup page prefills the code and preselects join mode from `?invite=`. - **Invite share links**: copy a one-click `/signup?invite=CODE` link in addition to the bare code; the signup page prefills the code and preselects join mode from `?invite=`.
+12 -4
View File
@@ -519,11 +519,19 @@ export function OpsClient({ member, spaceId, kbEntries: initial, areaLeads: init
const [showLeadModal, setShowLeadModal] = useState(false) const [showLeadModal, setShowLeadModal] = useState(false)
const [editingLead, setEditingLead] = useState<AreaLead | null>(null) const [editingLead, setEditingLead] = useState<AreaLead | null>(null)
// Every predicate is null-safe: title/content/area/handle can all be null
// in the database. These memos recompute on every keystroke of the shared
// `search` state regardless of the active tab, so an unguarded
// .toLowerCase() on a null field throws and takes the whole page down
// (this is why KB search appeared broken: filteredLeads referenced a
// non-existent `member_name` column).
const has = (v: string | null | undefined, q: string) => !!v && v.toLowerCase().includes(q)
const filteredKb = useMemo(() => { const filteredKb = useMemo(() => {
const q = search.toLowerCase() const q = search.toLowerCase()
return kbEntries.filter(e => return kbEntries.filter(e =>
!e.tags?.includes('process') && !e.tags?.includes('process') &&
(!q || e.title.toLowerCase().includes(q) || e.content?.toLowerCase().includes(q) || e.area?.toLowerCase().includes(q)), (!q || has(e.title, q) || has(e.content, q) || has(e.area, q)),
) )
}, [kbEntries, search]) }, [kbEntries, search])
@@ -531,18 +539,18 @@ export function OpsClient({ member, spaceId, kbEntries: initial, areaLeads: init
const q = search.toLowerCase() const q = search.toLowerCase()
return kbEntries.filter(e => return kbEntries.filter(e =>
e.tags?.includes('process') && e.tags?.includes('process') &&
(!q || e.title.toLowerCase().includes(q) || e.content?.toLowerCase().includes(q) || e.area?.toLowerCase().includes(q)), (!q || has(e.title, q) || has(e.content, q) || has(e.area, q)),
) )
}, [kbEntries, search]) }, [kbEntries, search])
const filteredSecrets = useMemo(() => { const filteredSecrets = useMemo(() => {
const q = search.toLowerCase() const q = search.toLowerCase()
return secrets.filter(s => !q || s.title.toLowerCase().includes(q) || s.area?.toLowerCase().includes(q)) return secrets.filter(s => !q || has(s.title, q) || has(s.label, q) || has(s.area, q))
}, [secrets, search]) }, [secrets, search])
const filteredLeads = useMemo(() => { const filteredLeads = useMemo(() => {
const q = search.toLowerCase() const q = search.toLowerCase()
return areaLeads.filter(l => !q || l.area_name.toLowerCase().includes(q) || l.member_name.toLowerCase().includes(q)) return areaLeads.filter(l => !q || has(l.area_name, q) || has(l.lead_handle, q))
}, [areaLeads, search]) }, [areaLeads, search])
async function deleteLeadFn(id: string) { async function deleteLeadFn(id: string) {
+196
View File
@@ -0,0 +1,196 @@
/* =============================================================================
* Landing stylesheet. Shares the editorial palette and type system of the
* /resources subsite (IBM Plex Mono + Libre Baskerville, near-black with a
* lime accent and a faint grid) so the marketing page and the resources
* project read as one brand. Scoped under .landing-root so it never bleeds
* into the management app's shadcn/Tailwind system.
* ===========================================================================*/
.landing-root {
--ln-bg: #0c0c0c;
--ln-surface: #161616;
--ln-border: #252525;
--ln-text: #e8e8e8;
--ln-text-dim: #707070;
--ln-text-muted: #404040;
--ln-accent: #d4ff00;
background: var(--ln-bg);
color: var(--ln-text);
font-family: var(--font-ibm-plex-mono), 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 16px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
position: relative;
}
.landing-root ::selection { background: var(--ln-accent); color: var(--ln-bg); }
.landing-root a { color: inherit; text-decoration: none; }
.landing-root .landing-grid-bg {
position: fixed;
inset: 0;
background-image:
linear-gradient(var(--ln-border) 1px, transparent 1px),
linear-gradient(90deg, var(--ln-border) 1px, transparent 1px);
background-size: 60px 60px;
opacity: 0.3;
pointer-events: none;
z-index: 0;
}
.landing-root > *:not(.landing-grid-bg) { position: relative; z-index: 1; }
.landing-root .landing-container { max-width: 1100px; margin: 0 auto; padding: 0 32px; }
/* Nav */
.landing-root .landing-nav {
border-bottom: 1px solid var(--ln-border);
}
.landing-root .landing-nav-inner {
display: flex; align-items: center; justify-content: space-between;
gap: 24px; padding: 28px 0;
}
.landing-root .landing-wordmark {
display: flex; align-items: center; gap: 10px;
font-weight: 500; letter-spacing: -0.02em;
}
.landing-root .landing-nav-links { display: flex; align-items: center; gap: 28px; font-size: 0.8125rem; color: var(--ln-text-dim); }
.landing-root .landing-nav-links a:hover { color: var(--ln-text); }
/* Buttons */
.landing-root .landing-btn,
.landing-root .landing-btn-accent {
display: inline-flex; align-items: center; gap: 10px;
font-size: 0.8125rem; padding: 12px 24px;
border: 1px solid var(--ln-border);
transition: all 0.25s ease;
white-space: nowrap;
}
.landing-root .landing-btn:hover { border-color: var(--ln-text-dim); color: var(--ln-text); }
.landing-root .landing-btn-accent {
background: var(--ln-accent); border-color: var(--ln-accent); color: var(--ln-bg);
font-weight: 500;
}
.landing-root .landing-btn-accent:hover { filter: brightness(1.08); }
/* Hero */
.landing-root .landing-hero { padding: 88px 0 72px; border-bottom: 1px solid var(--ln-border); }
.landing-root .landing-eyebrow {
font-size: 0.75rem; font-weight: 700; letter-spacing: 0.18em;
text-transform: uppercase; color: var(--ln-text-dim); margin-bottom: 24px;
}
.landing-root .landing-display {
font-family: var(--font-libre-baskerville), 'Libre Baskerville', Georgia, serif;
font-size: clamp(2rem, 5.5vw, 3.5rem);
font-weight: 400; line-height: 1.12; letter-spacing: -0.02em;
max-width: 16ch;
}
.landing-root .landing-display em { font-style: italic; color: var(--ln-accent); }
.landing-root .landing-lede {
font-size: 1rem; color: var(--ln-text-dim); line-height: 1.75;
max-width: 52ch; margin: 24px 0 36px;
}
.landing-root .landing-cta-row { display: flex; flex-wrap: wrap; gap: 12px; }
/* Section header */
.landing-root .landing-section { padding: 72px 0; border-bottom: 1px solid var(--ln-border); }
.landing-root .landing-section-head {
display: flex; justify-content: space-between; align-items: baseline;
gap: 24px; margin-bottom: 40px;
}
.landing-root .landing-section-label {
font-size: 0.75rem; font-weight: 700; letter-spacing: 0.15em;
text-transform: uppercase; color: var(--ln-text-dim);
}
.landing-root .landing-section-note { font-size: 0.75rem; color: var(--ln-text-muted); }
/* Module cards */
.landing-root .landing-grid {
display: grid; grid-template-columns: repeat(3, 1fr);
gap: 1px; background: var(--ln-border);
border: 1px solid var(--ln-border);
}
.landing-root .landing-card {
background: var(--ln-bg); padding: 32px;
display: flex; flex-direction: column; min-height: 200px;
transition: background 0.3s ease;
}
.landing-root .landing-card:hover { background: var(--ln-surface); }
.landing-root .landing-card-num {
font-size: 0.6875rem; color: var(--ln-text-muted);
letter-spacing: 0.1em; margin-bottom: 20px;
}
.landing-root .landing-card-title {
font-family: var(--font-libre-baskerville), 'Libre Baskerville', Georgia, serif;
font-size: 1.25rem; font-weight: 400; letter-spacing: -0.01em; margin-bottom: 10px;
transition: color 0.3s ease;
}
.landing-root .landing-card:hover .landing-card-title { color: var(--ln-accent); }
.landing-root .landing-card-desc { font-size: 0.8125rem; color: var(--ln-text-dim); line-height: 1.65; }
/* Steps */
.landing-root .landing-steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 40px; }
.landing-root .landing-step-num {
font-family: var(--font-libre-baskerville), 'Libre Baskerville', Georgia, serif;
font-size: 2rem; color: var(--ln-text-muted); margin-bottom: 12px;
}
.landing-root .landing-step-title { font-size: 0.95rem; color: var(--ln-text); margin-bottom: 8px; }
.landing-root .landing-step-desc { font-size: 0.8125rem; color: var(--ln-text-dim); line-height: 1.65; }
/* Resource showcase: a single row of square tiles */
.landing-root .landing-showcase {
display: grid; grid-template-columns: repeat(6, 1fr);
gap: 1px; background: var(--ln-border); border: 1px solid var(--ln-border);
}
.landing-root .landing-tile {
background: var(--ln-bg); aspect-ratio: 1 / 1;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 14px; padding: 20px; text-align: center;
transition: background 0.3s ease;
}
.landing-root .landing-tile:hover { background: var(--ln-surface); }
.landing-root .landing-tile-visual {
width: 52px; height: 52px; color: var(--ln-text);
display: flex; align-items: center; justify-content: center;
transition: transform 0.3s ease;
}
.landing-root .landing-tile:hover .landing-tile-visual { transform: translateY(-2px); }
.landing-root .landing-tile-visual svg { width: 100%; height: 100%; }
.landing-root .landing-tile-title {
font-size: 0.75rem; letter-spacing: 0.04em; color: var(--ln-text-dim);
transition: color 0.3s ease;
}
.landing-root .landing-tile:hover .landing-tile-title { color: var(--ln-text); }
/* CTA + footer */
.landing-root .landing-cta { padding: 96px 0; text-align: center; border-bottom: 1px solid var(--ln-border); }
.landing-root .landing-cta-title {
font-family: var(--font-libre-baskerville), 'Libre Baskerville', Georgia, serif;
font-size: clamp(1.5rem, 4vw, 2.25rem); font-weight: 400; margin-bottom: 28px;
}
.landing-root .landing-cta .landing-cta-row { justify-content: center; }
.landing-root .landing-cta-foot { margin-top: 28px; font-size: 0.8125rem; color: var(--ln-text-muted); }
.landing-root .landing-cta-foot a { color: var(--ln-accent); }
.landing-root .landing-cta-foot a:hover { color: var(--ln-text); }
.landing-root footer { padding: 40px 0; font-size: 0.75rem; color: var(--ln-text-muted); }
.landing-root .landing-foot-inner { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; }
.landing-root footer a { color: var(--ln-text-dim); transition: color 0.2s; }
.landing-root footer a:hover { color: var(--ln-text); }
/* Mobile */
@media (max-width: 900px) {
.landing-root .landing-grid { grid-template-columns: repeat(2, 1fr); }
.landing-root .landing-showcase { grid-template-columns: repeat(3, 1fr); }
.landing-root .landing-steps { grid-template-columns: 1fr; gap: 28px; }
}
@media (max-width: 600px) {
.landing-root .landing-container { padding: 0 20px; }
.landing-root .landing-hero { padding: 56px 0 48px; }
.landing-root .landing-grid { grid-template-columns: 1fr; }
.landing-root .landing-showcase { grid-template-columns: repeat(2, 1fr); }
.landing-root .landing-nav-links { gap: 16px; }
.landing-root .landing-foot-inner { flex-direction: column; align-items: flex-start; }
}
+27 -8
View File
@@ -1,14 +1,33 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { IBM_Plex_Mono, Libre_Baskerville } from 'next/font/google'
import './landing.css'
const ibmPlexMono = IBM_Plex_Mono({
subsets: ['latin'],
weight: ['400', '500'],
variable: '--font-ibm-plex-mono',
display: 'swap',
})
const libreBaskerville = Libre_Baskerville({
subsets: ['latin'],
weight: ['400', '700'],
style: ['normal', 'italic'],
variable: '--font-libre-baskerville',
display: 'swap',
})
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'hackerspace.sh — Operational Software for Makerspaces', title: 'hackerspace.sh — Operational software for makerspaces',
description: 'Member management, dues tracking, task coordination, and project visibility. Built by people who have actually run these spaces.', description:
'Member management, dues tracking, task coordination, and project visibility. Built by people who have actually run these spaces.',
} }
export default function LandingLayout({ export default function LandingLayout({ children }: { children: React.ReactNode }) {
children, return (
}: { <div className={`landing-root ${ibmPlexMono.variable} ${libreBaskerville.variable}`}>
children: React.ReactNode <div className="landing-grid-bg" aria-hidden="true" />
}) { {children}
return children </div>
)
} }
+93 -492
View File
@@ -1,539 +1,140 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { BrandMark } from '@/components/brand-mark' import { BrandMark } from '@/components/brand-mark'
import { GithubIcon, ArrowIcon } from '@/components/landing/icons'
import { ResourceShowcase } from '@/components/landing/resource-showcase'
// Icons const MODULES = [
function IcoUsers({ className }: { className?: string }) { { num: '01', title: 'Members', desc: 'Tiers, dues status, contact info, custom roles. Know who is current and who is not, at a glance.' },
return <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /></svg> { num: '02', title: 'Tasks and chores', desc: 'One-off and recurring work with assignments, due dates, and area tags. Claim and complete from anywhere.' },
} { num: '03', title: 'Projects', desc: 'A board for what the space is building. Progress, areas, and who is on it, visible to everyone.' },
function IcoTask({ className }: { className?: string }) { { num: '04', title: 'Payments', desc: 'Import from PayPal, Venmo, or Zeffy. Reconcile transactions to members by hand or by email match.' },
return <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> { num: '05', title: 'Ops and knowledge', desc: 'Procedures, area leads, and an encrypted secrets vault. Role-gated, revealed on demand.' },
} { num: '06', title: 'Governance', desc: 'Proposals with quorum and voting, incident reports, a versioned policy library, and a member forum.' },
function IcoFolder({ className }: { className?: string }) { ]
return <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg>
}
function IcoCreditCard({ className }: { className?: string }) {
return <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" /></svg>
}
function IcoLock({ className }: { className?: string }) {
return <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
}
function IcoDoc({ className }: { className?: string }) {
return <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
}
function IcoArrow({ className }: { className?: string }) {
return <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" /></svg>
}
function IcoEye({ className }: { className?: string }) {
return <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
}
function IcoPin({ className }: { className?: string }) {
return <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" /></svg>
}
function IcoGithub({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" clipRule="evenodd" d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56 0-.27-.01-1.01-.02-1.98-3.2.7-3.88-1.54-3.88-1.54-.52-1.33-1.27-1.69-1.27-1.69-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.03 1.76 2.7 1.25 3.36.96.1-.75.4-1.25.73-1.54-2.55-.29-5.24-1.28-5.24-5.69 0-1.26.45-2.29 1.18-3.09-.12-.29-.51-1.45.11-3.02 0 0 .97-.31 3.18 1.18.92-.26 1.91-.39 2.89-.39.98 0 1.97.13 2.89.39 2.2-1.49 3.17-1.18 3.17-1.18.63 1.57.23 2.73.11 3.02.74.8 1.18 1.83 1.18 3.09 0 4.42-2.69 5.4-5.25 5.68.41.35.78 1.04.78 2.11 0 1.52-.01 2.74-.01 3.11 0 .31.21.68.8.56 4.57-1.52 7.85-5.83 7.85-10.91C23.5 5.65 18.35.5 12 .5z" />
</svg>
)
}
// ─── Mini UI Previews ────────────────────────────────────────────────────────── const STEPS = [
{ num: '01', title: 'Import your members', desc: 'Paste from a spreadsheet or add them one by one. Set tiers, statuses, and contact info.' },
function MiniMemberTable() { { num: '02', title: 'Connect payments', desc: 'Link PayPal or Zeffy, import transactions, and match them to members.' },
const [activeRow, setActiveRow] = useState(0) { num: '03', title: 'Seed your ops docs', desc: 'Drop in SOPs, credentials, and area leads. The whole board has one place to look things up.' },
const members = [ ]
{ name: 'Alex Chen', tier: 'PLUS', status: 'current' },
{ name: 'Jordan Mills', tier: 'BASIC', status: 'current' },
{ name: 'Sam Rivera', tier: 'PLUS', status: 'late' },
{ name: 'Casey Wong', tier: 'BASIC', status: 'current' },
]
useEffect(() => {
const t = setInterval(() => setActiveRow(p => (p + 1) % members.length), 2000)
return () => clearInterval(t)
}, [members.length])
return (
<div className="bg-card border border-border rounded overflow-hidden text-[10px]">
<div className="bg-sidebar px-2 py-1.5 flex items-center justify-between">
<span className="text-white/90 font-medium">Members</span>
<span className="text-white/50 font-mono">{members.length}</span>
</div>
<div className="divide-y divide-border">
{members.map((m, i) => (
<div key={m.name} className={`flex items-center gap-2 px-2 py-1.5 transition-all duration-500 ${i === activeRow ? 'bg-primary/5' : ''}`}>
<div className={`w-5 h-5 rounded flex items-center justify-center font-mono text-[8px] font-bold transition-all duration-500 ${i === activeRow ? 'bg-primary text-white' : 'bg-muted text-muted-foreground'}`}>
{m.name.split(' ').map(n => n[0]).join('')}
</div>
<span className="flex-1 text-foreground truncate">{m.name}</span>
<span className={`font-mono text-[8px] px-1 py-0.5 rounded ${m.tier === 'PLUS' ? 'text-blue-500 bg-blue-500/10' : 'text-muted-foreground bg-muted'}`}>{m.tier}</span>
<span className={`w-1.5 h-1.5 rounded-full ${m.status === 'current' ? 'bg-primary' : 'bg-orange-400'}`} />
</div>
))}
</div>
</div>
)
}
function MiniTaskList() {
const [checkedIndex, setCheckedIndex] = useState(-1)
const tasks = [
{ title: 'Clean laser cutter lens', area: 'Laser' },
{ title: 'Restock filament', area: '3D Print' },
{ title: 'Update wiki docs', area: 'Admin' },
]
useEffect(() => {
const t = setInterval(() => {
setCheckedIndex(p => (p >= tasks.length - 1 ? -1 : p + 1))
}, 1500)
return () => clearInterval(t)
}, [tasks.length])
return (
<div className="bg-card border border-border rounded overflow-hidden text-[10px]">
<div className="bg-sidebar px-2 py-1.5 flex items-center justify-between">
<span className="text-white/90 font-medium">Tasks</span>
<span className="text-primary font-mono">+</span>
</div>
<div className="divide-y divide-border">
{tasks.map((t, i) => (
<div key={t.title} className="flex items-center gap-2 px-2 py-1.5">
<div className={`w-3 h-3 rounded flex items-center justify-center transition-all duration-300 ${i <= checkedIndex ? 'bg-primary' : 'border border-border'}`}>
{i <= checkedIndex && (
<svg className="w-2 h-2 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<span className={`flex-1 truncate transition-all duration-300 ${i <= checkedIndex ? 'text-muted-foreground line-through' : 'text-foreground'}`}>{t.title}</span>
<span className="text-[8px] text-muted-foreground font-mono">{t.area}</span>
</div>
))}
</div>
</div>
)
}
function MiniProjectBoard() {
const [movingCard, setMovingCard] = useState(0)
useEffect(() => {
const t = setInterval(() => setMovingCard(p => (p + 1) % 3), 2500)
return () => clearInterval(t)
}, [])
const columns = ['BACKLOG', 'IN PROGRESS', 'DONE']
return (
<div className="bg-card border border-border rounded overflow-hidden text-[10px]">
<div className="bg-sidebar px-2 py-1.5">
<span className="text-white/90 font-medium">Projects</span>
</div>
<div className="grid grid-cols-3 gap-1 p-1.5">
{columns.map((col, colIndex) => (
<div key={col} className="space-y-1">
<div className="flex items-center gap-1 mb-1">
<span className={`w-1 h-1 rounded-full ${colIndex === 1 ? 'bg-primary' : 'bg-muted-foreground/40'}`} />
<span className="text-[7px] font-mono text-muted-foreground">{col}</span>
</div>
{colIndex === movingCard && (
<div className="bg-muted border border-primary/30 rounded p-1 transition-all duration-500">
<p className="text-[8px] text-foreground truncate">CNC Upgrade</p>
<div className="h-0.5 bg-border rounded mt-1">
<div className="h-full bg-primary rounded" style={{ width: '60%' }} />
</div>
</div>
)}
{colIndex === 1 && colIndex !== movingCard && (
<div className="bg-muted border border-border rounded p-1 opacity-40">
<p className="text-[8px] text-muted-foreground truncate">Network rack</p>
</div>
)}
</div>
))}
</div>
</div>
)
}
function MiniPayments() {
const [linking, setLinking] = useState(false)
useEffect(() => {
const t = setInterval(() => setLinking(p => !p), 2000)
return () => clearInterval(t)
}, [])
return (
<div className="bg-card border border-border rounded overflow-hidden text-[10px]">
<div className="bg-sidebar px-2 py-1.5 flex items-center justify-between">
<span className="text-white/90 font-medium">Payments</span>
<span className="text-orange-400 font-mono text-[8px]">2 unlinked</span>
</div>
<div className="divide-y divide-border">
<div className="flex items-center gap-2 px-2 py-1.5">
<span className="text-blue-500 font-mono text-[8px] font-bold">PAYPAL</span>
<span className="flex-1 text-foreground">$50.00</span>
<span className={`font-mono text-[8px] px-1 py-0.5 rounded transition-all duration-500 ${linking ? 'text-primary bg-primary/10 border border-primary/20' : 'text-muted-foreground border border-dashed border-border'}`}>
{linking ? 'LINKED' : '+ LINK'}
</span>
</div>
<div className="flex items-center gap-2 px-2 py-1.5">
<span className="text-green-500 font-mono text-[8px] font-bold">VENMO</span>
<span className="flex-1 text-foreground">$75.00</span>
<span className="font-mono text-[8px] px-1 py-0.5 rounded text-primary bg-primary/10 border border-primary/20">LINKED</span>
</div>
</div>
</div>
)
}
function MiniOps() {
const [revealedIndex, setRevealedIndex] = useState(-1)
const [tab, setTab] = useState<'secrets' | 'kb'>('secrets')
const secrets = [
{ label: 'WiFi Password', area: 'Network' },
{ label: 'Alarm Code', area: 'Security' },
{ label: 'PayPal API Key', area: 'Finance' },
]
const kb = [
{ title: 'Open / Close Procedure', area: 'Ops', pinned: true },
{ title: 'Laser Cutter Safety', area: 'Laser', pinned: false },
{ title: 'Board Meeting Runbook', area: 'Admin', pinned: true },
]
useEffect(() => {
if (tab !== 'secrets') return
const t = setInterval(() => {
setRevealedIndex(p => (p >= secrets.length - 1 ? -1 : p + 1))
}, 1800)
return () => clearInterval(t)
}, [tab, secrets.length])
useEffect(() => {
const t = setInterval(() => {
setTab(p => (p === 'secrets' ? 'kb' : 'secrets'))
setRevealedIndex(-1)
}, 5000)
return () => clearInterval(t)
}, [])
return (
<div className="bg-card border border-border rounded overflow-hidden text-[10px]">
<div className="bg-sidebar px-2 py-1.5">
<span className="text-white/90 font-medium">Ops</span>
</div>
<div className="flex border-b border-border">
{(['secrets', 'kb'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`flex-1 py-1 font-mono text-[8px] transition-all duration-300 ${tab === t ? 'text-primary border-b border-primary' : 'text-muted-foreground'}`}
>
{t === 'secrets' ? 'SECRETS' : 'KNOWLEDGE BASE'}
</button>
))}
</div>
<div className="divide-y divide-border">
{tab === 'secrets' && secrets.map((s, i) => (
<div key={s.label} className="flex items-center gap-2 px-2 py-1.5">
<div className="w-4 h-4 rounded bg-amber-500/10 flex items-center justify-center flex-shrink-0">
<IcoLock className="w-2.5 h-2.5 text-amber-500" />
</div>
<span className="flex-1 text-foreground truncate">{s.label}</span>
<span className="text-muted-foreground font-mono text-[8px]">{s.area}</span>
<div className={`flex items-center gap-0.5 font-mono text-[8px] px-1 py-0.5 rounded border transition-all duration-400 ${
i === revealedIndex
? 'text-primary border-primary/30 bg-primary/5'
: 'text-muted-foreground border-border'
}`}>
<IcoEye className="w-2 h-2" />
{i === revealedIndex ? (
<span className="font-mono text-[7px] text-primary">••••1234</span>
) : (
<span>reveal</span>
)}
</div>
</div>
))}
{tab === 'kb' && kb.map((k) => (
<div key={k.title} className={`flex items-center gap-2 px-2 py-1.5 border-l-2 ${k.pinned ? 'border-l-primary' : 'border-l-transparent'}`}>
<div className="w-4 h-4 rounded bg-muted flex items-center justify-center flex-shrink-0">
<IcoDoc className="w-2.5 h-2.5 text-muted-foreground" />
</div>
<span className="flex-1 text-foreground truncate text-[9px]">{k.title}</span>
{k.pinned && <IcoPin className="w-2.5 h-2.5 text-primary flex-shrink-0" />}
<span className="text-muted-foreground font-mono text-[8px]">{k.area}</span>
</div>
))}
</div>
</div>
)
}
function MiniDashboard() {
const [tick, setTick] = useState(0)
useEffect(() => {
const t = setInterval(() => setTick(p => p + 1), 3000)
return () => clearInterval(t)
}, [])
const members = 47 + (tick % 3)
return (
<div className="grid grid-cols-2 gap-1.5">
<div className="bg-card border border-border rounded p-2">
<div className="flex items-center gap-1 mb-1">
<IcoUsers className="w-2.5 h-2.5 text-muted-foreground" />
<span className="text-[8px] font-mono text-muted-foreground uppercase">Members</span>
</div>
<p className="text-base font-bold text-primary transition-all duration-300">{members}</p>
</div>
<div className="bg-card border border-border rounded p-2">
<div className="flex items-center gap-1 mb-1">
<IcoTask className="w-2.5 h-2.5 text-muted-foreground" />
<span className="text-[8px] font-mono text-muted-foreground uppercase">Tasks</span>
</div>
<p className="text-base font-bold text-orange-500">12</p>
</div>
<div className="bg-card border border-border rounded p-2 col-span-2">
<div className="flex items-center justify-between mb-1.5">
<span className="text-[8px] font-mono text-muted-foreground uppercase">Due this week</span>
<span className="text-[8px] font-mono text-orange-400">3 late</span>
</div>
<div className="flex gap-0.5">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className={`flex-1 h-3 rounded-sm transition-all duration-700 ${
i < 3 ? 'bg-orange-400' : i < 9 ? 'bg-primary' : 'bg-muted'
}`} />
))}
</div>
</div>
</div>
)
}
// ─── Main Landing Page ─────────────────────────────────────────────────────────
export default function LandingPage() { export default function LandingPage() {
return ( return (
<div className="min-h-screen bg-background"> <>
{/* Nav */} <nav className="landing-nav">
<nav className="border-b border-border bg-sidebar"> <div className="landing-container landing-nav-inner">
<div className="max-w-6xl mx-auto px-4 md:px-6 py-3 flex items-center justify-between gap-4"> <Link href="/" className="landing-wordmark" aria-label="hackerspace.sh">
<div className="flex items-center gap-2 min-w-0"> <BrandMark className="w-5 h-5" style={{ color: 'var(--ln-accent)' }} />
<BrandMark className="w-5 h-5 text-primary shrink-0" /> <span>hackerspace<span style={{ color: 'var(--ln-accent)' }}>.sh</span></span>
<span className="font-mono text-sm font-bold text-white truncate">hackerspace.sh</span> </Link>
</div> <div className="landing-nav-links">
<div className="flex items-center gap-2 shrink-0"> <Link href="/resources" className="hidden sm:inline">Resources</Link>
<Link href="/resources" className="hidden sm:inline-flex font-sans text-sm text-sidebar-foreground hover:text-white transition px-3 py-1.5">
Resources
</Link>
<a <a
href="https://github.com/virgilvox/hackerspace-management" href="https://github.com/virgilvox/hackerspace-management"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label="View source on GitHub" aria-label="View source on GitHub"
className="inline-flex items-center justify-center w-9 h-9 rounded text-sidebar-foreground hover:text-white hover:bg-white/5 transition" className="inline-flex"
> >
<IcoGithub className="w-5 h-5" /> <GithubIcon className="w-[18px] h-[18px]" />
</a> </a>
<Link href="/login" className="font-sans text-sm text-sidebar-foreground hover:text-white transition px-3 py-1.5"> <Link href="/login">Log in</Link>
Log in <Link href="/signup" className="landing-btn-accent">Get started</Link>
</Link>
<Link href="/signup" className="font-sans text-sm bg-primary text-white px-3 py-1.5 rounded hover:bg-primary/90 transition whitespace-nowrap">
Get Started
</Link>
</div> </div>
</div> </div>
</nav> </nav>
{/* Hero */} <header className="landing-hero">
<section className="bg-sidebar border-b border-sidebar-border"> <div className="landing-container">
<div className="max-w-6xl mx-auto px-4 md:px-6 py-16 md:py-24"> <p className="landing-eyebrow">Operating system for hackerspaces</p>
<div className="grid lg:grid-cols-2 gap-12 items-center"> <h1 className="landing-display">
<div> Run your <em>space</em>, not a spreadsheet
<p className="font-mono text-[10px] tracking-widest text-primary uppercase mb-4"> </h1>
hackerspace management <p className="landing-lede">
</p> Members, dues, tasks, projects, ops docs, and governance in one
<h1 className="font-sans text-3xl md:text-4xl lg:text-5xl font-bold text-white leading-tight mb-6 text-balance"> place. Built by people who have actually run these spaces, for the
Members, payments, tasks, projects, ops docs people who keep them running.
</h1> </p>
<p className="font-sans text-base md:text-lg text-sidebar-foreground leading-relaxed mb-8 max-w-lg"> <div className="landing-cta-row">
Track who is in your space, what they owe, what needs doing, and where to find the WiFi password. <Link href="/signup" className="landing-btn-accent">
</p> Get started <ArrowIcon className="w-3.5 h-3.5" />
<div className="flex flex-col sm:flex-row gap-3"> </Link>
<Link href="/signup" className="inline-flex items-center justify-center gap-2 bg-primary text-white font-sans text-sm px-6 py-3 rounded hover:bg-primary/90 transition"> <Link href="/login" className="landing-btn">Log in</Link>
Get Started
<IcoArrow className="w-4 h-4" />
</Link>
<Link href="/login" className="inline-flex items-center justify-center gap-2 border border-sidebar-border text-sidebar-foreground font-sans text-sm px-6 py-3 rounded hover:border-primary/50 hover:text-white transition">
Log in
</Link>
</div>
</div>
<div className="hidden lg:block">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-3">
<MiniMemberTable />
<MiniDashboard />
</div>
<div className="space-y-3 pt-6">
<MiniTaskList />
<MiniPayments />
</div>
</div>
</div>
</div> </div>
</div> </div>
</section> </header>
{/* Features */} <section className="landing-section">
<section id="features" className="py-16 md:py-24"> <div className="landing-container">
<div className="max-w-6xl mx-auto px-4 md:px-6"> <div className="landing-section-head">
<div className="mb-12 md:mb-16"> <span className="landing-section-label">What it does</span>
<p className="font-mono text-[10px] tracking-widest text-primary uppercase mb-3">What it does</p> <span className="landing-section-note">06 modules</span>
<h2 className="font-sans text-2xl md:text-3xl font-bold text-foreground mb-4 text-balance">
Six modules
</h2>
</div> </div>
<div className="landing-grid">
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6"> {MODULES.map(m => (
{[ <div key={m.num} className="landing-card">
{ <span className="landing-card-num">{m.num}</span>
Icon: IcoUsers, <h2 className="landing-card-title">{m.title}</h2>
title: 'Member Directory', <p className="landing-card-desc">{m.desc}</p>
description: 'Track membership tiers, payment status, and contact info. Bulk actions for common operations. Know who is current and who is not.',
preview: <MiniMemberTable />,
},
{
Icon: IcoTask,
title: 'Task Board',
description: 'Chores, maintenance, and one-off tasks with assignments, due dates, and recurring schedules. Claim and complete from anywhere.',
preview: <MiniTaskList />,
},
{
Icon: IcoFolder,
title: 'Project Tracking',
description: 'Kanban board for space projects. Track progress, assign areas, and keep the community informed about what is in motion.',
preview: <MiniProjectBoard />,
},
{
Icon: IcoCreditCard,
title: 'Payment Reconciliation',
description: 'Import from PayPal, Venmo, or Zeffy. Match transactions to members manually or let it auto-match by email.',
preview: <MiniPayments />,
},
{
Icon: IcoLock,
title: 'Ops: Secrets and KB',
description: 'Store WiFi passwords, alarm codes, API keys, and procedures in one place. Role-based visibility. Reveal on demand.',
preview: <MiniOps />,
},
{
Icon: IcoDoc,
title: 'Dashboard',
description: 'At a glance status on membership, open tasks, payment issues, and recent activity. Know what needs attention when you log in.',
preview: <MiniDashboard />,
},
].map((feature) => (
<div key={feature.title} className="bg-card border border-border rounded-lg p-5 hover:border-primary/30 transition group">
<div className="flex items-center gap-3 mb-3">
<div className="w-8 h-8 rounded bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition">
<feature.Icon className="w-4 h-4 text-primary" />
</div>
<h3 className="font-sans text-base font-semibold text-foreground">{feature.title}</h3>
</div>
<p className="font-sans text-sm text-muted-foreground mb-4 leading-relaxed">
{feature.description}
</p>
<div className="opacity-80 group-hover:opacity-100 transition">
{feature.preview}
</div>
</div> </div>
))} ))}
</div> </div>
</div> </div>
</section> </section>
{/* How it works */} <section className="landing-section">
<section className="py-16 md:py-24 bg-muted/30 border-y border-border"> <div className="landing-container">
<div className="max-w-6xl mx-auto px-4 md:px-6"> <div className="landing-section-head">
<div className="mb-12"> <span className="landing-section-label">Setup</span>
<p className="font-mono text-[10px] tracking-widest text-primary uppercase mb-3">Setup</p> <span className="landing-section-note">under an hour</span>
<h2 className="font-sans text-2xl md:text-3xl font-bold text-foreground mb-4">
From nothing to running in under an hour
</h2>
</div> </div>
<div className="grid md:grid-cols-3 gap-8"> <div className="landing-steps">
{[ {STEPS.map(s => (
{ <div key={s.num}>
step: '01', <p className="landing-step-num">{s.num}</p>
title: 'Import your members', <h3 className="landing-step-title">{s.title}</h3>
description: 'Paste from a spreadsheet or add them one by one. Set tiers, statuses, and contact info.', <p className="landing-step-desc">{s.desc}</p>
},
{
step: '02',
title: 'Connect payments',
description: 'Link your PayPal or Zeffy account. Import transactions and match them to members.',
},
{
step: '03',
title: 'Seed your ops docs',
description: 'Drop in your SOPs, credentials, and area leads. The whole board has one place to look things up.',
},
].map((item) => (
<div key={item.step}>
<p className="font-mono text-3xl font-bold text-primary/30 mb-3">{item.step}</p>
<h3 className="font-sans text-lg font-semibold text-foreground mb-2">{item.title}</h3>
<p className="font-sans text-sm text-muted-foreground leading-relaxed">{item.description}</p>
</div> </div>
))} ))}
</div> </div>
</div> </div>
</section> </section>
{/* CTA */} <ResourceShowcase />
<section className="py-16 md:py-24 bg-sidebar border-t border-sidebar-border">
<div className="max-w-3xl mx-auto px-4 md:px-6 text-center"> <section className="landing-cta">
<div className="flex flex-col sm:flex-row gap-3 justify-center"> <div className="landing-container">
<Link href="/signup" className="inline-flex items-center gap-2 bg-primary text-white font-sans text-sm px-8 py-3 rounded hover:bg-primary/90 transition"> <h2 className="landing-cta-title">Stop running your space from memory.</h2>
Get Started <div className="landing-cta-row">
<IcoArrow className="w-4 h-4" /> <Link href="/signup" className="landing-btn-accent">
</Link> Get started <ArrowIcon className="w-3.5 h-3.5" />
<Link href="/login" className="inline-flex items-center gap-2 border border-sidebar-border text-sidebar-foreground font-sans text-sm px-8 py-3 rounded hover:border-primary/50 hover:text-white transition">
Log in
</Link> </Link>
<Link href="/login" className="landing-btn">Log in</Link>
</div> </div>
<p className="mt-6 font-mono text-xs text-sidebar-foreground/60"> <p className="landing-cta-foot">
Not running a space? Read the{' '} Not running a space? Read the{' '}
<Link href="/resources" className="text-primary hover:underline"> <Link href="/resources">hackerspace research and games</Link>.
hackerspace research and games →
</Link>
</p> </p>
</div> </div>
</section> </section>
{/* Footer */} <footer>
<footer className="bg-sidebar border-t border-sidebar-border"> <div className="landing-container landing-foot-inner">
<div className="max-w-6xl mx-auto px-4 md:px-6 py-8"> <div className="flex items-center gap-2">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4"> <BrandMark className="w-4 h-4" style={{ color: 'var(--ln-accent)' }} />
<div className="flex items-center gap-2"> <span>hackerspace.sh</span>
<BrandMark className="w-4 h-4 text-primary" />
<span className="font-mono text-xs text-sidebar-foreground">hackerspace.sh</span>
</div>
<div className="flex items-center gap-4">
<p className="font-mono text-[10px] text-sidebar-foreground/50">
Management software for makerspaces and community workshops.
</p>
<a
href="https://github.com/virgilvox/hackerspace-management"
target="_blank"
rel="noopener noreferrer"
aria-label="View source on GitHub"
className="inline-flex items-center gap-1.5 text-sidebar-foreground/70 hover:text-white transition font-mono text-[10px]"
>
<IcoGithub className="w-3.5 h-3.5" />
source
</a>
</div>
</div> </div>
<a
href="https://github.com/virgilvox/hackerspace-management"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5"
>
<GithubIcon className="w-3.5 h-3.5" />
source
</a>
</div> </div>
</footer> </footer>
</div> </>
) )
} }
+5 -2
View File
@@ -1,10 +1,13 @@
import type { CSSProperties } from 'react'
// The hackerspace.sh brand mark: a minimal terminal prompt glyph (a chevron // The hackerspace.sh brand mark: a minimal terminal prompt glyph (a chevron
// and an underscore). Inherits the surrounding text color via currentColor, // and an underscore). Inherits the surrounding text color via currentColor,
// so it picks up the lime accent wherever it sits on the dark sidebar. // or set an explicit color via `style`.
export function BrandMark({ className }: { className?: string }) { export function BrandMark({ className, style }: { className?: string; style?: CSSProperties }) {
return ( return (
<svg <svg
className={className} className={className}
style={style}
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
+27
View File
@@ -0,0 +1,27 @@
import type { SVGProps } from 'react'
type IconProps = SVGProps<SVGSVGElement>
export function GithubIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" clipRule="evenodd" d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56 0-.27-.01-1.01-.02-1.98-3.2.7-3.88-1.54-3.88-1.54-.52-1.33-1.27-1.69-1.27-1.69-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.03 1.76 2.7 1.25 3.36.96.1-.75.4-1.25.73-1.54-2.55-.29-5.24-1.28-5.24-5.69 0-1.26.45-2.29 1.18-3.09-.12-.29-.51-1.45.11-3.02 0 0 .97-.31 3.18 1.18.92-.26 1.91-.39 2.89-.39.98 0 1.97.13 2.89.39 2.2-1.49 3.17-1.18 3.17-1.18.63 1.57.23 2.73.11 3.02.74.8 1.18 1.83 1.18 3.09 0 4.42-2.69 5.4-5.25 5.68.41.35.78 1.04.78 2.11 0 1.52-.01 2.74-.01 3.11 0 .31.21.68.8.56 4.57-1.52 7.85-5.83 7.85-10.91C23.5 5.65 18.35.5 12 .5z" />
</svg>
)
}
export function ArrowIcon(props: IconProps) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} {...props}>
<path d="M3 8h10M9 4l4 4-4 4" />
</svg>
)
}
export function ExternalArrowIcon(props: IconProps) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} {...props}>
<path d="M4 12L12 4M12 4H6M12 4v6" />
</svg>
)
}
+43
View File
@@ -0,0 +1,43 @@
import Link from 'next/link'
import { AtlasLogo } from '@/components/resources/atlas-logo'
import { CubeIcon, TraceIcon, SpaceIcon, GridIcon, KeyIcon } from '@/components/resources/icons'
// The six hackerspace.sh research/game projects, shown as a single row of
// square tiles at the foot of the landing page. Internal routes use next/link;
// the interactive learner is an external app.
const RESOURCES = [
{ title: 'Atlas', href: '/atlas', external: false, visual: <AtlasLogo /> },
{ title: 'Space After Dark', href: '/space-after-dark', external: false, visual: <CubeIcon /> },
{ title: 'Proposal Duel', href: '/proposal-duel', external: false, visual: <TraceIcon /> },
{ title: 'The Hackerspace Game', href: '/zine', external: false, visual: <SpaceIcon /> },
{ title: 'Interactive Learner', href: 'https://hackerspacegame.netlify.app/', external: true, visual: <GridIcon /> },
{ title: 'Governance in a Box', href: '/governance', external: false, visual: <KeyIcon /> },
]
export function ResourceShowcase() {
return (
<section className="landing-section">
<div className="landing-container">
<div className="landing-section-head">
<span className="landing-section-label">From hackerspace.sh</span>
<span className="landing-section-note">06 projects</span>
</div>
<div className="landing-showcase">
{RESOURCES.map(r =>
r.external ? (
<a key={r.title} href={r.href} target="_blank" rel="noopener noreferrer" className="landing-tile">
<span className="landing-tile-visual">{r.visual}</span>
<span className="landing-tile-title">{r.title}</span>
</a>
) : (
<Link key={r.title} href={r.href} className="landing-tile">
<span className="landing-tile-visual">{r.visual}</span>
<span className="landing-tile-title">{r.title}</span>
</Link>
),
)}
</div>
</div>
</section>
)
}
+21
View File
@@ -4,6 +4,27 @@ Append-only. Newest entries on top. Keep each entry to one screen.
--- ---
## 2026-05-15 (pass 14) — Landing redesign + KB search fix
Branch: `main`.
### What shipped
- **Landing rebuilt in the resources editorial aesthetic.** New scoped `app/(landing)/landing.css` mirrors the `/resources` palette (`#0c0c0c` / lime `#d4ff00` / `#252525` borders), IBM Plex Mono + Libre Baskerville (loaded in `(landing)/layout.tsx`, which now wraps `.landing-root` + grid overlay). `app/(landing)/page.tsx` went from a 539-line `'use client'` monolith to a ~150-line server component. The animated `Mini*` previews and inline `Ico*` set were removed (they clashed with the editorial look). Modules extracted for separation of concerns: `components/landing/icons.tsx`, `components/landing/resource-showcase.tsx`. `BrandMark` now accepts `style`.
- **Resource showcase**: a single row of six square tiles at the foot of the landing, each with the real project SVG logo (`AtlasLogo`, `CubeIcon`, `TraceIcon`, `SpaceIcon`, `GridIcon`, `KeyIcon`), linking to /atlas, /space-after-dark, /proposal-duel, /zine, the external learner, /governance. Responsive 6 -> 3 -> 2 columns.
- **KB search crash fixed** (`app/(app)/ops/ops-client.tsx`). Root cause: `filteredLeads` referenced `l.member_name`, a column that does not exist (it is `lead_handle`), so `undefined.toLowerCase()` threw. Because `search` is shared state and all four filter memos recompute on every keystroke regardless of the active tab, typing in the KB box crashed the whole page. All four predicates are now null-safe via a shared `has()` helper; `filteredSecrets` also no longer assumes a non-null `title`.
### Verification
- `pnpm build` green (`/` route present), `pnpm test` 266/266.
- No migration this pass.
### Next pass (designed, not yet built): permissions + Ops ACLs + area-lead roles
User asked for: customizable permissions; per-secret and per-Ops-item multi-role access; area-lead roles that show "vacant" until a member is assigned (from a dedicated UI or by clicking a member in the directory) and that grant permissions wherever that area-lead role is selected. This is a security-sensitive subsystem (RLS rewrites on `secrets` and `knowledge_base`) and is deliberately NOT bundled with the landing redesign. Design is captured in `docs/PERMISSIONS_DESIGN.md`.
---
## 2026-05-15 (pass 13) — Customize hub, roles editor, logo revert ## 2026-05-15 (pass 13) — Customize hub, roles editor, logo revert
Branch: `main`. Branch: `main`.
+101
View File
@@ -0,0 +1,101 @@
# Permissions, Ops ACLs, and Area-Lead Roles — Design
Status: designed, not yet implemented. This is the plan for the next focused
pass. It is security-sensitive (it rewrites RLS on `secrets` and
`knowledge_base`), so it ships on its own, with its own tests, not bundled
with unrelated UI work.
## Goals (from the user)
1. Customizable permissions, not just role labels.
2. Per-secret access: choose which roles (multiple) can see each secret.
3. Same per-item access control across all Ops and Facilities content
(knowledge base, processes, secrets, area leads).
4. Area-lead roles: an admin can create them. Each shows as "vacant" until a
member is assigned, either from a dedicated picker or by clicking a member
in the directory. Being the lead of an area grants the permissions attached
wherever that area-lead role is referenced.
## Model
Three layers, smallest blast radius first.
### A. Permission catalog + role grants
- `permissions` is a fixed code list in app code (not a table): e.g.
`ops.kb.read`, `ops.kb.write`, `ops.secrets.read`, `ops.secrets.write`,
`members.manage`, `payments.manage`, `governance.manage`, etc.
- New table `space_role_permissions (space_id, role_or_custom, permission)`
where `role_or_custom` is either a built-in `member_role` value or a
`space_custom_roles.slug`. Admin always has all permissions implicitly
(never lockable out — guard in code and RLS).
- A SQL helper `user_has_permission(uid, sid, perm text) returns boolean`
(SECURITY DEFINER, fixed search_path) used by RLS and a TS mirror
`hasPermission()` used in server actions for early rejection + UI gating.
### B. Per-item ACLs for Ops content
- New table `ops_acl (id, space_id, entity_type, entity_id, role text)`
where `entity_type in ('secret','kb','process','area_lead')` and `role`
is a built-in role, a custom-role slug, or the sentinel
`area_lead:<area_code>`.
- An item with zero ACL rows falls back to its existing visibility column
(`knowledge_base.visibility`, the board/admin default for `secrets`), so
this is additive and backward compatible.
- RLS SELECT on `secrets` / `knowledge_base` becomes: existing rule OR an
`EXISTS` against `ops_acl` joined to the caller's effective roles
(built-in role + assigned custom roles + area-lead memberships). Write
stays admin/board (plus `ops.*.write` permission once layer A lands).
### C. Area-lead roles
- Reuse `space_custom_roles` with a flag `is_area_lead boolean` and an
`area_code` link, OR add `space_area_lead_roles (id, space_id, area_code,
name, color)`. Prefer the dedicated table: it is the thing that renders
"vacant".
- `area_lead_assignments (space_id, area_lead_role_id, member_id)`. Absence
of a row => the role renders "Vacant". Assignment is created from a picker
in `/customize` -> Areas or from a "Make area lead" action on a member row
in the directory.
- Effective-roles resolution (used by `ops_acl` SELECT and
`user_has_permission`) treats a member who holds an area-lead assignment as
also holding `area_lead:<area_code>`, so any Ops item whose ACL includes
that sentinel becomes visible to them, and any permission granted to that
area-lead role applies.
## UI surfaces
- `/customize` -> new "Permissions" panel: a role x permission matrix.
- `/customize` -> Areas: per-area "Lead role" with a member picker; shows
"Vacant" when unassigned.
- `/ops` create/edit modals (KB, process, secret): a multi-select of roles +
area-lead roles that may access this item.
- `/members`: row action "Assign as area lead" -> area picker.
## Migration plan
`scripts/023_permissions.sql` + `schema.sql` Section 15:
- `space_role_permissions`, `ops_acl`, `space_area_lead_roles`,
`area_lead_assignments`. All with full RLS (SELECT for space members,
write for admin/board, delete admin).
- Helper functions `user_effective_roles(uid, sid)` and
`user_has_permission(uid, sid, perm)`.
- Rewrite `secrets` and `knowledge_base` SELECT policies to add the ACL
branch. Keep the old branch so nothing that works today breaks.
- Seed sensible default `space_role_permissions` per space (admin: all;
board: ops + members + governance; treasurer: payments; member: kb.read).
- Backfill: none required (empty ACL == current behavior).
- Refresh `types/database.ts`, `DB_SCHEMA_MAP.md`, `DATABASE_SCHEMA.md`,
`API_REFERENCE.md`.
## Risks / why this is its own pass
- RLS on `secrets`/`knowledge_base` is the highest-risk surface in the app.
A wrong policy either leaks a credential cross-role or locks admins out.
Needs dedicated RLS tests (the `__tests__` governance/RLS pattern) before
it goes near production.
- Permission resolution must never let a member escalate themselves (the
migration-015 trigger pattern). The `user_has_permission` helper must be
SECURITY DEFINER with a pinned search_path, same as the existing helpers.
- Ship behind the additive fallback so existing spaces are unaffected until
an admin opts in by setting ACLs/permissions.