Redesign homepage with hero, features, and categorized notebook gallery
SSR the notebook list in Astro frontmatter (eliminating the client-side loading spinner). Add hero section with oscilloscope graticule background, 4-column feature highlights, and a React island gallery with category filter pills, tag search, and grouped/flat view modes.
This commit is contained in:
parent
1e08be4409
commit
eae849fe7a
@ -9,7 +9,7 @@ export default defineConfig({
|
||||
adapter: node({ mode: 'standalone' }),
|
||||
integrations: [
|
||||
react(),
|
||||
icon({ include: { lucide: ['plus', 'book-open', 'zap', 'cpu'] } }),
|
||||
icon({ include: { lucide: ['plus', 'book-open', 'zap', 'cpu', 'activity', 'circuit-board', 'file-text', 'chevron-down'] } }),
|
||||
],
|
||||
telemetry: false,
|
||||
devToolbar: { enabled: false },
|
||||
|
||||
65
frontend/src/components/NotebookCard.tsx
Normal file
65
frontend/src/components/NotebookCard.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
import { Badge } from './ui/Badge';
|
||||
import type { NotebookSummary } from '../lib/types';
|
||||
|
||||
const engineVariant: Record<string, 'blue' | 'amber'> = {
|
||||
ngspice: 'blue',
|
||||
ltspice: 'amber',
|
||||
};
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
interface NotebookCardProps {
|
||||
notebook: NotebookSummary;
|
||||
}
|
||||
|
||||
export function NotebookCard({ notebook }: NotebookCardProps) {
|
||||
const { id, title, engine, tags, cell_count, modified } = notebook;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/notebook/${encodeURIComponent(id)}`}
|
||||
className="group block rounded-lg border border-slate-800 bg-slate-900/50 p-5 hover:border-blue-500/40 hover:bg-slate-800/70 transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className="text-base font-semibold text-slate-100 group-hover:text-blue-400 transition-colors line-clamp-1">
|
||||
{title}
|
||||
</h3>
|
||||
<Badge variant={engineVariant[engine] ?? 'default'} className="shrink-0 ml-2">
|
||||
{engine}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-slate-500 mb-3">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<LayoutGrid className="w-3 h-3" />
|
||||
{cell_count} cell{cell_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span>{formatDate(modified)}</span>
|
||||
</div>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-xs bg-slate-700/50 text-slate-400 px-2 py-0.5 rounded"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
229
frontend/src/components/NotebookGallery.tsx
Normal file
229
frontend/src/components/NotebookGallery.tsx
Normal file
@ -0,0 +1,229 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Timer,
|
||||
Triangle,
|
||||
Thermometer,
|
||||
Radio,
|
||||
BatteryCharging,
|
||||
Volume2,
|
||||
BookOpen,
|
||||
Cpu,
|
||||
Search,
|
||||
X,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { NotebookCard } from './NotebookCard';
|
||||
import type { NotebookSummary } from '../lib/types';
|
||||
|
||||
// Category definitions: priority-ordered, matched by tag keywords
|
||||
const CATEGORIES = [
|
||||
{ key: 'timers', label: 'Timers & Oscillators', icon: Timer, matchTags: ['555', 'timer', 'oscillator'] },
|
||||
{ key: 'opamp', label: 'Op-Amp Circuits', icon: Triangle, matchTags: ['op-amp'] },
|
||||
{ key: 'sensors', label: 'Sensors & Measurement', icon: Thermometer, matchTags: ['sensor', 'measurement', 'bridge'] },
|
||||
{ key: 'comms', label: 'Communications & RF', icon: Radio, matchTags: ['radio', 'rf', 'communications', 'am'] },
|
||||
{ key: 'power', label: 'Power Electronics', icon: BatteryCharging, matchTags: ['power', 'converter', 'charge'] },
|
||||
{ key: 'amplifiers', label: 'Amplifiers', icon: Volume2, matchTags: ['amplifier', 'bjt', 'audio'] },
|
||||
{ key: 'passive', label: 'Passive & Beginner', icon: BookOpen, matchTags: ['passive', 'beginner', 'filter', 'dc'] },
|
||||
{ key: 'advanced', label: 'Advanced Topics', icon: Cpu, matchTags: [] }, // fallback
|
||||
] as const;
|
||||
|
||||
type CategoryKey = (typeof CATEGORIES)[number]['key'];
|
||||
|
||||
function categorize(notebooks: NotebookSummary[]): Map<CategoryKey, NotebookSummary[]> {
|
||||
const grouped = new Map<CategoryKey, NotebookSummary[]>();
|
||||
for (const cat of CATEGORIES) grouped.set(cat.key, []);
|
||||
|
||||
const assigned = new Set<string>();
|
||||
|
||||
// First pass: assign each notebook to the first matching category
|
||||
for (const cat of CATEGORIES) {
|
||||
if (cat.matchTags.length === 0) continue; // skip fallback
|
||||
for (const nb of notebooks) {
|
||||
if (assigned.has(nb.id)) continue;
|
||||
const lower = nb.tags.map((t) => t.toLowerCase());
|
||||
if (cat.matchTags.some((mt) => lower.includes(mt))) {
|
||||
grouped.get(cat.key)!.push(nb);
|
||||
assigned.add(nb.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: unmatched → Advanced Topics
|
||||
for (const nb of notebooks) {
|
||||
if (!assigned.has(nb.id)) {
|
||||
grouped.get('advanced')!.push(nb);
|
||||
}
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function matchesSearch(nb: NotebookSummary, query: string): boolean {
|
||||
const q = query.toLowerCase();
|
||||
return (
|
||||
nb.title.toLowerCase().includes(q) ||
|
||||
nb.tags.some((t) => t.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
|
||||
interface NotebookGalleryProps {
|
||||
initialNotebooks: NotebookSummary[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export default function NotebookGallery({ initialNotebooks, error }: NotebookGalleryProps) {
|
||||
const [activeCategory, setActiveCategory] = useState<'all' | CategoryKey>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const grouped = useMemo(() => categorize(initialNotebooks), [initialNotebooks]);
|
||||
|
||||
// Categories that actually have notebooks
|
||||
const activeCategories = useMemo(
|
||||
() => CATEGORIES.filter((cat) => (grouped.get(cat.key)?.length ?? 0) > 0),
|
||||
[grouped],
|
||||
);
|
||||
|
||||
// Apply search filter
|
||||
const filteredGrouped = useMemo(() => {
|
||||
if (!searchQuery) return grouped;
|
||||
const result = new Map<CategoryKey, NotebookSummary[]>();
|
||||
for (const [key, nbs] of grouped) {
|
||||
result.set(key, nbs.filter((nb) => matchesSearch(nb, searchQuery)));
|
||||
}
|
||||
return result;
|
||||
}, [grouped, searchQuery]);
|
||||
|
||||
// Flat list for single-category view
|
||||
const flatFiltered = useMemo(() => {
|
||||
if (activeCategory === 'all') return null;
|
||||
const nbs = filteredGrouped.get(activeCategory) ?? [];
|
||||
return nbs;
|
||||
}, [activeCategory, filteredGrouped]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-600/10 p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-400 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<h3 className="text-amber-400 font-medium">Could not load notebooks</h3>
|
||||
<p className="text-amber-200/70 text-sm mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (initialNotebooks.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-slate-500 mb-4">No notebooks yet.</p>
|
||||
<a
|
||||
href="/notebook/new"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-medium transition-colors text-sm"
|
||||
>
|
||||
Create your first notebook
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Filter bar */}
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="flex gap-2 overflow-x-auto filter-pills flex-1 pb-1">
|
||||
<button
|
||||
onClick={() => setActiveCategory('all')}
|
||||
className={`shrink-0 px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
activeCategory === 'all'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{activeCategories.map((cat) => {
|
||||
const Icon = cat.icon;
|
||||
const count = filteredGrouped.get(cat.key)?.length ?? 0;
|
||||
return (
|
||||
<button
|
||||
key={cat.key}
|
||||
onClick={() => setActiveCategory(cat.key)}
|
||||
className={`shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
activeCategory === cat.key
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{cat.label}
|
||||
<span className="text-xs opacity-70">({count})</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tag search */}
|
||||
<div className="relative shrink-0">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tags..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-40 pl-8 pr-8 py-1.5 text-sm rounded-lg border border-slate-700 bg-slate-800/50 text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-blue-500/50"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category-grouped "All" view */}
|
||||
{activeCategory === 'all' && (
|
||||
<div className="space-y-10">
|
||||
{activeCategories.map((cat) => {
|
||||
const nbs = filteredGrouped.get(cat.key) ?? [];
|
||||
if (nbs.length === 0) return null;
|
||||
const Icon = cat.icon;
|
||||
return (
|
||||
<section key={cat.key}>
|
||||
<h3 className="flex items-center gap-2 text-lg font-semibold text-slate-200 mb-4">
|
||||
<Icon className="w-5 h-5 text-slate-400" />
|
||||
{cat.label}
|
||||
<span className="text-sm font-normal text-slate-500">({nbs.length})</span>
|
||||
</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{nbs.map((nb) => (
|
||||
<NotebookCard key={nb.id} notebook={nb} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Single-category flat view */}
|
||||
{activeCategory !== 'all' && flatFiltered && (
|
||||
<div>
|
||||
{flatFiltered.length === 0 ? (
|
||||
<p className="text-slate-500 text-center py-12">No notebooks match this filter.</p>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{flatFiltered.map((nb) => (
|
||||
<NotebookCard key={nb.id} notebook={nb} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,11 @@
|
||||
import type { NotebookSummary } from './types';
|
||||
|
||||
// process.env is required here — Vite's import.meta.env only exposes
|
||||
// PUBLIC_* prefixed vars. This module runs server-side only (SSR).
|
||||
export const INTERNAL_API_BASE =
|
||||
process.env.BACKEND_INTERNAL_URL || 'http://localhost:8099';
|
||||
|
||||
export async function fetchNotebookMeta(id: string) {
|
||||
const url = `${INTERNAL_API_BASE}/api/notebooks/${encodeURIComponent(id)}`;
|
||||
async function ssrFetch<T>(label: string, url: string): Promise<T | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
@ -12,19 +13,29 @@ export async function fetchNotebookMeta(id: string) {
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(`[server-api] fetchNotebookMeta(${id}): HTTP ${res.status}`);
|
||||
console.warn(`[server-api] ${label}: HTTP ${res.status}`);
|
||||
return null;
|
||||
}
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
console.error(`[server-api] fetchNotebookMeta(${id}): timed out after 5s`);
|
||||
console.error(`[server-api] ${label}: timed out after 5s`);
|
||||
} else {
|
||||
console.error(
|
||||
`[server-api] fetchNotebookMeta(${id}):`,
|
||||
`[server-api] ${label}:`,
|
||||
err instanceof Error ? err.message : err
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchNotebookMeta(id: string) {
|
||||
const url = `${INTERNAL_API_BASE}/api/notebooks/${encodeURIComponent(id)}`;
|
||||
return ssrFetch(`fetchNotebookMeta(${id})`, url);
|
||||
}
|
||||
|
||||
export async function fetchNotebookList(): Promise<NotebookSummary[]> {
|
||||
const url = `${INTERNAL_API_BASE}/api/notebooks`;
|
||||
return (await ssrFetch<NotebookSummary[]>('fetchNotebookList', url)) ?? [];
|
||||
}
|
||||
|
||||
@ -1,33 +1,115 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components';
|
||||
import NotebookLayout from '../layouts/NotebookLayout.astro';
|
||||
import NotebookList from '../components/NotebookList';
|
||||
import NotebookGallery from '../components/NotebookGallery';
|
||||
import { fetchNotebookList } from '../lib/server-api';
|
||||
import type { NotebookSummary } from '../lib/types';
|
||||
|
||||
let notebooks: NotebookSummary[] = [];
|
||||
let fetchError: string | null = null;
|
||||
try {
|
||||
notebooks = await fetchNotebookList();
|
||||
} catch (err) {
|
||||
fetchError = err instanceof Error ? err.message : 'Backend unavailable';
|
||||
}
|
||||
---
|
||||
|
||||
<NotebookLayout
|
||||
title="SpiceBook"
|
||||
description="Notebook interface for SPICE circuit simulation"
|
||||
description="Write SPICE netlists, run ngspice simulations, and visualize waveforms in a single document."
|
||||
canonicalPath="/"
|
||||
>
|
||||
<div class="max-w-5xl mx-auto px-6 py-10">
|
||||
<!-- Header (static) -->
|
||||
<header class="mb-10">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-slate-100 tracking-tight">SpiceBook</h1>
|
||||
<p class="text-slate-400 mt-1">Notebook interface for SPICE circuit simulation</p>
|
||||
</div>
|
||||
<style>
|
||||
@import '../styles/homepage.css';
|
||||
</style>
|
||||
|
||||
<!-- Hero -->
|
||||
<section class="hero-graticule border-b border-slate-800/60">
|
||||
<div class="relative max-w-6xl mx-auto px-6 pt-16 pb-20">
|
||||
<p class="text-sm font-semibold tracking-widest text-blue-400 uppercase mb-4">SpiceBook</p>
|
||||
<h1 class="text-4xl md:text-5xl font-bold text-slate-100 tracking-tight max-w-2xl">
|
||||
Circuit Simulation Notebooks
|
||||
</h1>
|
||||
<p class="mt-4 text-lg text-slate-400 max-w-xl leading-relaxed">
|
||||
Write SPICE netlists, run ngspice simulations, and visualize waveforms in a single document.
|
||||
</p>
|
||||
<div class="flex items-center gap-3 mt-8">
|
||||
<a
|
||||
href="/notebook/new"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-medium transition-colors text-sm"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-medium transition-colors text-sm"
|
||||
>
|
||||
<Icon name="lucide:plus" class="w-4 h-4" />
|
||||
New Notebook
|
||||
</a>
|
||||
<a
|
||||
href="#notebooks"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg border border-slate-700 text-slate-300 hover:border-slate-500 hover:text-slate-100 font-medium transition-colors text-sm"
|
||||
>
|
||||
Browse Notebooks
|
||||
<Icon name="lucide:chevron-down" class="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Dynamic notebook list (client-side fetch) -->
|
||||
<NotebookList client:load />
|
||||
</div>
|
||||
<!-- Feature Highlights -->
|
||||
<section class="max-w-6xl mx-auto px-6 py-16">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="p-5 rounded-lg border border-slate-800 bg-slate-900/50">
|
||||
<Icon name="lucide:zap" class="w-6 h-6 text-blue-400 mb-3" />
|
||||
<h3 class="font-semibold text-slate-100 mb-1">SPICE Simulation</h3>
|
||||
<p class="text-sm text-slate-400 leading-relaxed">
|
||||
Write netlists and run ngspice. Transient, AC, DC sweep, operating point.
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-5 rounded-lg border border-slate-800 bg-slate-900/50">
|
||||
<Icon name="lucide:activity" class="w-6 h-6 text-blue-400 mb-3" />
|
||||
<h3 class="font-semibold text-slate-100 mb-1">Waveform Plots</h3>
|
||||
<p class="text-sm text-slate-400 leading-relaxed">
|
||||
Results render as interactive plots below each netlist.
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-5 rounded-lg border border-slate-800 bg-slate-900/50">
|
||||
<Icon name="lucide:circuit-board" class="w-6 h-6 text-blue-400 mb-3" />
|
||||
<h3 class="font-semibold text-slate-100 mb-1">Schematic Generation</h3>
|
||||
<p class="text-sm text-slate-400 leading-relaxed">
|
||||
Netlists produce SVG circuit diagrams automatically.
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-5 rounded-lg border border-slate-800 bg-slate-900/50">
|
||||
<Icon name="lucide:file-text" class="w-6 h-6 text-blue-400 mb-3" />
|
||||
<h3 class="font-semibold text-slate-100 mb-1">Notebook Format</h3>
|
||||
<p class="text-sm text-slate-400 leading-relaxed">
|
||||
Markdown, SPICE, and Python cells in a single document.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Notebook Gallery -->
|
||||
<section id="notebooks" class="max-w-6xl mx-auto px-6 pb-20 scroll-mt-8">
|
||||
<h2 class="text-2xl font-bold text-slate-100 mb-8">Notebooks</h2>
|
||||
<NotebookGallery
|
||||
client:load
|
||||
initialNotebooks={notebooks}
|
||||
error={fetchError}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Footer CTA -->
|
||||
<footer class="border-t border-slate-800/60">
|
||||
<div class="max-w-6xl mx-auto px-6 py-16 text-center">
|
||||
<h2 class="text-2xl font-bold text-slate-100 mb-3">Ready to simulate?</h2>
|
||||
<a
|
||||
href="/notebook/new"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-blue-600 hover:bg-blue-500 text-white font-medium transition-colors text-sm mt-4"
|
||||
>
|
||||
<Icon name="lucide:plus" class="w-4 h-4" />
|
||||
New Notebook
|
||||
</a>
|
||||
<p class="text-sm text-slate-500 mt-6">
|
||||
Built on ngspice · Part of <a href="https://warehack.ing" class="text-slate-400 hover:text-slate-300 underline underline-offset-2">warehack.ing</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</NotebookLayout>
|
||||
|
||||
31
frontend/src/styles/homepage.css
Normal file
31
frontend/src/styles/homepage.css
Normal file
@ -0,0 +1,31 @@
|
||||
/* Oscilloscope graticule background for the hero section */
|
||||
.hero-graticule {
|
||||
--grid-color: rgba(51, 65, 85, 0.35);
|
||||
--grid-size: 48px;
|
||||
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-graticule::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(var(--grid-color) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-color) 1px, transparent 1px);
|
||||
background-size: var(--grid-size) var(--grid-size);
|
||||
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.5) 0%, transparent 85%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.5) 0%, transparent 85%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Horizontal scrollable filter pills */
|
||||
.filter-pills {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.filter-pills::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user