From 43f9c09d7016321ca3e288d336cbac390dc0b63f Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Fri, 15 May 2026 16:15:56 -0700 Subject: [PATCH] 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. --- CHANGELOG.md | 9 + app/(app)/ops/ops-client.tsx | 16 +- app/(landing)/landing.css | 196 ++++++++ app/(landing)/layout.tsx | 35 +- app/(landing)/page.tsx | 585 ++++------------------- components/brand-mark.tsx | 7 +- components/landing/icons.tsx | 27 ++ components/landing/resource-showcase.tsx | 43 ++ docs/HANDOFF.md | 21 + docs/PERMISSIONS_DESIGN.md | 101 ++++ 10 files changed, 534 insertions(+), 506 deletions(-) create mode 100644 app/(landing)/landing.css create mode 100644 components/landing/icons.tsx create mode 100644 components/landing/resource-showcase.tsx create mode 100644 docs/PERMISSIONS_DESIGN.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8991d93..230eea9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,16 @@ All notable changes to this project are documented here. Format follows [Keep a ## [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 +- `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. - **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=`. diff --git a/app/(app)/ops/ops-client.tsx b/app/(app)/ops/ops-client.tsx index 760232c..ca8ddd5 100644 --- a/app/(app)/ops/ops-client.tsx +++ b/app/(app)/ops/ops-client.tsx @@ -519,11 +519,19 @@ export function OpsClient({ member, spaceId, kbEntries: initial, areaLeads: init const [showLeadModal, setShowLeadModal] = useState(false) const [editingLead, setEditingLead] = useState(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) { diff --git a/app/(landing)/landing.css b/app/(landing)/landing.css new file mode 100644 index 0000000..f308aa9 --- /dev/null +++ b/app/(landing)/landing.css @@ -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; } +} diff --git a/app/(landing)/layout.tsx b/app/(landing)/layout.tsx index b76986f..2217b58 100644 --- a/app/(landing)/layout.tsx +++ b/app/(landing)/layout.tsx @@ -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 ( +
+ + ) } diff --git a/app/(landing)/page.tsx b/app/(landing)/page.tsx index 608c6d4..aa9cd83 100644 --- a/app/(landing)/page.tsx +++ b/app/(landing)/page.tsx @@ -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 -} -function IcoTask({ className }: { className?: string }) { - return -} -function IcoFolder({ className }: { className?: string }) { - return -} -function IcoCreditCard({ className }: { className?: string }) { - return -} -function IcoLock({ className }: { className?: string }) { - return -} -function IcoDoc({ className }: { className?: string }) { - return -} -function IcoArrow({ className }: { className?: string }) { - return -} -function IcoEye({ className }: { className?: string }) { - return -} -function IcoPin({ className }: { className?: string }) { - return -} -function IcoGithub({ className }: { className?: string }) { - return ( - - ) -} +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 ( -
-
- Members - {members.length} -
-
- {members.map((m, i) => ( -
-
- {m.name.split(' ').map(n => n[0]).join('')} -
- {m.name} - {m.tier} - -
- ))} -
-
- ) -} - -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 ( -
-
- Tasks - + -
-
- {tasks.map((t, i) => ( -
-
- {i <= checkedIndex && ( - - - - )} -
- {t.title} - {t.area} -
- ))} -
-
- ) -} - -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 ( -
-
- Projects -
-
- {columns.map((col, colIndex) => ( -
-
- - {col} -
- {colIndex === movingCard && ( -
-

CNC Upgrade

-
-
-
-
- )} - {colIndex === 1 && colIndex !== movingCard && ( -
-

Network rack

-
- )} -
- ))} -
-
- ) -} - -function MiniPayments() { - const [linking, setLinking] = useState(false) - useEffect(() => { - const t = setInterval(() => setLinking(p => !p), 2000) - return () => clearInterval(t) - }, []) - return ( -
-
- Payments - 2 unlinked -
-
-
- PAYPAL - $50.00 - - {linking ? 'LINKED' : '+ LINK'} - -
-
- VENMO - $75.00 - LINKED -
-
-
- ) -} - -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 ( -
-
- Ops -
-
- {(['secrets', 'kb'] as const).map(t => ( - - ))} -
-
- {tab === 'secrets' && secrets.map((s, i) => ( -
-
- -
- {s.label} - {s.area} -
- - {i === revealedIndex ? ( - ••••1234 - ) : ( - reveal - )} -
-
- ))} - {tab === 'kb' && kb.map((k) => ( -
-
- -
- {k.title} - {k.pinned && } - {k.area} -
- ))} -
-
- ) -} - -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 ( -
-
-
- - Members -
-

{members}

-
-
-
- - Tasks -
-

12

-
-
-
- Due this week - 3 late -
-
- {Array.from({ length: 12 }).map((_, i) => ( -
- ))} -
-
-
- ) -} - -// ─── 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 ( -
- {/* Nav */} -