feat: bake hackerspace-sh subsite into the management app
Ports the existing hackerspace.sh Vue site (sibling repo) into this
codebase as a route group `app/(resources)/` so a single domain serves
both the public research / games subsite and the authenticated
management app.
Routes added (all public, all prerendered):
/resources landing page (the original HomePage.vue)
/zine iframe to the printable hackerspace zine
/governance iframe to Governance in a Box
/space-after-dark iframe to the isometric exploration game
/proposal-duel iframe to the proposal-debate game
/atlas iframe to the bundled atlas.html (2.2 MB)
The individual resource paths match the originals exactly so existing
deep links and bookmarks survive the domain switch-over.
Ports six Vue components to React: Wordmark, SpaceIcon, TraceIcon,
CubeIcon, KeyIcon, GridIcon, PromptIcon, AtlasLogo, ProjectCard,
IframeWrapper. CSS scoped under `.resources-root` so the dark IBM Plex
Mono / Libre Baskerville theme cannot bleed into the management app's
shadcn design system. Fonts loaded via next/font/google. The 2.2 MB
atlas.html lives in /public.
proxy.ts adds the six routes plus /atlas.html to PUBLIC_ROUTES so
anonymous visitors can reach them without redirecting to /login.
The management landing page links to /resources twice: a "Resources"
nav link, and a callout under the CTA buttons ("Not running a space?
Read the hackerspace research and games").
This commit is contained in:
@@ -315,6 +315,9 @@ export default function LandingPage() {
|
|||||||
<span className="font-mono text-sm font-bold text-white truncate">hackerspace.sh</span>
|
<span className="font-mono text-sm font-bold text-white truncate">hackerspace.sh</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<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>
|
||||||
<Link href="/login" className="font-sans text-sm text-sidebar-foreground hover:text-white transition px-3 py-1.5">
|
<Link href="/login" className="font-sans text-sm text-sidebar-foreground hover:text-white transition px-3 py-1.5">
|
||||||
Log in
|
Log in
|
||||||
</Link>
|
</Link>
|
||||||
@@ -482,6 +485,12 @@ export default function LandingPage() {
|
|||||||
Log in
|
Log in
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-6 font-mono text-xs text-sidebar-foreground/60">
|
||||||
|
Not running a space? Read the{' '}
|
||||||
|
<Link href="/resources" className="text-primary hover:underline">
|
||||||
|
hackerspace research and games →
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { IframeWrapper } from '@/components/resources/iframe-wrapper'
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Hackerspace Atlas — hackerspace.sh',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AtlasPage() {
|
||||||
|
// atlas.html lives in /public, served by Next.js at the same path.
|
||||||
|
return <IframeWrapper url="/atlas.html" title="Hackerspace Atlas" />
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { IframeWrapper } from '@/components/resources/iframe-wrapper'
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Governance in a Box — hackerspace.sh',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GovernancePage() {
|
||||||
|
return (
|
||||||
|
<IframeWrapper
|
||||||
|
url="https://hackerspaceinabox.netlify.app/"
|
||||||
|
title="Governance in a Box"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { IBM_Plex_Mono, Libre_Baskerville } from 'next/font/google'
|
||||||
|
import './resources.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',
|
||||||
|
description:
|
||||||
|
'How hackerspaces actually work. Research, tools, and games for community workshop governance.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResourcesLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={`resources-root ${ibmPlexMono.variable} ${libreBaskerville.variable}`}>
|
||||||
|
<div className="resources-grid-bg" aria-hidden="true" />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { IframeWrapper } from '@/components/resources/iframe-wrapper'
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Proposal Duel — hackerspace.sh',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProposalDuelPage() {
|
||||||
|
return (
|
||||||
|
<IframeWrapper
|
||||||
|
url="https://lumencanvas.nyc3.cdn.digitaloceanspaces.com/artifacts/hackerspace/proposal-duel.html"
|
||||||
|
title="Proposal Duel"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,508 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* Resources subsite stylesheet — ported from the hackerspace-sh Vue project.
|
||||||
|
*
|
||||||
|
* Scoped under .resources-root so it cannot bleed into the management app's
|
||||||
|
* shadcn/Tailwind design system. Every selector below must descend from
|
||||||
|
* .resources-root.
|
||||||
|
* ===========================================================================*/
|
||||||
|
|
||||||
|
.resources-root {
|
||||||
|
--res-bg: #0c0c0c;
|
||||||
|
--res-surface: #161616;
|
||||||
|
--res-border: #252525;
|
||||||
|
--res-text: #e8e8e8;
|
||||||
|
--res-text-dim: #707070;
|
||||||
|
--res-text-muted: #404040;
|
||||||
|
--res-accent: #d4ff00;
|
||||||
|
|
||||||
|
background: var(--res-bg);
|
||||||
|
color: var(--res-text);
|
||||||
|
font-family: '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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root ::selection {
|
||||||
|
background: var(--res-accent);
|
||||||
|
color: var(--res-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .wordmark {
|
||||||
|
height: 1.5em;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid background */
|
||||||
|
.resources-root .resources-grid-bg {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--res-border) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--res-border) 1px, transparent 1px);
|
||||||
|
background-size: 60px 60px;
|
||||||
|
opacity: 0.3;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root > *:not(.resources-grid-bg):not(.resources-iframe-wrapper) {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container */
|
||||||
|
.resources-root .resources-container {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.resources-root header {
|
||||||
|
padding: 48px 0;
|
||||||
|
border-bottom: 1px solid var(--res-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-header-inner {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-site-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-site-title svg {
|
||||||
|
height: 24px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-header-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 32px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--res-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-header-links a:hover {
|
||||||
|
color: var(--res-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-header-links a.resources-header-link-accent {
|
||||||
|
color: var(--res-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-header-links a.resources-header-link-accent:hover {
|
||||||
|
color: var(--res-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero */
|
||||||
|
.resources-root .resources-hero {
|
||||||
|
padding: 32px 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 32px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-hero-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-hero-title {
|
||||||
|
font-family: 'Libre Baskerville', Georgia, serif;
|
||||||
|
font-size: clamp(1.75rem, 4vw, 2.5rem);
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-hero-title em {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--res-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-hero-prompt {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
color: var(--res-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-hero-sub {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--res-text-dim);
|
||||||
|
line-height: 1.7;
|
||||||
|
max-width: 380px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section header */
|
||||||
|
.resources-root .resources-section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 48px 0 40px;
|
||||||
|
border-top: 1px solid var(--res-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-section-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--res-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-section-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--res-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Projects grid */
|
||||||
|
.resources-root .resources-projects {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--res-border);
|
||||||
|
border: 1px solid var(--res-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-card {
|
||||||
|
background: var(--res-bg);
|
||||||
|
padding: 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 280px;
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-card:hover {
|
||||||
|
background: var(--res-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-card-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-card-icon {
|
||||||
|
position: absolute;
|
||||||
|
top: 24px;
|
||||||
|
right: 24px;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
color: var(--res-text);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-card-icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-card-has-icon .resources-card-num {
|
||||||
|
padding-right: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-card-num {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--res-text-muted);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-card-title {
|
||||||
|
font-family: 'Libre Baskerville', Georgia, serif;
|
||||||
|
font-size: 1.625rem;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-card:hover .resources-card-title {
|
||||||
|
color: var(--res-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-card-desc {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--res-text-dim);
|
||||||
|
line-height: 1.65;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-card-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 32px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid var(--res-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-card-type {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--res-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-card-arrow {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--res-border);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-card-arrow svg {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
color: var(--res-text-dim);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-card:hover .resources-card-arrow {
|
||||||
|
background: var(--res-accent);
|
||||||
|
border-color: var(--res-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-card:hover .resources-card-arrow svg {
|
||||||
|
color: var(--res-bg);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-card-featured {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 60px;
|
||||||
|
min-height: auto;
|
||||||
|
padding: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-card-featured .resources-card-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-card-visual {
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--res-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-card-visual svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Author */
|
||||||
|
.resources-root .resources-author {
|
||||||
|
padding: 80px 0;
|
||||||
|
border-top: 1px solid var(--res-border);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 48px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-author-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-author-label {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--res-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-author-name {
|
||||||
|
font-family: 'Libre Baskerville', Georgia, serif;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-author-bio {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--res-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-author-link {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
padding: 14px 28px;
|
||||||
|
border: 1px solid var(--res-border);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-author-link:hover {
|
||||||
|
background: var(--res-accent);
|
||||||
|
border-color: var(--res-accent);
|
||||||
|
color: var(--res-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-author-link svg {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.resources-root footer {
|
||||||
|
padding: 40px 0;
|
||||||
|
border-top: 1px solid var(--res-border);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--res-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-footer-inner {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root footer a {
|
||||||
|
color: var(--res-text-dim);
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root footer a:hover {
|
||||||
|
color: var(--res-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-footer-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Iframe wrapper (used by /zine, /governance, /space-after-dark, /proposal-duel, /atlas) */
|
||||||
|
.resources-root .resources-iframe-wrapper {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--res-bg);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-iframe-topbar {
|
||||||
|
height: 48px;
|
||||||
|
min-height: 48px;
|
||||||
|
background: var(--res-bg);
|
||||||
|
border-bottom: 1px solid var(--res-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 24px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-iframe-back {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--res-text-dim);
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-iframe-back:hover {
|
||||||
|
color: var(--res-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-iframe-back > svg:first-child {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-iframe-back .wordmark {
|
||||||
|
height: 18px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-iframe-title {
|
||||||
|
color: var(--res-text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-iframe-frame {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.resources-root .resources-container {
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-hero-sub {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-projects {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-card {
|
||||||
|
padding: 32px 24px;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-card-featured {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-card-featured .resources-card-visual {
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-author {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-root .resources-footer-inner {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import { ProjectCard } from '@/components/resources/project-card'
|
||||||
|
import {
|
||||||
|
Wordmark,
|
||||||
|
PromptIcon,
|
||||||
|
SpaceIcon,
|
||||||
|
TraceIcon,
|
||||||
|
CubeIcon,
|
||||||
|
KeyIcon,
|
||||||
|
GridIcon,
|
||||||
|
} from '@/components/resources/icons'
|
||||||
|
import { AtlasLogo } from '@/components/resources/atlas-logo'
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'hackerspace.sh — projects',
|
||||||
|
description:
|
||||||
|
'How hackerspaces actually work: research, governance frameworks, games, and a global atlas of community workshops.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ResourcesLandingPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header>
|
||||||
|
<div className="resources-container">
|
||||||
|
<div className="resources-header-inner">
|
||||||
|
<Link href="/resources" className="resources-site-title" aria-label="hackerspace.sh">
|
||||||
|
<Wordmark />
|
||||||
|
</Link>
|
||||||
|
<nav className="resources-header-links">
|
||||||
|
<a href="https://github.com/virgilvox">GitHub</a>
|
||||||
|
<a href="https://hackster.io/virgilvox">Hackster</a>
|
||||||
|
<Link href="/" className="resources-header-link-accent">
|
||||||
|
Manage your space →
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div className="resources-container">
|
||||||
|
<section className="resources-hero">
|
||||||
|
<div className="resources-hero-content">
|
||||||
|
<h1 className="resources-hero-title">
|
||||||
|
How <em>hackerspaces</em> actually work
|
||||||
|
</h1>
|
||||||
|
<PromptIcon className="resources-hero-prompt" />
|
||||||
|
</div>
|
||||||
|
<p className="resources-hero-sub">
|
||||||
|
The dynamics, the drama, the governance patterns that make or break community
|
||||||
|
workshops. Research turned into tools you can use.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="resources-section-header">
|
||||||
|
<span className="resources-section-label">Projects</span>
|
||||||
|
<span className="resources-section-count">06 tools</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="resources-projects">
|
||||||
|
<ProjectCard
|
||||||
|
to="/atlas"
|
||||||
|
num="01"
|
||||||
|
title="Hackerspace Atlas"
|
||||||
|
description="An interactive map of hackerspaces around the world. Find active spaces, discover closed ones, and explore the global maker community network."
|
||||||
|
type="Map · Global"
|
||||||
|
featured
|
||||||
|
visual={<AtlasLogo />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProjectCard
|
||||||
|
to="/space-after-dark"
|
||||||
|
num="02"
|
||||||
|
title="The Space After Dark"
|
||||||
|
description="An isometric exploration game set in a hackerspace at night. Talk to the regulars — each one an archetype you'll recognize."
|
||||||
|
type="Game · 6 characters"
|
||||||
|
visual={<CubeIcon />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProjectCard
|
||||||
|
to="/proposal-duel"
|
||||||
|
num="03"
|
||||||
|
title="Proposal Duel"
|
||||||
|
description="Fallout-style dialogue combat for hackerspace governance. You have a proposal. Someone opposes it. Survive the meeting."
|
||||||
|
type="Game · 5 opponents"
|
||||||
|
visual={<TraceIcon />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProjectCard
|
||||||
|
to="/zine"
|
||||||
|
num="04"
|
||||||
|
title="The Hackerspace Game"
|
||||||
|
description="A printable zine covering archetypes, trust dynamics, and the lifecycle of community spaces. Based on research from HeatSync Labs, Noisebridge, Hackerspace Gent, and years of watching spaces rise and fall."
|
||||||
|
type="Zine · 12 pages"
|
||||||
|
visual={<SpaceIcon />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProjectCard
|
||||||
|
href="https://hackerspacegame.netlify.app/"
|
||||||
|
external
|
||||||
|
num="05"
|
||||||
|
title="Interactive Learner"
|
||||||
|
description="Nicky Case-style simulations for hackerspace dynamics. Tweak member ratios, watch trust networks form and collapse, run governance scenarios."
|
||||||
|
type="Web App · 8 chapters"
|
||||||
|
visual={<GridIcon />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProjectCard
|
||||||
|
to="/governance"
|
||||||
|
num="06"
|
||||||
|
title="Governance in a Box"
|
||||||
|
description="Templates and frameworks for running a hackerspace. Do-ocracy guidelines, conflict resolution, burnout prevention, succession planning. Fork it and make it yours."
|
||||||
|
type="Resource Kit"
|
||||||
|
featured
|
||||||
|
visual={<KeyIcon />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="resources-author">
|
||||||
|
<div className="resources-author-content">
|
||||||
|
<span className="resources-author-label">Made by</span>
|
||||||
|
<h2 className="resources-author-name">Moheeb Zara</h2>
|
||||||
|
<p className="resources-author-bio">
|
||||||
|
Hardware hacker, new media artist, maker community advocate
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="https://hack.build" className="resources-author-link">
|
||||||
|
hack.build
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path d="M4 12L12 4M12 4H6M12 4v6" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div className="resources-container resources-footer-inner">
|
||||||
|
<p>
|
||||||
|
A project from{' '}
|
||||||
|
<a href="https://heatsynclabs.org">HeatSync Labs</a>. Run your own space?{' '}
|
||||||
|
<Link href="/">Manage it here →</Link>
|
||||||
|
</p>
|
||||||
|
<nav className="resources-footer-links">
|
||||||
|
<a href="https://github.com/virgilvox">Source</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { IframeWrapper } from '@/components/resources/iframe-wrapper'
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'The Space After Dark — hackerspace.sh',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SpaceAfterDarkPage() {
|
||||||
|
return (
|
||||||
|
<IframeWrapper
|
||||||
|
url="https://lumencanvas.nyc3.cdn.digitaloceanspaces.com/artifacts/hackerspace/space_dynamics.html"
|
||||||
|
title="The Space After Dark"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { IframeWrapper } from '@/components/resources/iframe-wrapper'
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'The Hackerspace Game — hackerspace.sh',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ZinePage() {
|
||||||
|
return (
|
||||||
|
<IframeWrapper
|
||||||
|
url="https://lumencanvas.nyc3.cdn.digitaloceanspaces.com/artifacts/hackerspace-zine.html"
|
||||||
|
title="The Hackerspace Game"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { SVGProps } from 'react'
|
||||||
|
|
||||||
|
export function AtlasLogo(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="60 -5 280 350"
|
||||||
|
role="img"
|
||||||
|
aria-label="Hackerspace Atlas logo"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Globe outer */}
|
||||||
|
<circle cx="200" cy="185" r="115" stroke="currentColor" fill="none" strokeWidth={7} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
{/* Meridians */}
|
||||||
|
<ellipse cx="200" cy="185" rx="45" ry="115" stroke="currentColor" fill="none" strokeWidth={4} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<ellipse cx="200" cy="185" rx="85" ry="115" stroke="currentColor" fill="none" strokeWidth={4} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
{/* Equator */}
|
||||||
|
<line x1="85" y1="185" x2="315" y2="185" stroke="currentColor" fill="none" strokeWidth={4} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
{/* Latitude rings */}
|
||||||
|
<ellipse cx="200" cy="133" rx="100" ry="28" stroke="currentColor" fill="none" strokeWidth={4} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<ellipse cx="200" cy="237" rx="100" ry="28" stroke="currentColor" fill="none" strokeWidth={4} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
{/* Vias at intersections */}
|
||||||
|
<g fill="currentColor" stroke="none">
|
||||||
|
<circle cx="85" cy="185" r="9" /><circle cx="85" cy="185" r="3.5" fill="#0c0c0c" />
|
||||||
|
<circle cx="115" cy="185" r="7" /><circle cx="115" cy="185" r="2.5" fill="#0c0c0c" />
|
||||||
|
<circle cx="155" cy="185" r="7" /><circle cx="155" cy="185" r="2.5" fill="#0c0c0c" />
|
||||||
|
<circle cx="245" cy="185" r="7" /><circle cx="245" cy="185" r="2.5" fill="#0c0c0c" />
|
||||||
|
<circle cx="285" cy="185" r="7" /><circle cx="285" cy="185" r="2.5" fill="#0c0c0c" />
|
||||||
|
<circle cx="315" cy="185" r="9" /><circle cx="315" cy="185" r="3.5" fill="#0c0c0c" />
|
||||||
|
<circle cx="200" cy="70" r="8" /><circle cx="200" cy="70" r="3" fill="#0c0c0c" />
|
||||||
|
<circle cx="200" cy="300" r="8" /><circle cx="200" cy="300" r="3" fill="#0c0c0c" />
|
||||||
|
</g>
|
||||||
|
{/* Hackerspace markers */}
|
||||||
|
<g>
|
||||||
|
<circle cx="145" cy="160" r="11" fill="currentColor" />
|
||||||
|
<circle cx="145" cy="160" r="4.5" fill="#0c0c0c" />
|
||||||
|
<circle cx="145" cy="160" r="16" stroke="currentColor" fill="none" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<circle cx="260" cy="205" r="10" fill="currentColor" />
|
||||||
|
<circle cx="260" cy="205" r="4" fill="#0c0c0c" />
|
||||||
|
<circle cx="260" cy="205" r="15" stroke="currentColor" fill="none" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<circle cx="170" cy="255" r="9" fill="currentColor" />
|
||||||
|
<circle cx="170" cy="255" r="3.5" fill="#0c0c0c" />
|
||||||
|
<circle cx="170" cy="255" r="14" stroke="currentColor" fill="none" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</g>
|
||||||
|
{/* Soldering iron compass needle */}
|
||||||
|
<g>
|
||||||
|
<line x1="200" y1="75" x2="200" y2="285" stroke="currentColor" strokeWidth={10} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<path d="M200 28 L186 82 L200 98 L214 82 Z" fill="currentColor" />
|
||||||
|
<rect x="192" y="96" width="16" height="12" rx="2" fill="currentColor" />
|
||||||
|
<rect x="186" y="258" width="28" height="55" rx="6" fill="currentColor" />
|
||||||
|
<line x1="189" y1="272" x2="211" y2="272" stroke="#0c0c0c" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<line x1="189" y1="286" x2="211" y2="286" stroke="#0c0c0c" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<line x1="189" y1="300" x2="211" y2="300" stroke="#0c0c0c" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<rect x="190" y="310" width="20" height="12" rx="4" fill="currentColor" />
|
||||||
|
<path d="M200 335 L212 295 L200 278 L188 295 Z" fill="currentColor" opacity={0.9} />
|
||||||
|
<g stroke="currentColor" fill="none" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" opacity={0.8}>
|
||||||
|
<path d="M184 28 Q174 14 184 4" />
|
||||||
|
<path d="M216 28 Q226 14 216 4" />
|
||||||
|
<path d="M200 22 Q200 12 200 2" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
{/* East/west pointers */}
|
||||||
|
<path d="M330 185 L290 170 L275 185 L290 200 Z" fill="currentColor" opacity={0.9} />
|
||||||
|
<path d="M70 185 L110 200 L125 185 L110 170 Z" fill="currentColor" opacity={0.9} />
|
||||||
|
{/* Center hub */}
|
||||||
|
<circle cx="200" cy="185" r="18" fill="currentColor" />
|
||||||
|
<circle cx="200" cy="185" r="7" fill="#d4ff00" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// Iconography ported from the original hackerspace-sh Vue subsite.
|
||||||
|
// All SVGs render with `currentColor` so they inherit the surrounding
|
||||||
|
// text colour and can be tinted by parent styles. The lime accent
|
||||||
|
// (#d4ff00) is hardcoded where the original components used it.
|
||||||
|
|
||||||
|
import type { SVGProps } from 'react'
|
||||||
|
|
||||||
|
type IconProps = SVGProps<SVGSVGElement>
|
||||||
|
|
||||||
|
export function Wordmark(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 240 48"
|
||||||
|
className="wordmark"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<text
|
||||||
|
x="0"
|
||||||
|
y="34"
|
||||||
|
fontFamily="'IBM Plex Mono', monospace"
|
||||||
|
fontSize="28"
|
||||||
|
fontWeight="500"
|
||||||
|
fill="currentColor"
|
||||||
|
letterSpacing="-1"
|
||||||
|
>
|
||||||
|
hackerspace
|
||||||
|
<tspan fill="#d4ff00">.sh</tspan>
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpaceIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" {...props}>
|
||||||
|
<path d="M6 6 L6 30 L18 30 L18 18 L30 18 L30 6 Z" fill="currentColor" />
|
||||||
|
<path d="M18 18 L18 42 L42 42 L42 18 Z" fill="none" stroke="currentColor" strokeWidth={2.5} />
|
||||||
|
<circle cx="30" cy="30" r="5" fill="#d4ff00" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TraceIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" {...props}>
|
||||||
|
<path
|
||||||
|
d="M8 8 L8 24 L24 24 L24 40 L40 40"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={3.5}
|
||||||
|
strokeLinecap="square"
|
||||||
|
/>
|
||||||
|
<circle cx="8" cy="8" r="5" fill="currentColor" />
|
||||||
|
<circle cx="24" cy="24" r="5" fill="currentColor" />
|
||||||
|
<circle cx="40" cy="40" r="6" fill="#d4ff00" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CubeIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 48 48" fill="none" {...props}>
|
||||||
|
<path d="M24 4 L44 16 L44 36 L24 48 L4 36 L4 16 Z" stroke="currentColor" strokeWidth={2.5} />
|
||||||
|
<path d="M24 4 L24 24 M4 16 L24 24 L44 16" stroke="currentColor" strokeWidth={2.5} />
|
||||||
|
<path d="M24 24 L24 48" stroke="#d4ff00" strokeWidth={2.5} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 48 48" fill="none" {...props}>
|
||||||
|
<circle cx="14" cy="14" r="10" stroke="currentColor" strokeWidth={3} />
|
||||||
|
<path d="M22 22 L42 42" stroke="currentColor" strokeWidth={4} strokeLinecap="round" />
|
||||||
|
<path d="M34 34 L40 28" stroke="#d4ff00" strokeWidth={4} strokeLinecap="round" />
|
||||||
|
<path d="M38 38 L44 32" stroke="#d4ff00" strokeWidth={4} strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GridIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 48 48" fill="none" {...props}>
|
||||||
|
<rect x="6" y="6" width="16" height="16" fill="currentColor" />
|
||||||
|
<rect x="26" y="6" width="16" height="16" fill="currentColor" opacity={0.5} />
|
||||||
|
<rect x="6" y="26" width="16" height="16" fill="currentColor" opacity={0.5} />
|
||||||
|
<rect x="26" y="26" width="16" height="16" fill="#d4ff00" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PromptIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" {...props}>
|
||||||
|
<path
|
||||||
|
d="M8 12 L24 24 L8 36"
|
||||||
|
fill="none"
|
||||||
|
stroke="#d4ff00"
|
||||||
|
strokeWidth={5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M28 36 L40 36"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import { Wordmark } from './icons'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders an iframe at full-viewport size with a thin top bar that links
|
||||||
|
* back to /resources and labels the embedded content. Used by /zine,
|
||||||
|
* /governance, /space-after-dark, /proposal-duel, and /atlas.
|
||||||
|
*/
|
||||||
|
export function IframeWrapper({ url, title }: { url: string; title: string }) {
|
||||||
|
return (
|
||||||
|
<div className="resources-iframe-wrapper">
|
||||||
|
<div className="resources-iframe-topbar">
|
||||||
|
<Link href="/resources" className="resources-iframe-back">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path d="M10 4L6 8l4 4" />
|
||||||
|
</svg>
|
||||||
|
<Wordmark />
|
||||||
|
</Link>
|
||||||
|
<span className="resources-iframe-title">{title}</span>
|
||||||
|
</div>
|
||||||
|
<iframe src={url} className="resources-iframe-frame" title={title} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
type CommonProps = {
|
||||||
|
num: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
type: string
|
||||||
|
featured?: boolean
|
||||||
|
visual?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
type InternalProps = CommonProps & { to: string; href?: never; external?: false }
|
||||||
|
type ExternalProps = CommonProps & { href: string; to?: never; external?: true }
|
||||||
|
type Props = InternalProps | ExternalProps
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project card on /resources. Behaves as either an internal next/link
|
||||||
|
* (when `to` is set) or an external anchor with target=_blank (when
|
||||||
|
* `href` is set). Featured cards span full width with the visual on the
|
||||||
|
* right; non-featured render with a small icon in the top-right corner.
|
||||||
|
*/
|
||||||
|
export function ProjectCard(props: Props) {
|
||||||
|
const { num, title, description, type, featured = false, visual } = props
|
||||||
|
const hasIcon = visual && !featured
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
{hasIcon && <span className="resources-card-icon">{visual}</span>}
|
||||||
|
<div className="resources-card-content">
|
||||||
|
<span className="resources-card-num">{num}</span>
|
||||||
|
<h2 className="resources-card-title">{title}</h2>
|
||||||
|
<p className="resources-card-desc">{description}</p>
|
||||||
|
<div className="resources-card-footer">
|
||||||
|
<span className="resources-card-type">{type}</span>
|
||||||
|
<span className="resources-card-arrow">
|
||||||
|
{'external' in props && props.external ? (
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path d="M4 12L12 4M12 4H6M12 4v6" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path d="M3 8h10M9 4l4 4-4 4" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{featured && visual && <div className="resources-card-visual">{visual}</div>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const className = `resources-card${featured ? ' resources-card-featured' : ''}${hasIcon ? ' resources-card-has-icon' : ''}`
|
||||||
|
|
||||||
|
if ('to' in props && props.to) {
|
||||||
|
return (
|
||||||
|
<Link href={props.to} className={className}>
|
||||||
|
{body}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<a href={(props as ExternalProps).href} className={className} target="_blank" rel="noopener noreferrer">
|
||||||
|
{body}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,25 @@ import { updateSession } from '@/lib/supabase/proxy'
|
|||||||
import { createServerClient } from '@supabase/ssr'
|
import { createServerClient } from '@supabase/ssr'
|
||||||
import { NextResponse, type NextRequest } from 'next/server'
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
|
|
||||||
const PUBLIC_ROUTES = ['/', '/login', '/signup', '/auth', '/api/health']
|
// Public routes are reachable without an authenticated session. The
|
||||||
|
// /resources subsite is the legacy hackerspace.sh content baked in; the
|
||||||
|
// five individual resource routes (/zine, /governance, /space-after-dark,
|
||||||
|
// /proposal-duel, /atlas) must keep these exact paths so existing deep
|
||||||
|
// links and bookmarks survive the domain switch-over.
|
||||||
|
const PUBLIC_ROUTES = [
|
||||||
|
'/',
|
||||||
|
'/login',
|
||||||
|
'/signup',
|
||||||
|
'/auth',
|
||||||
|
'/api/health',
|
||||||
|
'/resources',
|
||||||
|
'/zine',
|
||||||
|
'/governance',
|
||||||
|
'/space-after-dark',
|
||||||
|
'/proposal-duel',
|
||||||
|
'/atlas',
|
||||||
|
'/atlas.html',
|
||||||
|
]
|
||||||
|
|
||||||
export async function proxy(request: NextRequest) {
|
export async function proxy(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl
|
const { pathname } = request.nextUrl
|
||||||
|
|||||||
+48112
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user