Sezione Campus: layout dedicato, route protette e documentazione
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,7 @@ const user = Astro.locals.user;
|
||||
<a href="/admin">Articoli</a>
|
||||
{(user.role === 'superuser' || user.role === 'admin') && <a href="/admin/content">Contenuti</a>}
|
||||
{user.role === 'admin' && <a href="/admin/users">Utenti</a>}
|
||||
{user.role === 'admin' && <a href="/campus">Campus</a>}
|
||||
<a href="/admin/new">Nuovo</a>
|
||||
<a href="/admin/logout">Esci</a>
|
||||
<a href="/blog" target="_blank">Vedi sito ↗</a>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
// Layout della sezione riservata Campus. Usa i token e i font del sito, ma
|
||||
// non ne monta Header e Footer: la navigazione è tutta interna, con un solo
|
||||
// richiamo alla home.
|
||||
import '@fontsource/montserrat/400.css';
|
||||
import '@fontsource/montserrat/500.css';
|
||||
import '@fontsource/montserrat/600.css';
|
||||
import '@fontsource/open-sans/400.css';
|
||||
import '@fontsource/open-sans/600.css';
|
||||
import '../styles/global.css';
|
||||
import type { CampusNav } from '../lib/campus';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
nav: CampusNav;
|
||||
currentArea?: string;
|
||||
currentLesson?: string;
|
||||
}
|
||||
const { title, nav, currentArea, currentLesson } = Astro.props;
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title} — Campus InsanityLab</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
</head>
|
||||
<body class="cp">
|
||||
<input type="checkbox" id="cp-menu" class="cp-menu-toggle" hidden />
|
||||
<label for="cp-menu" class="cp-menu-btn" aria-label="Apri il menu">☰ Indice</label>
|
||||
|
||||
<aside class="cp-side">
|
||||
<a class="cp-brand" href="/campus">
|
||||
<span class="eyebrow">InsanityLab</span>
|
||||
Campus
|
||||
</a>
|
||||
<a class="cp-home" href="/">← Torna alla home</a>
|
||||
|
||||
<nav class="cp-nav">
|
||||
{nav.areas.map((area) => (
|
||||
<div class="cp-area" data-on={area.slug === currentArea ? '' : null}>
|
||||
<a class="cp-area__link" href={`/campus/${area.slug}`} style={`--c:${area.color}`}>{area.title}</a>
|
||||
{area.slug === currentArea && (
|
||||
<ul class="cp-chapters">
|
||||
{area.chapters.map((ch) => (
|
||||
<li>
|
||||
<span class="cp-ch" style={`--c:${ch.color}`}>{ch.num}. {ch.title}</span>
|
||||
<ul>
|
||||
{ch.lessons.map((l) => (
|
||||
<li class={l.slug === currentLesson ? 'on' : undefined}>
|
||||
<a href={`/campus/${area.slug}/${l.slug}`}>
|
||||
<span class="cp-label">{l.label}</span> {l.titleShort}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<a class="cp-logout" href="/admin/logout">Esci</a>
|
||||
</aside>
|
||||
|
||||
<main class="cp-main"><slot /></main>
|
||||
|
||||
<style is:global>
|
||||
body.cp { display: flex; align-items: flex-start; background: var(--c-bg); }
|
||||
|
||||
/* --- sidebar --- */
|
||||
.cp-side {
|
||||
width: 300px; flex: 0 0 300px; height: 100vh; position: sticky; top: 0; overflow-y: auto;
|
||||
background: var(--c-dark); color: #cfc6bd; padding: 28px 22px 50px;
|
||||
}
|
||||
.cp-brand { display: block; font-family: var(--font-heading); font-size: 1.5rem; font-weight: 600;
|
||||
letter-spacing: .12em; text-transform: uppercase; color: #fff; text-decoration: none; line-height: 1.1; }
|
||||
.cp-brand .eyebrow { display: block; color: var(--c-accent); margin-bottom: 4px; }
|
||||
.cp-home { display: inline-block; margin: 18px 0 24px; font-family: var(--font-heading); font-size: .7rem;
|
||||
font-weight: 600; letter-spacing: .18em; text-transform: uppercase; color: var(--c-accent);
|
||||
text-decoration: none; border-bottom: 1px solid transparent; }
|
||||
.cp-home:hover { border-bottom-color: var(--c-accent); }
|
||||
|
||||
.cp-nav { border-top: 1px solid rgba(255,255,255,.12); padding-top: 18px; }
|
||||
.cp-area + .cp-area { margin-top: 14px; }
|
||||
.cp-area__link { display: block; font-family: var(--font-heading); font-size: .82rem; font-weight: 600;
|
||||
letter-spacing: .12em; text-transform: uppercase; color: #fff; text-decoration: none;
|
||||
padding: 8px 10px; border-left: 3px solid var(--c); }
|
||||
.cp-area__link:hover, .cp-area[data-on] .cp-area__link { background: rgba(255,255,255,.07); }
|
||||
|
||||
.cp-chapters { list-style: none; margin: 8px 0 0; padding: 0; }
|
||||
.cp-chapters > li + li { margin-top: 12px; }
|
||||
.cp-ch { display: block; font-family: var(--font-heading); font-size: .72rem; letter-spacing: .08em;
|
||||
text-transform: uppercase; color: var(--c); padding-left: 12px; }
|
||||
.cp-chapters ul { list-style: none; margin: 4px 0 0; padding: 0 0 0 12px; }
|
||||
.cp-chapters ul a { display: block; text-decoration: none; color: #b6ada3; font-size: .85rem;
|
||||
line-height: 1.35; padding: 4px 8px; }
|
||||
.cp-chapters ul a:hover { color: #fff; background: rgba(255,255,255,.06); }
|
||||
.cp-chapters ul li.on a { color: #fff; background: rgba(184,169,140,.22); }
|
||||
.cp-label { font-family: var(--font-heading); font-size: .68rem; letter-spacing: .06em; color: var(--c-accent); }
|
||||
|
||||
.cp-logout { display: inline-block; margin-top: 30px; font-size: .78rem; letter-spacing: .1em;
|
||||
text-transform: uppercase; color: #8d8379; text-decoration: none; }
|
||||
.cp-logout:hover { color: #fff; }
|
||||
|
||||
/* --- contenuto --- */
|
||||
.cp-main { flex: 1; min-width: 0; max-width: 920px; margin: 0 auto; padding: 56px 44px 100px; }
|
||||
.cp-main h1 { font-size: clamp(1.9rem, 3.4vw, 2.8rem); text-transform: none; letter-spacing: .02em; }
|
||||
.cp-main h2 { font-size: clamp(1.3rem, 2.2vw, 1.7rem); margin-top: 2em; padding-bottom: .25em;
|
||||
border-bottom: 1px solid #e8e4de; text-transform: none; letter-spacing: .02em; }
|
||||
.cp-main h3 { font-size: 1.12rem; margin-top: 1.7em; color: var(--c-accent-dark); }
|
||||
.cp-main img { margin: 22px 0; border: 1px solid #e8e4de; }
|
||||
.cp-main table { width: 100%; border-collapse: collapse; margin: 20px 0; font-size: .92rem; }
|
||||
.cp-main th, .cp-main td { border: 1px solid #e8e4de; padding: 8px 12px; text-align: left; vertical-align: top; }
|
||||
.cp-main th { background: var(--c-bg-alt); font-family: var(--font-heading); font-weight: 600; }
|
||||
.cp-main blockquote { margin: 20px 0; padding: 4px 20px; border-left: 3px solid var(--c-accent); color: #6a6a6a; }
|
||||
.cp-main code { background: var(--c-bg-alt); padding: 1px 5px; font-size: .9em; }
|
||||
.cp-main li { margin-bottom: .35em; }
|
||||
|
||||
/* --- menu mobile (solo CSS) --- */
|
||||
.cp-menu-btn { display: none; }
|
||||
@media (max-width: 900px) {
|
||||
body.cp { display: block; }
|
||||
.cp-menu-btn { display: block; position: sticky; top: 0; z-index: 20; background: var(--c-dark); color: #fff;
|
||||
font-family: var(--font-heading); font-size: .75rem; font-weight: 600; letter-spacing: .18em;
|
||||
text-transform: uppercase; padding: 14px 20px; cursor: pointer; }
|
||||
.cp-side { display: none; width: auto; flex: none; height: auto; position: static; }
|
||||
.cp-menu-toggle:checked ~ .cp-side { display: block; }
|
||||
.cp-main { padding: 32px 20px 70px; }
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,94 @@
|
||||
// Accesso ai contenuti della sezione Campus, generati da `npm run sync-campus`
|
||||
// in campus-content/. I file sono letti a runtime (non a build time) e tenuti
|
||||
// in cache in memoria: cambiano solo con un nuovo deploy.
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join, normalize, resolve } from 'node:path';
|
||||
|
||||
export interface CampusLink { slug: string; area: string; title: string }
|
||||
|
||||
export interface CampusLesson {
|
||||
slug: string;
|
||||
kind: string;
|
||||
label: string;
|
||||
titleFull: string;
|
||||
titleShort: string;
|
||||
relatore: string;
|
||||
concetti: string[];
|
||||
chapter: number;
|
||||
area: string;
|
||||
prev: CampusLink | null;
|
||||
next: CampusLink | null;
|
||||
}
|
||||
|
||||
export interface CampusChapter {
|
||||
num: number;
|
||||
title: string;
|
||||
sub: string;
|
||||
color: string;
|
||||
lessons: CampusLesson[];
|
||||
}
|
||||
|
||||
export interface CampusArea {
|
||||
slug: string;
|
||||
title: string;
|
||||
sub: string;
|
||||
color: string;
|
||||
chapters: CampusChapter[];
|
||||
}
|
||||
|
||||
export interface CampusNav { areas: CampusArea[]; count: number }
|
||||
|
||||
const CONTENT_DIR = resolve(process.env.CAMPUS_DIR ?? './campus-content');
|
||||
|
||||
let navCache: CampusNav | null = null;
|
||||
|
||||
/** Struttura della sezione, o null se i contenuti non sono stati generati. */
|
||||
export function getNav(): CampusNav | null {
|
||||
if (navCache) return navCache;
|
||||
const file = join(CONTENT_DIR, 'nav.json');
|
||||
if (!existsSync(file)) {
|
||||
console.warn(`Campus: contenuti assenti in ${CONTENT_DIR}. Eseguire "npm run sync-campus".`);
|
||||
return null;
|
||||
}
|
||||
navCache = JSON.parse(readFileSync(file, 'utf8')) as CampusNav;
|
||||
return navCache;
|
||||
}
|
||||
|
||||
export function findArea(nav: CampusNav, slug: string): CampusArea | null {
|
||||
return nav.areas.find((a) => a.slug === slug) ?? null;
|
||||
}
|
||||
|
||||
export function findLesson(nav: CampusNav, slug: string): CampusLesson | null {
|
||||
for (const area of nav.areas)
|
||||
for (const ch of area.chapters) {
|
||||
const l = ch.lessons.find((x) => x.slug === slug);
|
||||
if (l) return l;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function chapterOf(nav: CampusNav, lesson: CampusLesson): CampusChapter | null {
|
||||
for (const area of nav.areas) {
|
||||
const ch = area.chapters.find((c) => c.num === lesson.chapter);
|
||||
if (ch) return ch;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Corpo HTML di una guida; null se il frammento non esiste. */
|
||||
export function readLessonHtml(slug: string): string | null {
|
||||
const file = join(CONTENT_DIR, 'pages', `${slug}.html`);
|
||||
if (!existsSync(file)) return null;
|
||||
return readFileSync(file, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Percorso assoluto di un asset dentro campus-content/assets, oppure null se
|
||||
* il path esce dalla cartella (traversal) o il file non esiste.
|
||||
*/
|
||||
export function resolveAsset(relPath: string): string | null {
|
||||
const base = join(CONTENT_DIR, 'assets');
|
||||
const full = resolve(base, normalize(relPath));
|
||||
if (full !== base && !full.startsWith(base + '/')) return null;
|
||||
return existsSync(full) ? full : null;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
import Admin from '../../layouts/Admin.astro';
|
||||
import { getDb } from '../../lib/db';
|
||||
import { login, SESSION_COOKIE } from '../../lib/auth';
|
||||
import { login, getSessionUser, landingFor, SESSION_COOKIE } from '../../lib/auth';
|
||||
import { rateLimit } from '../../lib/rate-limit';
|
||||
export const prerender = false;
|
||||
|
||||
@@ -17,7 +17,9 @@ if (Astro.request.method === 'POST') {
|
||||
httpOnly: true, sameSite: 'lax', path: '/',
|
||||
secure: import.meta.env.PROD, maxAge: 7 * 24 * 3600,
|
||||
});
|
||||
return Astro.redirect('/admin');
|
||||
// Ogni ruolo atterra sulla sua sezione: campus fuori dal pannello, gli
|
||||
// altri sulla pagina admin che possono effettivamente aprire.
|
||||
return Astro.redirect(landingFor(getSessionUser(getDb(), token)?.role ?? 'user'));
|
||||
}
|
||||
error = 'Credenziali non valide.';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
// Due forme di URL:
|
||||
// /campus/<area> → indice dei capitoli dell'area
|
||||
// /campus/<area>/<guida> → corpo della guida
|
||||
import Campus from '../../layouts/Campus.astro';
|
||||
import { getNav, findArea, findLesson, chapterOf, readLessonHtml } from '../../lib/campus';
|
||||
export const prerender = false;
|
||||
|
||||
const nav = getNav();
|
||||
if (!nav) return new Response('Contenuti del Campus non disponibili', { status: 404 });
|
||||
|
||||
const parts = (Astro.params.slug ?? '').split('/').filter(Boolean);
|
||||
if (parts.length === 0 || parts.length > 2) return new Response('Pagina non trovata', { status: 404 });
|
||||
|
||||
const [areaSlug, lessonSlug] = parts;
|
||||
const area = findArea(nav, areaSlug);
|
||||
if (!area) return new Response('Pagina non trovata', { status: 404 });
|
||||
|
||||
const lesson = lessonSlug ? findLesson(nav, lessonSlug) : null;
|
||||
if (lessonSlug && (!lesson || lesson.area !== areaSlug)) return new Response('Pagina non trovata', { status: 404 });
|
||||
|
||||
const body = lesson ? readLessonHtml(lesson.slug) : null;
|
||||
if (lesson && body === null) return new Response('Guida non disponibile', { status: 404 });
|
||||
|
||||
const chapter = lesson ? chapterOf(nav, lesson) : null;
|
||||
---
|
||||
<Campus title={lesson ? lesson.titleShort : area.title} nav={nav} currentArea={areaSlug} currentLesson={lesson?.slug}>
|
||||
{lesson ? (
|
||||
<article>
|
||||
<nav class="cp-crumbs">
|
||||
<a href="/campus">Campus</a> <span>/</span>
|
||||
<a href={`/campus/${area.slug}`}>{area.title}</a>
|
||||
{chapter && <><span>/</span> <span>{chapter.num}. {chapter.title}</span></>}
|
||||
</nav>
|
||||
<p class="eyebrow">{lesson.label} · {lesson.relatore}</p>
|
||||
<h1>{lesson.titleFull}</h1>
|
||||
{lesson.concetti.length > 0 && (
|
||||
<ul class="cp-concetti">
|
||||
{lesson.concetti.map((c) => <li>{c}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
<div class="cp-body" set:html={body} />
|
||||
<nav class="cp-prevnext">
|
||||
{lesson.prev
|
||||
? <a href={`/campus/${lesson.prev.area}/${lesson.prev.slug}`}>← {lesson.prev.title}</a>
|
||||
: <span />}
|
||||
{lesson.next && <a class="cp-next" href={`/campus/${lesson.next.area}/${lesson.next.slug}`}>{lesson.next.title} →</a>}
|
||||
</nav>
|
||||
</article>
|
||||
) : (
|
||||
<>
|
||||
<nav class="cp-crumbs"><a href="/campus">Campus</a> <span>/</span> <span>{area.title}</span></nav>
|
||||
<p class="eyebrow">Area di studio</p>
|
||||
<h1>{area.title}</h1>
|
||||
<p class="cp-lead">{area.sub}</p>
|
||||
{area.chapters.map((ch) => (
|
||||
<section class="cp-chapter" style={`--c:${ch.color}`}>
|
||||
<h2>{ch.num}. {ch.title}</h2>
|
||||
<p class="cp-chapter__sub">{ch.sub}</p>
|
||||
<ul class="cp-list">
|
||||
{ch.lessons.map((l) => (
|
||||
<li>
|
||||
<a href={`/campus/${area.slug}/${l.slug}`}>
|
||||
<span class="cp-list__label">{l.label}</span>
|
||||
<span class="cp-list__title">{l.titleShort}</span>
|
||||
<span class="cp-list__rel">{l.relatore}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Campus>
|
||||
|
||||
<style is:global>
|
||||
.cp-crumbs { font-size: .78rem; letter-spacing: .06em; color: var(--c-text-light); margin-bottom: 22px; }
|
||||
.cp-crumbs a { color: var(--c-accent-dark); text-decoration: none; }
|
||||
.cp-crumbs a:hover { text-decoration: underline; }
|
||||
.cp-crumbs span { margin: 0 4px; }
|
||||
|
||||
.cp-lead { font-size: 1.08rem; color: var(--c-text-light); max-width: 62ch; margin-bottom: 40px; }
|
||||
|
||||
.cp-concetti { list-style: none; margin: 26px 0 34px; padding: 20px 24px; background: var(--c-bg-alt);
|
||||
border-left: 3px solid var(--c-accent); }
|
||||
.cp-concetti li { font-size: .93rem; margin-bottom: .3em; }
|
||||
.cp-concetti li::before { content: '—'; color: var(--c-accent-dark); margin-right: 8px; }
|
||||
|
||||
.cp-chapter { margin-bottom: 42px; }
|
||||
.cp-chapter h2 { font-size: 1.25rem; text-transform: uppercase; letter-spacing: .08em; border: 0;
|
||||
padding: 0 0 0 12px; margin-bottom: .3em; border-left: 4px solid var(--c); }
|
||||
.cp-chapter__sub { color: var(--c-text-light); font-size: .92rem; padding-left: 16px; }
|
||||
|
||||
.cp-list { list-style: none; margin: 0; padding: 0; }
|
||||
.cp-list li { border-bottom: 1px solid #efece7; }
|
||||
.cp-list a { display: grid; grid-template-columns: 70px 1fr auto; gap: 14px; align-items: baseline;
|
||||
text-decoration: none; color: inherit; padding: 11px 16px; }
|
||||
.cp-list a:hover { background: var(--c-bg-alt); }
|
||||
.cp-list__label { font-family: var(--font-heading); font-size: .72rem; letter-spacing: .06em; color: var(--c-accent-dark); }
|
||||
.cp-list__rel { font-size: .78rem; color: var(--c-text-light); text-align: right; }
|
||||
|
||||
.cp-prevnext { display: flex; justify-content: space-between; gap: 20px; margin-top: 56px;
|
||||
padding-top: 22px; border-top: 1px solid #e8e4de; font-size: .9rem; }
|
||||
.cp-prevnext a { color: var(--c-accent-dark); text-decoration: none; }
|
||||
.cp-prevnext a:hover { text-decoration: underline; }
|
||||
.cp-prevnext .cp-next { margin-left: auto; text-align: right; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cp-list a { grid-template-columns: 56px 1fr; }
|
||||
.cp-list__rel { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
// Immagini delle slide del Campus. Passano dal middleware come il resto di
|
||||
// /campus, quindi non sono raggiungibili senza login con ruolo abilitato.
|
||||
import type { APIRoute } from 'astro';
|
||||
import { createReadStream, statSync } from 'node:fs';
|
||||
import { extname } from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { resolveAsset } from '../../../lib/campus';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const TYPES: Record<string, string> = {
|
||||
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml',
|
||||
};
|
||||
|
||||
export const GET: APIRoute = ({ params }) => {
|
||||
const rel = params.path;
|
||||
if (!rel) return new Response('Not found', { status: 404 });
|
||||
|
||||
const type = TYPES[extname(rel).toLowerCase()];
|
||||
if (!type) return new Response('Tipo non consentito', { status: 400 });
|
||||
|
||||
const file = resolveAsset(rel);
|
||||
if (!file) return new Response('Not found', { status: 404 });
|
||||
|
||||
const { size } = statSync(file);
|
||||
const stream = Readable.toWeb(createReadStream(file)) as ReadableStream;
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': type,
|
||||
'Content-Length': String(size),
|
||||
'Cache-Control': 'private, max-age=86400',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
import Campus from '../../layouts/Campus.astro';
|
||||
import { getNav } from '../../lib/campus';
|
||||
export const prerender = false;
|
||||
|
||||
const nav = getNav();
|
||||
if (!nav) return new Response('Contenuti del Campus non disponibili', { status: 404 });
|
||||
---
|
||||
<Campus title="Guida allo studio" nav={nav}>
|
||||
<p class="eyebrow">Biohacking Campus 2025/2026</p>
|
||||
<h1>Guida allo studio</h1>
|
||||
<p class="cp-lead">
|
||||
Le sintesi di tutte le lezioni del corso, organizzate in cinque aree:
|
||||
{nav.count} guide con concetti chiave, sviluppo esteso e materiali originali.
|
||||
</p>
|
||||
|
||||
<div class="cp-grid">
|
||||
{nav.areas.map((area) => (
|
||||
<a class="cp-card" href={`/campus/${area.slug}`} style={`--c:${area.color}`}>
|
||||
<h2>{area.title}</h2>
|
||||
<p>{area.sub}</p>
|
||||
<ul>
|
||||
{area.chapters.map((ch) => <li>{ch.num}. {ch.title} <span>({ch.lessons.length})</span></li>)}
|
||||
</ul>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</Campus>
|
||||
|
||||
<style is:global>
|
||||
.cp-lead { font-size: 1.1rem; color: var(--c-text-light); max-width: 60ch; margin-bottom: 40px; }
|
||||
.cp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); gap: 20px; }
|
||||
.cp-card { display: block; text-decoration: none; color: inherit; background: #fff;
|
||||
border: 1px solid #e8e4de; border-top: 4px solid var(--c); padding: 22px 24px; transition: box-shadow .2s, transform .2s; }
|
||||
.cp-card:hover { transform: translateY(-3px); box-shadow: 0 12px 28px rgba(58,41,41,.10); }
|
||||
.cp-card h2 { font-size: 1.15rem; margin: 0 0 .4em; border: 0; padding: 0; letter-spacing: .06em;
|
||||
text-transform: uppercase; }
|
||||
.cp-card p { font-size: .92rem; color: var(--c-text-light); margin-bottom: .8em; }
|
||||
.cp-card ul { list-style: none; margin: 0; padding: 12px 0 0; border-top: 1px solid #efece7;
|
||||
font-size: .82rem; color: var(--c-text-light); }
|
||||
.cp-card ul li { margin: 0; padding: 2px 0; }
|
||||
.cp-card ul span { color: var(--c-accent-dark); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user