Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32a49690fa | ||
|
|
20f7b2f5aa | ||
|
|
5cb51e65be | ||
|
|
4dcd4aaa27 | ||
|
|
58320b9762 | ||
|
|
c48dffaef2 | ||
|
|
3ef4c5686e | ||
|
|
78e28789a5 | ||
|
|
03ec34bb5c |
10
CLAUDE.md
@@ -118,4 +118,14 @@ import type { ChartSeries, DataPoint } from '@cameleer/design-system'
|
||||
|
||||
// Styles (once, at app root)
|
||||
import '@cameleer/design-system/style.css'
|
||||
|
||||
// Brand assets (static files — logo variants and favicon sizes)
|
||||
import logo from '@cameleer/design-system/logo' // full resolution PNG
|
||||
import logo16 from '@cameleer/design-system/logo-16' // 16×16 favicon
|
||||
import logo32 from '@cameleer/design-system/logo-32' // 32×32 favicon
|
||||
import logo48 from '@cameleer/design-system/logo-48' // 48×48 taskbar
|
||||
import logo180 from '@cameleer/design-system/logo-180' // Apple touch icon
|
||||
import logo192 from '@cameleer/design-system/logo-192' // Android/PWA icon
|
||||
import logo512 from '@cameleer/design-system/logo-512' // PWA splash, og:image
|
||||
import logoSvg from '@cameleer/design-system/logo-svg' // SVG vector logo
|
||||
```
|
||||
|
||||
@@ -246,7 +246,7 @@ import {
|
||||
| Checkbox | primitive | Boolean input with label |
|
||||
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
|
||||
| Collapsible | primitive | Single expand/collapse section |
|
||||
| CommandPalette | composite | Full-screen search and command interface |
|
||||
| CommandPalette | composite | Full-screen search and command interface. `SearchCategory` is an open `string` type — known categories (application, exchange, attribute, route, agent) have built-in labels; custom categories render with title-cased labels and appear as dynamic tabs. |
|
||||
| ConfirmDialog | composite | Type-to-confirm destructive action dialog built on Modal. Props: open, onClose, onConfirm, title, message, confirmText, confirmLabel, cancelLabel, variant, loading, className |
|
||||
| DataTable | composite | Sortable, paginated data table with row actions. Use `flush` prop when embedded inside a container that provides its own border/radius |
|
||||
| DateRangePicker | primitive | Date range selection with presets |
|
||||
@@ -306,7 +306,7 @@ import {
|
||||
| Sidebar | Composable compound sidebar shell with icon-rail collapse mode. Sub-components: `Sidebar.Header`, `Sidebar.Section`, `Sidebar.Footer`, `Sidebar.FooterLink`. The app controls all content via children — the DS provides the frame. |
|
||||
| SidebarTree | Data-driven tree for sidebar sections. Accepts `nodes: SidebarTreeNode[]` with expand/collapse, starring, keyboard nav, search filter, and path-based selection highlighting. |
|
||||
| useStarred | Hook for localStorage-backed starred item IDs. Returns `{ starredIds, isStarred, toggleStar }`. |
|
||||
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment badge, user avatar |
|
||||
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment slot (`ReactNode` — pass a string for a static label or a custom dropdown for interactive selection), user avatar |
|
||||
|
||||
## Import Paths
|
||||
|
||||
@@ -334,6 +334,34 @@ import type { Column, DataTableProps, SearchResult } from '@cameleer/design-syst
|
||||
|
||||
See `CLAUDE.md` "Using This Design System in Other Apps" for full setup instructions.
|
||||
|
||||
## Brand Assets
|
||||
|
||||
The design system ships logo assets as static files via package exports. These are not React components — they resolve to file URLs when imported via a bundler.
|
||||
|
||||
| Export | Size | Use case |
|
||||
|--------|------|----------|
|
||||
| `@cameleer/design-system/logo` | Original | Full resolution for print/marketing |
|
||||
| `@cameleer/design-system/logo-16` | 16×16 | Browser tab favicon |
|
||||
| `@cameleer/design-system/logo-32` | 32×32 | Standard favicon, bookmarks |
|
||||
| `@cameleer/design-system/logo-48` | 48×48 | Windows taskbar |
|
||||
| `@cameleer/design-system/logo-180` | 180×180 | Apple touch icon |
|
||||
| `@cameleer/design-system/logo-192` | 192×192 | Android/PWA icon |
|
||||
| `@cameleer/design-system/logo-512` | 512×512 | PWA splash, og:image |
|
||||
| `@cameleer/design-system/logo-svg` | Vector | SVG logo for scalable usage |
|
||||
|
||||
### Usage
|
||||
|
||||
```tsx
|
||||
import logo from '@cameleer/design-system/logo-512'
|
||||
<img src={logo} alt="Cameleer3" />
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- Favicons in index.html -->
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/cameleer3-32.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/cameleer3-180.png">
|
||||
```
|
||||
|
||||
## Styling Rules
|
||||
|
||||
- **CSS Modules only** — no inline styles except dynamic values (width, color from props)
|
||||
|
||||
3
assets/camel-logo.svg
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
assets/cameleer3-16.png
Normal file
|
After Width: | Height: | Size: 983 B |
BIN
assets/cameleer3-180.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
assets/cameleer3-192.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
assets/cameleer3-32.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
assets/cameleer3-48.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
assets/cameleer3-512.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
assets/cameleer3-logo.png
Normal file
|
After Width: | Height: | Size: 374 KiB |
13
package.json
@@ -10,10 +10,19 @@
|
||||
"types": "./dist/index.es.d.ts",
|
||||
"import": "./dist/index.es.js"
|
||||
},
|
||||
"./style.css": "./dist/style.css"
|
||||
"./style.css": "./dist/style.css",
|
||||
"./logo": "./assets/cameleer3-logo.png",
|
||||
"./logo-16": "./assets/cameleer3-16.png",
|
||||
"./logo-32": "./assets/cameleer3-32.png",
|
||||
"./logo-48": "./assets/cameleer3-48.png",
|
||||
"./logo-180": "./assets/cameleer3-180.png",
|
||||
"./logo-192": "./assets/cameleer3-192.png",
|
||||
"./logo-512": "./assets/cameleer3-512.png",
|
||||
"./logo-svg": "./assets/camel-logo.svg"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"assets"
|
||||
],
|
||||
"sideEffects": [
|
||||
"*.css"
|
||||
|
||||
BIN
src/assets/cameleer3-logo.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
@@ -19,8 +19,7 @@ interface CommandPaletteProps {
|
||||
onSubmit?: (query: string) => void
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
||||
all: 'All',
|
||||
const KNOWN_CATEGORY_LABELS: Record<string, string> = {
|
||||
application: 'Applications',
|
||||
exchange: 'Exchanges',
|
||||
attribute: 'Attributes',
|
||||
@@ -28,8 +27,8 @@ const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
||||
agent: 'Agents',
|
||||
}
|
||||
|
||||
const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
|
||||
'all',
|
||||
/** Preferred display order for known categories */
|
||||
const KNOWN_CATEGORY_ORDER: string[] = [
|
||||
'application',
|
||||
'exchange',
|
||||
'attribute',
|
||||
@@ -37,6 +36,13 @@ const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
|
||||
'agent',
|
||||
]
|
||||
|
||||
function categoryLabel(cat: string): string {
|
||||
if (cat === 'all') return 'All'
|
||||
if (KNOWN_CATEGORY_LABELS[cat]) return KNOWN_CATEGORY_LABELS[cat]
|
||||
// Title-case unknown categories: "my-thing" → "My Thing", "foo_bar" → "Foo Bar"
|
||||
return cat.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function highlightText(text: string, query: string, matchRanges?: [number, number][]): ReactNode {
|
||||
if (!query && (!matchRanges || matchRanges.length === 0)) return text
|
||||
|
||||
@@ -69,7 +75,7 @@ function highlightText(text: string, query: string, matchRanges?: [number, numbe
|
||||
|
||||
export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryChange, onSubmit }: CommandPaletteProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [activeCategory, setActiveCategory] = useState<SearchCategory | 'all'>('all')
|
||||
const [activeCategory, setActiveCategory] = useState<string>('all')
|
||||
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
|
||||
const [focusedIdx, setFocusedIdx] = useState(0)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
@@ -128,7 +134,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
||||
|
||||
// Group results by category
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<SearchCategory, SearchResult[]>()
|
||||
const map = new Map<string, SearchResult[]>()
|
||||
for (const r of filtered) {
|
||||
if (!map.has(r.category)) map.set(r.category, [])
|
||||
map.get(r.category)!.push(r)
|
||||
@@ -148,6 +154,19 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
||||
return counts
|
||||
}, [queryFiltered])
|
||||
|
||||
// Build tab list dynamically: 'all' + known categories (in order) + any unknown categories found in data
|
||||
const visibleCategories = useMemo(() => {
|
||||
const dataCategories = new Set(data.map((r) => r.category))
|
||||
const tabs: string[] = ['all']
|
||||
for (const cat of KNOWN_CATEGORY_ORDER) {
|
||||
if (dataCategories.has(cat)) tabs.push(cat)
|
||||
}
|
||||
for (const cat of dataCategories) {
|
||||
if (!tabs.includes(cat)) tabs.push(cat)
|
||||
}
|
||||
return tabs
|
||||
}, [data])
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
@@ -246,7 +265,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
||||
|
||||
{/* Category tabs */}
|
||||
<div className={styles.tabs} role="tablist">
|
||||
{ALL_CATEGORIES.map((cat) => (
|
||||
{visibleCategories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
role="tab"
|
||||
@@ -262,7 +281,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
||||
setFocusedIdx(0)
|
||||
}}
|
||||
>
|
||||
{CATEGORY_LABELS[cat]}
|
||||
{categoryLabel(cat)}
|
||||
{categoryCounts[cat] != null && (
|
||||
<span className={styles.tabCount}>{categoryCounts[cat]}</span>
|
||||
)}
|
||||
@@ -283,7 +302,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
||||
Array.from(grouped.entries()).map(([category, items]) => (
|
||||
<div key={category} className={styles.group}>
|
||||
<div className={styles.groupHeader}>
|
||||
<SectionHeader>{CATEGORY_LABELS[category]}</SectionHeader>
|
||||
<SectionHeader>{categoryLabel(category)}</SectionHeader>
|
||||
</div>
|
||||
{items.map((result) => {
|
||||
const flatIdx = flatResults.indexOf(result)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export type SearchCategory = 'application' | 'exchange' | 'attribute' | 'route' | 'agent'
|
||||
/** Known categories: 'application' | 'exchange' | 'attribute' | 'route' | 'agent'. Custom categories are rendered with title-cased labels and a default icon. */
|
||||
export type SearchCategory = string
|
||||
|
||||
export interface SearchResult {
|
||||
id: string
|
||||
|
||||
@@ -153,14 +153,17 @@
|
||||
}
|
||||
|
||||
.env {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
border: 1px solid var(--success-border);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { Search, Moon, Sun, Power } from 'lucide-react'
|
||||
import styles from './TopBar.module.css'
|
||||
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
|
||||
@@ -14,7 +15,7 @@ import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider'
|
||||
|
||||
interface TopBarProps {
|
||||
breadcrumb: BreadcrumbItem[]
|
||||
environment?: string
|
||||
environment?: ReactNode
|
||||
user?: { name: string }
|
||||
onLogout?: () => void
|
||||
className?: string
|
||||
@@ -90,7 +91,7 @@ export function TopBar({
|
||||
title={globalFilters.autoRefresh ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
|
||||
>
|
||||
<span className={styles.liveDot} />
|
||||
{globalFilters.autoRefresh ? 'LIVE' : 'PAUSED'}
|
||||
{globalFilters.autoRefresh ? 'AUTO' : 'MANUAL'}
|
||||
</button>
|
||||
<button
|
||||
className={styles.themeToggle}
|
||||
@@ -102,7 +103,7 @@ export function TopBar({
|
||||
{theme === 'light' ? <Moon size={16} /> : <Sun size={16} />}
|
||||
</button>
|
||||
{environment && (
|
||||
<span className={styles.env}>{environment}</span>
|
||||
<div className={styles.env}>{environment}</div>
|
||||
)}
|
||||
{user && (
|
||||
<Dropdown
|
||||
|
||||
@@ -12,6 +12,7 @@ export type ExchangeStatus = 'completed' | 'failed' | 'running' | 'warning'
|
||||
interface GlobalFilterContextValue {
|
||||
timeRange: TimeRange
|
||||
setTimeRange: (range: TimeRange) => void
|
||||
refreshTimeRange: () => void
|
||||
statusFilters: Set<ExchangeStatus>
|
||||
toggleStatus: (status: ExchangeStatus) => void
|
||||
clearStatusFilters: () => void
|
||||
@@ -76,6 +77,14 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
||||
return () => clearInterval(id)
|
||||
}, [autoRefresh, timeRange.preset])
|
||||
|
||||
// Recompute time range from preset on demand (for manual refresh in PAUSED mode)
|
||||
const refreshTimeRange = useCallback(() => {
|
||||
if (timeRange.preset) {
|
||||
const { start, end } = computePresetRange(timeRange.preset)
|
||||
setTimeRangeState({ start, end, preset: timeRange.preset })
|
||||
}
|
||||
}, [timeRange.preset])
|
||||
|
||||
const isInTimeRange = useCallback(
|
||||
(timestamp: Date) => {
|
||||
if (timeRange.preset) {
|
||||
@@ -90,7 +99,7 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<GlobalFilterContext.Provider
|
||||
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange, autoRefresh, setAutoRefresh }}
|
||||
value={{ timeRange, setTimeRange, refreshTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange, autoRefresh, setAutoRefresh }}
|
||||
>
|
||||
{children}
|
||||
</GlobalFilterContext.Provider>
|
||||
|
||||
@@ -3,6 +3,7 @@ import styles from './Inventory.module.css'
|
||||
import { PrimitivesSection } from './sections/PrimitivesSection'
|
||||
import { CompositesSection } from './sections/CompositesSection'
|
||||
import { LayoutSection } from './sections/LayoutSection'
|
||||
import { BrandAssetsSection } from './sections/BrandAssetsSection'
|
||||
|
||||
const NAV_SECTIONS = [
|
||||
{
|
||||
@@ -93,6 +94,13 @@ const NAV_SECTIONS = [
|
||||
{ label: 'TopBar', href: '#topbar' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Brand Assets',
|
||||
href: '#brand-assets',
|
||||
components: [
|
||||
{ label: 'Cameleer3 Logo', href: '#brand-assets' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function Inventory() {
|
||||
@@ -121,6 +129,7 @@ export function Inventory() {
|
||||
<PrimitivesSection />
|
||||
<CompositesSection />
|
||||
<LayoutSection />
|
||||
<BrandAssetsSection />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
86
src/pages/Inventory/sections/BrandAssetsSection.module.css
Normal file
@@ -0,0 +1,86 @@
|
||||
.section {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.componentCard {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.componentTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.componentDesc {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.demoArea {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.logoGrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.logoItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.logoPreview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-body);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.logoPreview img {
|
||||
display: block;
|
||||
image-rendering: auto;
|
||||
}
|
||||
|
||||
.logoLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.logoExport {
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-body);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
69
src/pages/Inventory/sections/BrandAssetsSection.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import styles from './BrandAssetsSection.module.css'
|
||||
import camelLogoSvg from '../../../assets/camel-logo.svg'
|
||||
import cameleer3Logo from '../../../assets/cameleer3-logo.png'
|
||||
|
||||
const LOGO_SIZES = [16, 32, 48, 180, 192, 512] as const
|
||||
|
||||
export function BrandAssetsSection() {
|
||||
return (
|
||||
<section id="brand-assets" className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Brand Assets</h2>
|
||||
|
||||
<div className={styles.componentCard}>
|
||||
<h3 className={styles.componentTitle}>Cameleer3 Logo (PNG)</h3>
|
||||
<p className={styles.componentDesc}>
|
||||
Full-resolution logo and pre-generated size variants for favicons, PWA icons, and social images.
|
||||
Shipped as static assets via package exports.
|
||||
</p>
|
||||
<div className={styles.demoArea}>
|
||||
<div className={styles.logoGrid}>
|
||||
{LOGO_SIZES.map((size) => (
|
||||
<div key={size} className={styles.logoItem}>
|
||||
<div className={styles.logoPreview}>
|
||||
<img
|
||||
src={cameleer3Logo}
|
||||
alt={`Logo ${size}×${size}`}
|
||||
width={Math.min(size, 96)}
|
||||
height={Math.min(size, 96)}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.logoLabel}>{size}×{size}</span>
|
||||
<code className={styles.logoExport}>@cameleer/design-system/logo-{size}</code>
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.logoItem}>
|
||||
<div className={styles.logoPreview}>
|
||||
<img
|
||||
src={cameleer3Logo}
|
||||
alt="Full resolution logo"
|
||||
width={96}
|
||||
height={96}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.logoLabel}>Original</span>
|
||||
<code className={styles.logoExport}>@cameleer/design-system/logo</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.componentCard}>
|
||||
<h3 className={styles.componentTitle}>Camel Logo (SVG)</h3>
|
||||
<p className={styles.componentDesc}>
|
||||
Vector logo for scalable usage. Shipped as a static asset via package export.
|
||||
</p>
|
||||
<div className={styles.demoArea}>
|
||||
<div className={styles.logoGrid}>
|
||||
<div className={styles.logoItem}>
|
||||
<div className={styles.logoPreview}>
|
||||
<img src={camelLogoSvg} alt="Camel SVG logo" width={96} height={96} />
|
||||
</div>
|
||||
<span className={styles.logoLabel}>SVG</span>
|
||||
<code className={styles.logoExport}>@cameleer/design-system/logo-svg</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||