2026-03-04 08:25:52 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* Examples Gallery Component
|
|
|
|
|
|
*
|
|
|
|
|
|
* Displays a gallery of example Arduino projects that users can load and run
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2026-03-31 05:00:33 +07:00
|
|
|
|
import React, { useState, useCallback } from 'react';
|
2026-05-09 21:24:52 +07:00
|
|
|
|
import { useTranslation } from 'react-i18next';
|
2026-03-16 09:15:38 +07:00
|
|
|
|
import { exampleProjects, type ExampleProject } from '../../data/examples';
|
2026-05-08 03:44:10 +07:00
|
|
|
|
import { ExampleThumbnail } from './ExampleThumbnail';
|
2026-03-04 08:25:52 +07:00
|
|
|
|
import './ExamplesGallery.css';
|
|
|
|
|
|
|
|
|
|
|
|
interface ExamplesGalleryProps {
|
|
|
|
|
|
onLoadExample: (example: ExampleProject) => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 09:15:38 +07:00
|
|
|
|
// ── Board config ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
interface BoardTab {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
color: string;
|
|
|
|
|
|
bg: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const BOARD_TABS: BoardTab[] = [
|
2026-04-22 02:45:45 +07:00
|
|
|
|
{ id: 'all', label: 'All', color: '#ffffff', bg: '#444444' },
|
2026-06-05 03:08:43 +07:00
|
|
|
|
{ id: 'retro', label: 'Retro', color: '#1a1a1a', bg: '#e0a82e' },
|
2026-04-22 02:45:45 +07:00
|
|
|
|
{ id: 'arduino-uno', label: 'Arduino Uno', color: '#ffffff', bg: '#007acc' },
|
|
|
|
|
|
{ id: 'arduino-nano', label: 'Arduino Nano', color: '#ffffff', bg: '#0055aa' },
|
|
|
|
|
|
{ id: 'arduino-mega', label: 'Arduino Mega', color: '#ffffff', bg: '#003388' },
|
|
|
|
|
|
{ id: 'raspberry-pi-pico', label: 'Pico', color: '#ffffff', bg: '#c11c31' },
|
2026-04-29 12:33:59 +07:00
|
|
|
|
{ id: 'pi-pico-w', label: 'Pico W (Wi-Fi)', color: '#ffffff', bg: '#8c0e1e' },
|
2026-04-22 02:45:45 +07:00
|
|
|
|
{ id: 'esp32', label: 'ESP32 (Xtensa)', color: '#ffffff', bg: '#e77d11' },
|
2026-05-03 08:21:19 +07:00
|
|
|
|
{ id: 'esp32-cam', label: 'ESP32-CAM', color: '#ffffff', bg: '#d35400' },
|
2026-04-22 02:45:45 +07:00
|
|
|
|
{ id: 'esp32-c3', label: 'ESP32-C3 (RISC-V)', color: '#ffffff', bg: '#27ae60' },
|
2026-05-31 12:54:09 +07:00
|
|
|
|
{ id: 'stm32-bluepill', label: 'STM32 Blue Pill', color: '#ffffff', bg: '#0a7ea4' },
|
|
|
|
|
|
{ id: 'stm32-blackpill', label: 'STM32 Black Pill', color: '#ffffff', bg: '#2d3436' },
|
2026-04-22 03:14:27 +07:00
|
|
|
|
{ id: 'attiny85', label: 'ATtiny85', color: '#ffffff', bg: '#5d4037' },
|
2026-04-22 02:45:45 +07:00
|
|
|
|
{ id: 'multi', label: 'Multi-Board', color: '#ffffff', bg: '#7b2d8b' },
|
|
|
|
|
|
{ id: 'analog', label: 'Analog', color: '#ffffff', bg: '#0ea5a5' },
|
2026-05-13 00:26:33 +07:00
|
|
|
|
{ id: 'digital', label: 'Digital', color: '#ffffff', bg: '#6366f1' },
|
2026-03-16 09:15:38 +07:00
|
|
|
|
];
|
2026-03-04 08:25:52 +07:00
|
|
|
|
|
2026-06-05 03:08:43 +07:00
|
|
|
|
/** The "Retro" tab is tag-based (not board-kind based) so it can collect the
|
|
|
|
|
|
* Z80 / Intel / vintage-CPU examples regardless of their board filter. They
|
|
|
|
|
|
* also still appear under their board tab (Digital), so this is additive. */
|
|
|
|
|
|
function isRetro(example: ExampleProject): boolean {
|
|
|
|
|
|
return (example.tags ?? []).includes('retro');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 09:15:38 +07:00
|
|
|
|
function getBoardFilter(example: ExampleProject): string {
|
2026-05-31 12:54:09 +07:00
|
|
|
|
// An explicit boardFilter is the author's intent and wins — even for
|
|
|
|
|
|
// examples authored with the multi-board `boards[]` format (e.g. the STM32
|
|
|
|
|
|
// single-board examples). Genuinely multi-board examples with no boardFilter
|
|
|
|
|
|
// still fall through to 'multi'.
|
2026-03-16 09:15:38 +07:00
|
|
|
|
if ((example as any).boardFilter) return (example as any).boardFilter;
|
2026-05-31 12:54:09 +07:00
|
|
|
|
if (example.boards) return 'multi';
|
2026-03-16 09:15:38 +07:00
|
|
|
|
return example.boardType ?? 'arduino-uno';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const ExamplesGallery: React.FC<ExamplesGalleryProps> = ({ onLoadExample }) => {
|
2026-05-09 21:24:52 +07:00
|
|
|
|
const { t } = useTranslation();
|
2026-03-16 09:15:38 +07:00
|
|
|
|
const [selectedBoard, setSelectedBoard] = useState<string>('all');
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const [selectedCategory, setSelectedCategory] = useState<ExampleProject['category'] | 'all'>(
|
|
|
|
|
|
'all',
|
|
|
|
|
|
);
|
|
|
|
|
|
const [selectedDifficulty, setSelectedDifficulty] = useState<
|
|
|
|
|
|
ExampleProject['difficulty'] | 'all'
|
|
|
|
|
|
>('all');
|
2026-03-31 05:00:33 +07:00
|
|
|
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
2026-04-29 05:24:39 +07:00
|
|
|
|
const [search, setSearch] = useState<string>('');
|
2026-03-31 05:00:33 +07:00
|
|
|
|
|
|
|
|
|
|
const handleCopyLink = useCallback((e: React.MouseEvent, exampleId: string) => {
|
|
|
|
|
|
e.stopPropagation(); // Don't trigger card click
|
|
|
|
|
|
const url = `${window.location.origin}/examples/${exampleId}`;
|
|
|
|
|
|
navigator.clipboard.writeText(url).then(() => {
|
|
|
|
|
|
setCopiedId(exampleId);
|
|
|
|
|
|
setTimeout(() => setCopiedId(null), 2000);
|
|
|
|
|
|
});
|
|
|
|
|
|
}, []);
|
2026-03-04 08:25:52 +07:00
|
|
|
|
|
2026-04-29 05:24:39 +07:00
|
|
|
|
// Pre-tokenise the search string once per keystroke. Each token must match
|
|
|
|
|
|
// somewhere in the example's haystack, so users can type "esp32 oled dht"
|
|
|
|
|
|
// and find every project that hits all three.
|
|
|
|
|
|
const searchTokens = search
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
|
.split(/\s+/)
|
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
|
|
|
|
|
|
const exampleHaystack = (example: ExampleProject): string =>
|
|
|
|
|
|
[
|
|
|
|
|
|
example.title,
|
|
|
|
|
|
example.description,
|
|
|
|
|
|
example.category,
|
|
|
|
|
|
example.difficulty,
|
|
|
|
|
|
getBoardFilter(example),
|
|
|
|
|
|
...(example.tags ?? []),
|
|
|
|
|
|
...example.components.map((c) => c.type),
|
|
|
|
|
|
]
|
|
|
|
|
|
.join(' ')
|
|
|
|
|
|
.toLowerCase();
|
|
|
|
|
|
|
2026-03-04 08:25:52 +07:00
|
|
|
|
const filteredExamples = exampleProjects.filter((example) => {
|
2026-06-05 03:08:43 +07:00
|
|
|
|
const boardMatch =
|
|
|
|
|
|
selectedBoard === 'all' ||
|
|
|
|
|
|
(selectedBoard === 'retro' ? isRetro(example) : getBoardFilter(example) === selectedBoard);
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const catMatch = selectedCategory === 'all' || example.category === selectedCategory;
|
|
|
|
|
|
const diffMatch = selectedDifficulty === 'all' || example.difficulty === selectedDifficulty;
|
2026-04-29 05:24:39 +07:00
|
|
|
|
if (!boardMatch || !catMatch || !diffMatch) return false;
|
|
|
|
|
|
if (searchTokens.length === 0) return true;
|
|
|
|
|
|
const hay = exampleHaystack(example);
|
|
|
|
|
|
return searchTokens.every((tok) => hay.includes(tok));
|
2026-05-31 13:26:16 +07:00
|
|
|
|
})
|
|
|
|
|
|
.sort((a, b) => {
|
|
|
|
|
|
// Order by board following the BOARD_TABS order (so Arduino Uno comes
|
|
|
|
|
|
// first), then alphabetically by title within each board.
|
|
|
|
|
|
const ra = BOARD_TABS.findIndex((t) => t.id === getBoardFilter(a));
|
|
|
|
|
|
const rb = BOARD_TABS.findIndex((t) => t.id === getBoardFilter(b));
|
|
|
|
|
|
if (ra !== rb) return (ra < 0 ? 999 : ra) - (rb < 0 ? 999 : rb);
|
|
|
|
|
|
return (a.title ?? '').localeCompare(b.title ?? '');
|
|
|
|
|
|
});
|
2026-03-16 09:15:38 +07:00
|
|
|
|
|
|
|
|
|
|
// Count per board for tab badges
|
2026-06-05 03:08:43 +07:00
|
|
|
|
const boardCounts: Record<string, number> = {
|
|
|
|
|
|
all: exampleProjects.length,
|
|
|
|
|
|
retro: exampleProjects.filter(isRetro).length,
|
|
|
|
|
|
};
|
2026-03-16 09:15:38 +07:00
|
|
|
|
exampleProjects.forEach((ex) => {
|
|
|
|
|
|
const b = getBoardFilter(ex);
|
|
|
|
|
|
boardCounts[b] = (boardCounts[b] ?? 0) + 1;
|
2026-03-04 08:25:52 +07:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-05 08:05:23 +07:00
|
|
|
|
const getCategoryIcon = (category: ExampleProject['category']): React.ReactNode => {
|
|
|
|
|
|
const svgProps = {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
width: 16,
|
|
|
|
|
|
height: 16,
|
|
|
|
|
|
viewBox: '0 0 24 24',
|
|
|
|
|
|
fill: 'none',
|
|
|
|
|
|
stroke: 'currentColor',
|
|
|
|
|
|
strokeWidth: 2,
|
|
|
|
|
|
strokeLinecap: 'round' as const,
|
|
|
|
|
|
strokeLinejoin: 'round' as const,
|
2026-03-05 08:05:23 +07:00
|
|
|
|
style: { display: 'inline-block', verticalAlign: 'middle', flexShrink: 0 },
|
|
|
|
|
|
};
|
|
|
|
|
|
const icons: Record<ExampleProject['category'], React.ReactNode> = {
|
|
|
|
|
|
basics: (
|
|
|
|
|
|
<svg {...svgProps}>
|
|
|
|
|
|
<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5" />
|
2026-04-22 02:45:45 +07:00
|
|
|
|
<path d="M9 18h6" />
|
|
|
|
|
|
<path d="M10 22h4" />
|
2026-03-05 08:05:23 +07:00
|
|
|
|
</svg>
|
|
|
|
|
|
),
|
|
|
|
|
|
sensors: (
|
|
|
|
|
|
<svg {...svgProps}>
|
|
|
|
|
|
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9" />
|
|
|
|
|
|
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5" />
|
|
|
|
|
|
<circle cx="12" cy="12" r="2" />
|
|
|
|
|
|
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5" />
|
|
|
|
|
|
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
),
|
|
|
|
|
|
displays: (
|
|
|
|
|
|
<svg {...svgProps}>
|
|
|
|
|
|
<rect x="2" y="3" width="20" height="14" rx="2" />
|
|
|
|
|
|
<line x1="8" y1="21" x2="16" y2="21" />
|
|
|
|
|
|
<line x1="12" y1="17" x2="12" y2="21" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
),
|
|
|
|
|
|
communication: (
|
|
|
|
|
|
<svg {...svgProps}>
|
|
|
|
|
|
<path d="M5 12.55a11 11 0 0 1 14.08 0" />
|
|
|
|
|
|
<path d="M1.42 9a16 16 0 0 1 21.16 0" />
|
|
|
|
|
|
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
|
|
|
|
|
|
<circle cx="12" cy="20" r="1" fill="currentColor" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
),
|
|
|
|
|
|
games: (
|
|
|
|
|
|
<svg {...svgProps}>
|
|
|
|
|
|
<line x1="6" y1="11" x2="10" y2="11" />
|
|
|
|
|
|
<line x1="8" y1="9" x2="8" y2="13" />
|
|
|
|
|
|
<circle cx="15" cy="12" r="1" fill="currentColor" />
|
|
|
|
|
|
<circle cx="17" cy="10" r="1" fill="currentColor" />
|
|
|
|
|
|
<path d="M17 2H7a5 5 0 0 0-5 5v4.4A2.9 2.9 0 0 0 4.8 14l1.5 2.7A3 3 0 0 0 9 18h6a3 3 0 0 0 2.7-1.3l1.5-2.7a2.9 2.9 0 0 0 .3-1.3V7a5 5 0 0 0-5-5Z" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
),
|
|
|
|
|
|
robotics: (
|
|
|
|
|
|
<svg {...svgProps}>
|
|
|
|
|
|
<path d="M12 8V4H8" />
|
|
|
|
|
|
<rect width="16" height="12" x="4" y="8" rx="2" />
|
2026-04-22 02:45:45 +07:00
|
|
|
|
<path d="M2 14h2" />
|
|
|
|
|
|
<path d="M20 14h2" />
|
|
|
|
|
|
<path d="M15 13v2" />
|
|
|
|
|
|
<path d="M9 13v2" />
|
2026-03-05 08:05:23 +07:00
|
|
|
|
</svg>
|
|
|
|
|
|
),
|
2026-04-16 18:39:47 +07:00
|
|
|
|
circuits: (
|
|
|
|
|
|
<svg {...svgProps}>
|
|
|
|
|
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
),
|
2026-03-04 08:25:52 +07:00
|
|
|
|
};
|
|
|
|
|
|
return icons[category];
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const getDifficultyColor = (difficulty: ExampleProject['difficulty']): string =>
|
|
|
|
|
|
({
|
|
|
|
|
|
beginner: '#4ade80',
|
|
|
|
|
|
intermediate: '#fbbf24',
|
|
|
|
|
|
advanced: '#f87171',
|
|
|
|
|
|
})[difficulty];
|
2026-03-16 09:15:38 +07:00
|
|
|
|
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const getBoardBadge = (
|
|
|
|
|
|
example: ExampleProject,
|
|
|
|
|
|
): { label: string; color: string; bg: string } | null => {
|
2026-03-16 09:15:38 +07:00
|
|
|
|
const bf = getBoardFilter(example);
|
|
|
|
|
|
const tab = BOARD_TABS.find((t) => t.id === bf);
|
|
|
|
|
|
if (!tab || tab.id === 'all') return null;
|
|
|
|
|
|
return { label: tab.label, color: tab.color, bg: tab.bg };
|
2026-03-04 08:25:52 +07:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="examples-gallery">
|
|
|
|
|
|
<div className="examples-header">
|
2026-05-09 21:24:52 +07:00
|
|
|
|
<h1>{t('examples.heading')}</h1>
|
|
|
|
|
|
<p>{t('examples.subtitle')}</p>
|
2026-03-04 08:25:52 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-29 05:24:39 +07:00
|
|
|
|
{/* Search */}
|
|
|
|
|
|
<div className="examples-search">
|
|
|
|
|
|
<div className="examples-search-wrap">
|
|
|
|
|
|
<svg
|
|
|
|
|
|
className="examples-search-icon"
|
|
|
|
|
|
width="18"
|
|
|
|
|
|
height="18"
|
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
|
fill="none"
|
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
|
strokeWidth="2"
|
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
>
|
|
|
|
|
|
<circle cx="11" cy="11" r="7" />
|
|
|
|
|
|
<path d="m20 20-3.5-3.5" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="search"
|
|
|
|
|
|
className="examples-search-input"
|
2026-05-09 21:24:52 +07:00
|
|
|
|
placeholder={t('examples.searchPlaceholder')}
|
2026-04-29 05:24:39 +07:00
|
|
|
|
value={search}
|
|
|
|
|
|
onChange={(e) => setSearch(e.target.value)}
|
2026-05-09 21:24:52 +07:00
|
|
|
|
aria-label={t('examples.searchLabel')}
|
2026-04-29 05:24:39 +07:00
|
|
|
|
/>
|
|
|
|
|
|
{search && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="examples-search-clear"
|
|
|
|
|
|
onClick={() => setSearch('')}
|
2026-05-09 21:24:52 +07:00
|
|
|
|
aria-label={t('examples.clearSearch')}
|
|
|
|
|
|
title={t('examples.clear')}
|
2026-04-29 05:24:39 +07:00
|
|
|
|
>
|
|
|
|
|
|
×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{searchTokens.length > 0 && (
|
|
|
|
|
|
<span className="examples-search-count">
|
2026-05-09 21:24:52 +07:00
|
|
|
|
{t('examples.matchCount', { count: filteredExamples.length })}
|
2026-04-29 05:24:39 +07:00
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-16 09:15:38 +07:00
|
|
|
|
{/* Board tabs */}
|
|
|
|
|
|
<div className="examples-board-tabs">
|
|
|
|
|
|
{BOARD_TABS.map((tab) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={tab.id}
|
|
|
|
|
|
className={`board-tab ${selectedBoard === tab.id ? 'active' : ''}`}
|
2026-04-22 02:45:45 +07:00
|
|
|
|
style={
|
|
|
|
|
|
selectedBoard === tab.id
|
|
|
|
|
|
? { backgroundColor: tab.bg, color: tab.color, borderColor: tab.bg }
|
|
|
|
|
|
: {}
|
|
|
|
|
|
}
|
2026-03-16 09:15:38 +07:00
|
|
|
|
onClick={() => setSelectedBoard(tab.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{tab.label}
|
|
|
|
|
|
{boardCounts[tab.id] != null && (
|
|
|
|
|
|
<span className="board-tab-count">{boardCounts[tab.id]}</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Category + Difficulty filters */}
|
2026-03-04 08:25:52 +07:00
|
|
|
|
<div className="examples-filters">
|
|
|
|
|
|
<div className="filter-group">
|
2026-05-09 21:24:52 +07:00
|
|
|
|
<label>{t('examples.filters.categoryLabel')}</label>
|
2026-03-04 08:25:52 +07:00
|
|
|
|
<div className="filter-buttons">
|
2026-04-22 02:45:45 +07:00
|
|
|
|
{(
|
|
|
|
|
|
[
|
|
|
|
|
|
'all',
|
|
|
|
|
|
'basics',
|
|
|
|
|
|
'sensors',
|
|
|
|
|
|
'displays',
|
|
|
|
|
|
'communication',
|
|
|
|
|
|
'games',
|
|
|
|
|
|
'robotics',
|
|
|
|
|
|
'circuits',
|
|
|
|
|
|
] as const
|
|
|
|
|
|
).map((cat) => (
|
2026-03-04 08:25:52 +07:00
|
|
|
|
<button
|
2026-03-16 09:15:38 +07:00
|
|
|
|
key={cat}
|
|
|
|
|
|
className={`filter-button ${selectedCategory === cat ? 'active' : ''}`}
|
|
|
|
|
|
onClick={() => setSelectedCategory(cat)}
|
2026-03-04 08:25:52 +07:00
|
|
|
|
>
|
2026-04-22 02:45:45 +07:00
|
|
|
|
{cat !== 'all' && getCategoryIcon(cat as ExampleProject['category'])}{' '}
|
2026-05-09 21:24:52 +07:00
|
|
|
|
{t(`examples.filters.category.${cat}`)}
|
2026-03-04 08:25:52 +07:00
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="filter-group">
|
2026-05-09 21:24:52 +07:00
|
|
|
|
<label>{t('examples.filters.difficultyLabel')}</label>
|
2026-03-04 08:25:52 +07:00
|
|
|
|
<div className="filter-buttons">
|
2026-03-16 09:15:38 +07:00
|
|
|
|
{(['all', 'beginner', 'intermediate', 'advanced'] as const).map((diff) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={diff}
|
|
|
|
|
|
className={`filter-button ${selectedDifficulty === diff ? 'active' : ''}`}
|
|
|
|
|
|
onClick={() => setSelectedDifficulty(diff)}
|
|
|
|
|
|
>
|
2026-05-09 21:24:52 +07:00
|
|
|
|
{t(`examples.filters.difficulty.${diff}`)}
|
2026-03-16 09:15:38 +07:00
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
2026-03-04 08:25:52 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Examples Grid */}
|
|
|
|
|
|
<div className="examples-grid">
|
2026-03-16 09:15:38 +07:00
|
|
|
|
{filteredExamples.map((example) => {
|
|
|
|
|
|
const boardBadge = getBoardBadge(example);
|
|
|
|
|
|
return (
|
2026-04-22 02:45:45 +07:00
|
|
|
|
<div key={example.id} className="example-card" onClick={() => onLoadExample(example)}>
|
2026-03-16 09:15:38 +07:00
|
|
|
|
<div className="example-thumbnail">
|
2026-05-08 03:44:10 +07:00
|
|
|
|
<ExampleThumbnail
|
|
|
|
|
|
example={example}
|
|
|
|
|
|
width={300}
|
|
|
|
|
|
height={180}
|
|
|
|
|
|
background="#0a0a0c"
|
|
|
|
|
|
style={{ width: '100%', height: '100%' }}
|
|
|
|
|
|
/>
|
2026-03-16 09:15:38 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="example-info">
|
|
|
|
|
|
<h3 className="example-title">{example.title}</h3>
|
|
|
|
|
|
<p className="example-description">{example.description}</p>
|
|
|
|
|
|
<div className="example-meta">
|
|
|
|
|
|
<span
|
|
|
|
|
|
className="example-difficulty"
|
|
|
|
|
|
style={{ backgroundColor: getDifficultyColor(example.difficulty) }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{example.difficulty}
|
2026-03-06 07:07:03 +07:00
|
|
|
|
</span>
|
2026-03-16 09:15:38 +07:00
|
|
|
|
<span className="example-category">
|
|
|
|
|
|
{getCategoryIcon(example.category)} {example.category}
|
2026-03-15 02:57:22 +07:00
|
|
|
|
</span>
|
2026-03-16 09:15:38 +07:00
|
|
|
|
{boardBadge && (
|
2026-04-22 02:45:45 +07:00
|
|
|
|
<span
|
|
|
|
|
|
className="example-board-badge"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
backgroundColor: boardBadge.bg + '33',
|
|
|
|
|
|
color: boardBadge.bg,
|
|
|
|
|
|
border: `1px solid ${boardBadge.bg}66`,
|
|
|
|
|
|
padding: '2px 6px',
|
|
|
|
|
|
borderRadius: '4px',
|
|
|
|
|
|
fontSize: '0.7rem',
|
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-03-16 09:15:38 +07:00
|
|
|
|
{boardBadge.label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
2026-03-31 05:00:33 +07:00
|
|
|
|
<button
|
|
|
|
|
|
className="example-copy-link"
|
|
|
|
|
|
onClick={(e) => handleCopyLink(e, example.id)}
|
2026-05-09 21:24:52 +07:00
|
|
|
|
title={t('examples.copyLink')}
|
2026-03-31 05:00:33 +07:00
|
|
|
|
>
|
|
|
|
|
|
{copiedId === example.id ? (
|
2026-04-22 02:45:45 +07:00
|
|
|
|
<svg
|
|
|
|
|
|
width="14"
|
|
|
|
|
|
height="14"
|
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
|
fill="none"
|
|
|
|
|
|
stroke="#4ade80"
|
|
|
|
|
|
strokeWidth="2"
|
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
|
>
|
2026-03-31 05:00:33 +07:00
|
|
|
|
<polyline points="20 6 9 17 4 12" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
) : (
|
2026-04-22 02:45:45 +07:00
|
|
|
|
<svg
|
|
|
|
|
|
width="14"
|
|
|
|
|
|
height="14"
|
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
|
fill="none"
|
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
|
strokeWidth="2"
|
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
|
>
|
2026-03-31 05:00:33 +07:00
|
|
|
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
|
|
|
|
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
2026-03-16 09:15:38 +07:00
|
|
|
|
</div>
|
2026-03-04 08:25:52 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-03-16 09:15:38 +07:00
|
|
|
|
);
|
|
|
|
|
|
})}
|
2026-03-04 08:25:52 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{filteredExamples.length === 0 && (
|
|
|
|
|
|
<div className="examples-empty">
|
2026-04-29 05:24:39 +07:00
|
|
|
|
<p>
|
2026-05-09 21:24:52 +07:00
|
|
|
|
{searchTokens.length > 0
|
|
|
|
|
|
? t('examples.emptyForQuery', { query: search.trim() })
|
|
|
|
|
|
: t('examples.empty')}
|
2026-04-29 05:24:39 +07:00
|
|
|
|
</p>
|
|
|
|
|
|
{(searchTokens.length > 0 ||
|
|
|
|
|
|
selectedBoard !== 'all' ||
|
|
|
|
|
|
selectedCategory !== 'all' ||
|
|
|
|
|
|
selectedDifficulty !== 'all') && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="examples-empty-reset"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setSearch('');
|
|
|
|
|
|
setSelectedBoard('all');
|
|
|
|
|
|
setSelectedCategory('all');
|
|
|
|
|
|
setSelectedDifficulty('all');
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-05-09 21:24:52 +07:00
|
|
|
|
{t('examples.resetFilters')}
|
2026-04-29 05:24:39 +07:00
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2026-03-04 08:25:52 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|