redesign(examples): compact toolbar + denser grid + fix card black bars
The examples page showed only ~3 cards per row (the grid was capped at max-width 1200px with minmax(300px) columns), a tall header, and three stacked filter rows (16 board tabs + category + difficulty) that pushed the actual examples far down the page. Card thumbnails also letterboxed with black bars on the sides. - Grid: widen to 1680px + minmax(232px) columns → ~6 cols on a 1600px screen (was 3). Cards smaller/denser (radius, info padding, title). - Thumbnails: fixed 5:3 aspect-ratio container + object-fit:cover so the preview fills edge to edge — no more black side bars. - Filters: replace the search row + 16 board tabs + two button rows with ONE compact toolbar (search + Board/Category/Difficulty dropdowns + live count) and removable filter chips (badges with ×) + Clear all. - Header trimmed (smaller title, less margin) so cards start high.
This commit is contained in:
parent
5fc57c264d
commit
fcfe47eab5
|
|
@ -2,37 +2,139 @@
|
|||
* Examples Gallery — Apple Dark Design System
|
||||
*/
|
||||
|
||||
/* Shared content width — wide enough to show many example columns on big
|
||||
screens while staying readable. Was 1200px (capped the grid at ~3 cols). */
|
||||
.examples-gallery {
|
||||
--examples-max: 1680px;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
padding: 52px 40px;
|
||||
padding: 28px 32px 48px;
|
||||
font-family: var(--font);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.examples-header {
|
||||
text-align: center;
|
||||
margin-bottom: 52px;
|
||||
max-width: 1200px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin: 0 auto 20px auto;
|
||||
max-width: var(--examples-max);
|
||||
}
|
||||
|
||||
.examples-header h1 {
|
||||
font-size: clamp(32px, 5vw, 52px);
|
||||
margin: 0 0 14px 0;
|
||||
font-size: clamp(26px, 3.4vw, 40px);
|
||||
margin: 0 0 6px 0;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1.5px;
|
||||
letter-spacing: -1px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.examples-header p {
|
||||
font-size: 17px;
|
||||
font-size: 15px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
line-height: 1.65;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Compact toolbar (search + dropdowns) ─────────────────────────────── */
|
||||
.examples-toolbar {
|
||||
max-width: var(--examples-max);
|
||||
margin: 0 auto 12px auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.examples-select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
padding: 9px 30px 9px 14px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
/* chevron */
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
}
|
||||
|
||||
.examples-select:hover {
|
||||
background-color: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.examples-select:focus {
|
||||
border-color: var(--accent, var(--color-action-primary));
|
||||
}
|
||||
|
||||
.examples-count {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ── Active-filter chips (removable badges) ───────────────────────────── */
|
||||
.examples-chips {
|
||||
max-width: var(--examples-max);
|
||||
margin: 0 auto 18px auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px 4px 12px;
|
||||
background: color-mix(in srgb, var(--accent, #007acc) 18%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent, #007acc) 45%, transparent);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filter-chip-x {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: var(--text);
|
||||
border-radius: 50%;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.filter-chip-x:hover {
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
|
||||
.filter-chip-clear-all {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.filter-chip-clear-all:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Board tabs */
|
||||
|
|
@ -143,46 +245,50 @@
|
|||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Examples Grid */
|
||||
/* Examples Grid — denser: smaller min column so more cards fit per row
|
||||
(was minmax(300px) capped at 1200px → ~3 cols). */
|
||||
.examples-grid {
|
||||
max-width: 1200px;
|
||||
max-width: var(--examples-max);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(232px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.example-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 20px;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.3s cubic-bezier(0.34, 1.46, 0.64, 1),
|
||||
box-shadow 0.3s ease;
|
||||
transform 0.2s cubic-bezier(0.34, 1.46, 0.64, 1),
|
||||
box-shadow 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.example-card:hover {
|
||||
transform: scale(1.025);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.example-thumbnail {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
background: #0a0a0c;
|
||||
aspect-ratio: 5 / 3;
|
||||
background: var(--bg-elevated, #17171a);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Fill the whole thumbnail — `contain` left black bars on the sides of the
|
||||
card (the 300×180 preview is wider than the denser cards). `cover` crops a
|
||||
little instead, which reads far cleaner. */
|
||||
.example-thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.example-preview-image {
|
||||
|
|
@ -221,19 +327,20 @@
|
|||
}
|
||||
|
||||
.example-info {
|
||||
padding: 20px 20px 18px;
|
||||
padding: 12px 14px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.example-title {
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.3px;
|
||||
letter-spacing: -0.2px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.example-description {
|
||||
|
|
@ -292,8 +399,8 @@
|
|||
}
|
||||
|
||||
.examples-empty {
|
||||
max-width: 1200px;
|
||||
margin: 80px auto;
|
||||
max-width: var(--examples-max);
|
||||
margin: 60px auto;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 15px;
|
||||
|
|
@ -329,7 +436,8 @@
|
|||
}
|
||||
|
||||
.examples-search-wrap {
|
||||
flex: 1;
|
||||
flex: 1 1 260px;
|
||||
min-width: 220px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -423,19 +531,23 @@
|
|||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.examples-gallery {
|
||||
padding: 32px 20px;
|
||||
padding: 20px 16px 32px;
|
||||
}
|
||||
|
||||
.examples-grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
.examples-toolbar {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
min-width: auto;
|
||||
.examples-search-wrap {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.examples-count {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -220,6 +220,58 @@ export const ExamplesGallery: React.FC<ExamplesGalleryProps> = ({ onLoadExample
|
|||
return { label: tab.label, color: tab.color, bg: tab.bg };
|
||||
};
|
||||
|
||||
const CATEGORIES = [
|
||||
'all',
|
||||
'basics',
|
||||
'sensors',
|
||||
'displays',
|
||||
'communication',
|
||||
'games',
|
||||
'robotics',
|
||||
'circuits',
|
||||
] as const;
|
||||
const DIFFICULTIES = ['all', 'beginner', 'intermediate', 'advanced'] as const;
|
||||
|
||||
// Active filters rendered as removable chips (the compact replacement for
|
||||
// the old three rows of always-visible filter buttons). Each chip clears
|
||||
// its own filter; "Clear all" resets everything.
|
||||
const boardTabForChip = BOARD_TABS.find((tb) => tb.id === selectedBoard);
|
||||
const activeChips: { key: string; label: string; onClear: () => void }[] = [];
|
||||
if (selectedBoard !== 'all' && boardTabForChip) {
|
||||
activeChips.push({
|
||||
key: 'board',
|
||||
label: boardTabForChip.label,
|
||||
onClear: () => setSelectedBoard('all'),
|
||||
});
|
||||
}
|
||||
if (selectedCategory !== 'all') {
|
||||
activeChips.push({
|
||||
key: 'cat',
|
||||
label: t(`examples.filters.category.${selectedCategory}`),
|
||||
onClear: () => setSelectedCategory('all'),
|
||||
});
|
||||
}
|
||||
if (selectedDifficulty !== 'all') {
|
||||
activeChips.push({
|
||||
key: 'diff',
|
||||
label: t(`examples.filters.difficulty.${selectedDifficulty}`),
|
||||
onClear: () => setSelectedDifficulty('all'),
|
||||
});
|
||||
}
|
||||
if (search.trim()) {
|
||||
activeChips.push({
|
||||
key: 'search',
|
||||
label: `“${search.trim()}”`,
|
||||
onClear: () => setSearch(''),
|
||||
});
|
||||
}
|
||||
const resetAll = () => {
|
||||
setSearch('');
|
||||
setSelectedBoard('all');
|
||||
setSelectedCategory('all');
|
||||
setSelectedDifficulty('all');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="examples-gallery">
|
||||
<div className="examples-header">
|
||||
|
|
@ -227,8 +279,9 @@ export const ExamplesGallery: React.FC<ExamplesGalleryProps> = ({ onLoadExample
|
|||
<p>{t('examples.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="examples-search">
|
||||
{/* Compact toolbar: search + three dropdowns + live count.
|
||||
Replaces the old search row + 16 board tabs + two filter rows. */}
|
||||
<div className="examples-toolbar">
|
||||
<div className="examples-search-wrap">
|
||||
<svg
|
||||
className="examples-search-icon"
|
||||
|
|
@ -265,78 +318,76 @@ export const ExamplesGallery: React.FC<ExamplesGalleryProps> = ({ onLoadExample
|
|||
</button>
|
||||
)}
|
||||
</div>
|
||||
{searchTokens.length > 0 && (
|
||||
<span className="examples-search-count">
|
||||
{t('examples.matchCount', { count: filteredExamples.length })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<select
|
||||
className="examples-select"
|
||||
value={selectedBoard}
|
||||
onChange={(e) => setSelectedBoard(e.target.value)}
|
||||
aria-label={t('examples.filters.boardLabel', 'Board')}
|
||||
>
|
||||
{BOARD_TABS.map((tab) => (
|
||||
<option key={tab.id} value={tab.id}>
|
||||
{tab.id === 'all' ? t('examples.filters.allBoards', 'All boards') : tab.label}
|
||||
{boardCounts[tab.id] != null ? ` (${boardCounts[tab.id]})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="examples-select"
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value as typeof selectedCategory)}
|
||||
aria-label={t('examples.filters.categoryLabel')}
|
||||
>
|
||||
{CATEGORIES.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{t(`examples.filters.category.${cat}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="examples-select"
|
||||
value={selectedDifficulty}
|
||||
onChange={(e) => setSelectedDifficulty(e.target.value as typeof selectedDifficulty)}
|
||||
aria-label={t('examples.filters.difficultyLabel')}
|
||||
>
|
||||
{DIFFICULTIES.map((diff) => (
|
||||
<option key={diff} value={diff}>
|
||||
{t(`examples.filters.difficulty.${diff}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<span className="examples-count">
|
||||
{t('examples.matchCount', { count: filteredExamples.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Board tabs */}
|
||||
<div className="examples-board-tabs">
|
||||
{BOARD_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`board-tab ${selectedBoard === tab.id ? 'active' : ''}`}
|
||||
style={
|
||||
selectedBoard === tab.id
|
||||
? { backgroundColor: tab.bg, color: tab.color, borderColor: tab.bg }
|
||||
: {}
|
||||
}
|
||||
onClick={() => setSelectedBoard(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
{boardCounts[tab.id] != null && (
|
||||
<span className="board-tab-count">{boardCounts[tab.id]}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Category + Difficulty filters */}
|
||||
<div className="examples-filters">
|
||||
<div className="filter-group">
|
||||
<label>{t('examples.filters.categoryLabel')}</label>
|
||||
<div className="filter-buttons">
|
||||
{(
|
||||
[
|
||||
'all',
|
||||
'basics',
|
||||
'sensors',
|
||||
'displays',
|
||||
'communication',
|
||||
'games',
|
||||
'robotics',
|
||||
'circuits',
|
||||
] as const
|
||||
).map((cat) => (
|
||||
{/* Active-filter chips (removable badges). */}
|
||||
{activeChips.length > 0 && (
|
||||
<div className="examples-chips">
|
||||
{activeChips.map((chip) => (
|
||||
<span key={chip.key} className="filter-chip">
|
||||
{chip.label}
|
||||
<button
|
||||
key={cat}
|
||||
className={`filter-button ${selectedCategory === cat ? 'active' : ''}`}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
type="button"
|
||||
className="filter-chip-x"
|
||||
onClick={chip.onClear}
|
||||
aria-label={t('examples.clear')}
|
||||
title={t('examples.clear')}
|
||||
>
|
||||
{cat !== 'all' && getCategoryIcon(cat as ExampleProject['category'])}{' '}
|
||||
{t(`examples.filters.category.${cat}`)}
|
||||
×
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</span>
|
||||
))}
|
||||
{activeChips.length > 1 && (
|
||||
<button type="button" className="filter-chip-clear-all" onClick={resetAll}>
|
||||
{t('examples.resetFilters')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label>{t('examples.filters.difficultyLabel')}</label>
|
||||
<div className="filter-buttons">
|
||||
{(['all', 'beginner', 'intermediate', 'advanced'] as const).map((diff) => (
|
||||
<button
|
||||
key={diff}
|
||||
className={`filter-button ${selectedDifficulty === diff ? 'active' : ''}`}
|
||||
onClick={() => setSelectedDifficulty(diff)}
|
||||
>
|
||||
{t(`examples.filters.difficulty.${diff}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Examples Grid */}
|
||||
<div className="examples-grid">
|
||||
|
|
|
|||
Loading…
Reference in New Issue