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
+12 -4
View File
@@ -519,11 +519,19 @@ export function OpsClient({ member, spaceId, kbEntries: initial, areaLeads: init
const [showLeadModal, setShowLeadModal] = useState(false)
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 q = search.toLowerCase()
return kbEntries.filter(e =>
!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])
@@ -531,18 +539,18 @@ export function OpsClient({ member, spaceId, kbEntries: initial, areaLeads: init
const q = search.toLowerCase()
return kbEntries.filter(e =>
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])
const filteredSecrets = useMemo(() => {
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])
const filteredLeads = useMemo(() => {
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])
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 { 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 = {
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.',
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.',
}
export default function LandingLayout({
children,
}: {
children: React.ReactNode
}) {
return children
export default function LandingLayout({ children }: { children: React.ReactNode }) {
return (
<div className={`landing-root ${ibmPlexMono.variable} ${libreBaskerville.variable}`}>
<div className="landing-grid-bg" aria-hidden="true" />
{children}
</div>
)
}
+93 -492
View File
@@ -1,539 +1,140 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { BrandMark } from '@/components/brand-mark'
import { GithubIcon, ArrowIcon } from '@/components/landing/icons'
import { ResourceShowcase } from '@/components/landing/resource-showcase'
// Icons
function IcoUsers({ 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="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>
}
function IcoTask({ 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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
}
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>
)
}
const MODULES = [
{ num: '01', title: 'Members', desc: 'Tiers, dues status, contact info, custom roles. Know who is current and who is not, at a glance.' },
{ 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.' },
{ num: '04', title: 'Payments', desc: 'Import from PayPal, Venmo, or Zeffy. Reconcile transactions to members by hand or by email match.' },
{ 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.' },
]
// ─── Mini UI Previews ──────────────────────────────────────────────────────────
function MiniMemberTable() {
const [activeRow, setActiveRow] = useState(0)
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 ─────────────────────────────────────────────────────────
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.' },
{ num: '02', title: 'Connect payments', desc: 'Link PayPal or Zeffy, import transactions, and match them to members.' },
{ 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.' },
]
export default function LandingPage() {
return (
<div className="min-h-screen bg-background">
{/* Nav */}
<nav className="border-b border-border bg-sidebar">
<div className="max-w-6xl mx-auto px-4 md:px-6 py-3 flex items-center justify-between gap-4">
<div className="flex items-center gap-2 min-w-0">
<BrandMark className="w-5 h-5 text-primary shrink-0" />
<span className="font-mono text-sm font-bold text-white truncate">hackerspace.sh</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<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>
<>
<nav className="landing-nav">
<div className="landing-container landing-nav-inner">
<Link href="/" className="landing-wordmark" aria-label="hackerspace.sh">
<BrandMark className="w-5 h-5" style={{ color: 'var(--ln-accent)' }} />
<span>hackerspace<span style={{ color: 'var(--ln-accent)' }}>.sh</span></span>
</Link>
<div className="landing-nav-links">
<Link href="/resources" className="hidden sm:inline">Resources</Link>
<a
href="https://github.com/virgilvox/hackerspace-management"
target="_blank"
rel="noopener noreferrer"
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>
<Link href="/login" className="font-sans text-sm text-sidebar-foreground hover:text-white transition px-3 py-1.5">
Log in
</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>
<Link href="/login">Log in</Link>
<Link href="/signup" className="landing-btn-accent">Get started</Link>
</div>
</div>
</nav>
{/* Hero */}
<section className="bg-sidebar border-b border-sidebar-border">
<div className="max-w-6xl mx-auto px-4 md:px-6 py-16 md:py-24">
<div className="grid lg:grid-cols-2 gap-12 items-center">
<div>
<p className="font-mono text-[10px] tracking-widest text-primary uppercase mb-4">
hackerspace management
</p>
<h1 className="font-sans text-3xl md:text-4xl lg:text-5xl font-bold text-white leading-tight mb-6 text-balance">
Members, payments, tasks, projects, ops docs
</h1>
<p className="font-sans text-base md:text-lg text-sidebar-foreground leading-relaxed mb-8 max-w-lg">
Track who is in your space, what they owe, what needs doing, and where to find the WiFi password.
</p>
<div className="flex flex-col sm:flex-row gap-3">
<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">
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>
<header className="landing-hero">
<div className="landing-container">
<p className="landing-eyebrow">Operating system for hackerspaces</p>
<h1 className="landing-display">
Run your <em>space</em>, not a spreadsheet
</h1>
<p className="landing-lede">
Members, dues, tasks, projects, ops docs, and governance in one
place. Built by people who have actually run these spaces, for the
people who keep them running.
</p>
<div className="landing-cta-row">
<Link href="/signup" className="landing-btn-accent">
Get started <ArrowIcon className="w-3.5 h-3.5" />
</Link>
<Link href="/login" className="landing-btn">Log in</Link>
</div>
</div>
</section>
</header>
{/* Features */}
<section id="features" className="py-16 md:py-24">
<div className="max-w-6xl mx-auto px-4 md:px-6">
<div className="mb-12 md:mb-16">
<p className="font-mono text-[10px] tracking-widest text-primary uppercase mb-3">What it does</p>
<h2 className="font-sans text-2xl md:text-3xl font-bold text-foreground mb-4 text-balance">
Six modules
</h2>
<section className="landing-section">
<div className="landing-container">
<div className="landing-section-head">
<span className="landing-section-label">What it does</span>
<span className="landing-section-note">06 modules</span>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{[
{
Icon: IcoUsers,
title: 'Member Directory',
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 className="landing-grid">
{MODULES.map(m => (
<div key={m.num} className="landing-card">
<span className="landing-card-num">{m.num}</span>
<h2 className="landing-card-title">{m.title}</h2>
<p className="landing-card-desc">{m.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* How it works */}
<section className="py-16 md:py-24 bg-muted/30 border-y border-border">
<div className="max-w-6xl mx-auto px-4 md:px-6">
<div className="mb-12">
<p className="font-mono text-[10px] tracking-widest text-primary uppercase mb-3">Setup</p>
<h2 className="font-sans text-2xl md:text-3xl font-bold text-foreground mb-4">
From nothing to running in under an hour
</h2>
<section className="landing-section">
<div className="landing-container">
<div className="landing-section-head">
<span className="landing-section-label">Setup</span>
<span className="landing-section-note">under an hour</span>
</div>
<div className="grid md:grid-cols-3 gap-8">
{[
{
step: '01',
title: 'Import your members',
description: 'Paste from a spreadsheet or add them one by one. Set tiers, statuses, and contact info.',
},
{
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 className="landing-steps">
{STEPS.map(s => (
<div key={s.num}>
<p className="landing-step-num">{s.num}</p>
<h3 className="landing-step-title">{s.title}</h3>
<p className="landing-step-desc">{s.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* CTA */}
<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">
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<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">
Get Started
<IcoArrow className="w-4 h-4" />
</Link>
<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
<ResourceShowcase />
<section className="landing-cta">
<div className="landing-container">
<h2 className="landing-cta-title">Stop running your space from memory.</h2>
<div className="landing-cta-row">
<Link href="/signup" className="landing-btn-accent">
Get started <ArrowIcon className="w-3.5 h-3.5" />
</Link>
<Link href="/login" className="landing-btn">Log in</Link>
</div>
<p className="mt-6 font-mono text-xs text-sidebar-foreground/60">
<p className="landing-cta-foot">
Not running a space? Read the{' '}
<Link href="/resources" className="text-primary hover:underline">
hackerspace research and games →
</Link>
<Link href="/resources">hackerspace research and games</Link>.
</p>
</div>
</section>
{/* Footer */}
<footer className="bg-sidebar border-t border-sidebar-border">
<div className="max-w-6xl mx-auto px-4 md:px-6 py-8">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div className="flex items-center gap-2">
<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>
<footer>
<div className="landing-container landing-foot-inner">
<div className="flex items-center gap-2">
<BrandMark className="w-4 h-4" style={{ color: 'var(--ln-accent)' }} />
<span>hackerspace.sh</span>
</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>
</footer>
</div>
</>
)
}