diff --git a/v5/src/components/DetailShell.tsx b/v5/src/components/DetailShell.tsx index 11911bd..52618dc 100644 --- a/v5/src/components/DetailShell.tsx +++ b/v5/src/components/DetailShell.tsx @@ -77,6 +77,7 @@ export function DetailShell({ tool }: DetailShellProps) { sizes="(min-width: 980px) 45vw, 100vw" style={{ objectFit: "contain" }} priority + unoptimized /> diff --git a/v5/src/components/GalleryShell.tsx b/v5/src/components/GalleryShell.tsx index 1aba9ad..aeea60e 100644 --- a/v5/src/components/GalleryShell.tsx +++ b/v5/src/components/GalleryShell.tsx @@ -1,7 +1,7 @@ "use client"; import Image from "next/image"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { matchSorter } from "match-sorter"; import type { MakerLabTool } from "./catalog-types"; @@ -12,12 +12,6 @@ interface GalleryShellProps { tools: MakerLabTool[]; } -const TRAINING_LEVELS = [ - { value: "Beginner", key: "trainingBeginner" }, - { value: "Intermediate", key: "trainingIntermediate" }, - { value: "Advanced", key: "trainingAdvanced" }, -] as const; - // Ranked, typo-tolerant search keys. match-sorter ranks earlier keys above // later ones when match quality ties, so key order doubles as relevance // weight: name first, then the structured metadata (category / tags / @@ -35,14 +29,77 @@ const SEARCH_KEYS: ReadonlyArray = [ "description", ]; +type SortKey = "name" | "category" | "zone" | "trainingLevel"; +type SortState = { key: SortKey; dir: "asc" | "desc" }; + +// Table columns, in render order. `key` drives sorting; `labelKey` is the i18n +// header string. +const TABLE_COLUMNS: ReadonlyArray<{ key: SortKey; labelKey: string }> = [ + { key: "name", labelKey: "columnTool" }, + { key: "category", labelKey: "columnCategory" }, + { key: "zone", labelKey: "columnZone" }, + { key: "trainingLevel", labelKey: "columnTraining" }, +]; + +// The catalog stores materials as a flat list with no type, so the grouping +// for the Materials dropdown is defined here. Matching is case-insensitive; +// anything not listed falls into "Other". Order here is the display order. +const MATERIAL_GROUPS: ReadonlyArray<{ label: string; values: string[] }> = [ + { + label: "Plastics & Polymers", + values: ["ABS", "Acrylic", "Composite", "Nylon", "PETG", "PLA", "Plastic", "Polycarbonate", "PVC", "Resin", "TPU", "Vinyl"], + }, + { label: "Wood", values: ["Hardwood", "Softwood", "Plywood", "MDF", "Veneer", "Laminate", "Wood"] }, + { label: "Metal", values: ["Aluminum", "Brass", "Copper", "Steel"] }, + { label: "Other", values: ["Cardboard", "Ceramic", "Fabric", "Foam", "Glass", "Leather", "Paper", "Rubber"] }, +]; + +const OTHER_GROUP_LABEL = "Other"; + export function GalleryShell({ tools }: GalleryShellProps) { const t = useTranslations("gallery"); const [query, setQuery] = useState(""); - const [category, setCategory] = useState(null); - const [training, setTraining] = useState(null); - const [material, setMaterial] = useState(null); + // Category and materials are multi-select facets (OR within each facet); + // location stays single-select. + const [selectedCategories, setSelectedCategories] = useState([]); + const [selectedMaterials, setSelectedMaterials] = useState([]); const [location, setLocation] = useState(null); const [viewMode, setViewMode] = useState<"grid" | "table">("grid"); + const [filtersOpen, setFiltersOpen] = useState(false); + const [categoryOpen, setCategoryOpen] = useState(false); + const [materialsOpen, setMaterialsOpen] = useState(false); + const [sort, setSort] = useState(null); + // Close the open dropdown menus on an outside click or Escape, like a + // native setLocation(event.target.value || null)}> + + {locations.map((locationName) => ( + + ))} + + + + @@ -207,23 +394,36 @@ export function GalleryShell({ tools }: GalleryShellProps) { filteredTools.map((tool) => ) ) : ( <> -
diff --git a/v5/src/styles/globals.css b/v5/src/styles/globals.css index 014c71f..55a6310 100644 --- a/v5/src/styles/globals.css +++ b/v5/src/styles/globals.css @@ -152,6 +152,7 @@ img { gap: 8px; font-family: var(--font-display); font-weight: 700; + min-width: 0; } .brand-lockup span:last-child { @@ -435,6 +436,7 @@ p { .filter-console { display: grid; + grid-template-columns: minmax(0, 1fr) auto; gap: 16px; padding: 16px; } @@ -442,6 +444,7 @@ p { .search-row { display: grid; grid-template-columns: 48px 1fr; + grid-column: 1 / -1; } .search-row span { @@ -476,18 +479,46 @@ p { } .filter-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 12px 16px; + grid-column: 1; + grid-row: 2; +} + +.filter-toolbar { display: flex; align-items: center; - justify-content: space-between; - gap: 16px 32px; - flex-wrap: wrap; + align-self: end; + justify-content: flex-end; + gap: 10px; + grid-column: 2; + grid-row: 2; } -.filter-group { +.filter-controls { display: flex; - align-items: center; - gap: 12px; flex-wrap: wrap; + align-items: flex-start; + gap: 14px; + min-width: 0; +} + +/* Each filter sits in the row and only wraps to a new line when it can't + fit. position:relative anchors the floating dropdown panels below. */ +.filter-dropdown-group, +.filter-select-group { + position: relative; + flex: 1 1 200px; + min-width: 180px; + max-width: 280px; +} + +.filter-group { + display: grid; + gap: 6px; + min-width: 0; } .filter-group > span { @@ -496,6 +527,164 @@ p { font-size: 10px; } +.filter-select-group select { + width: 100%; + min-width: 0; + height: 36px; + border: 1px solid var(--outline); + background: var(--surface-container); + color: var(--on-surface); + font-family: var(--font-mono); + font-size: 11px; + text-transform: uppercase; + outline: none; + padding: 0 32px 0 10px; + appearance: none; + background-image: + linear-gradient(45deg, transparent 50%, var(--on-surface-muted) 50%), + linear-gradient(135deg, var(--on-surface-muted) 50%, transparent 50%); + background-position: + calc(100% - 16px) 15px, + calc(100% - 11px) 15px; + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; +} + +.filter-select-group select:focus { + border-color: var(--primary); +} + + +/* Materials is a collapsible dropdown whose panel groups the chips by type. */ +.filter-dropdown-toggle { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 10px; + width: 100%; + height: 36px; + padding: 0 12px; + border: 1px solid var(--outline); + background: var(--surface-container); + color: var(--on-surface); + font-family: var(--font-mono); + font-size: 11px; + text-transform: uppercase; + cursor: pointer; + transition: border-color 160ms ease-in; +} + +.filter-dropdown-toggle:hover, +.filter-dropdown-toggle.is-active { + border-color: var(--primary); +} + +.filter-dropdown-caret { + font-size: 8px; + color: var(--on-surface-muted); +} + +.filter-dropdown-panel { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 20; + display: flex; + flex-direction: column; + min-width: 100%; + max-width: min(340px, 86vw); + max-height: 340px; + overflow-y: auto; + padding: 4px; + border: 1px solid var(--outline); + background: var(--surface-container); +} + +/* Native-select-style menu rows with a checkbox affordance for multi-select. */ +.filter-option { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + border: 0; + background: transparent; + color: var(--on-surface); + font-family: var(--font-mono); + font-size: 11px; + text-transform: uppercase; + text-align: left; + cursor: pointer; + transition: background 120ms ease-in, color 120ms ease-in; +} + +.filter-option:hover { + background: var(--surface-container-high); +} + +.filter-option[aria-selected="true"] { + color: var(--primary); +} + +.filter-option-check { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 14px; + height: 14px; + border: 1px solid var(--outline); + font-size: 9px; + line-height: 1; + color: #0f0f0f; +} + +.filter-option[aria-selected="true"] .filter-option-check { + background: var(--primary); + border-color: var(--primary); +} + +/* Grouped sections inside the Materials menu. */ +.filter-menu-section { + display: flex; + flex-direction: column; +} + +.filter-menu-section + .filter-menu-section { + margin-top: 4px; + padding-top: 4px; + border-top: 1px solid var(--outline); +} + +.filter-menu-section-head { + width: 100%; + padding: 8px 10px 4px; + border: 0; + background: transparent; + color: var(--on-surface-muted); + font-family: var(--font-mono); + font-size: 9px; + letter-spacing: 0.1em; + text-transform: uppercase; + text-align: left; + cursor: pointer; + transition: color 120ms ease-in; +} + +.filter-menu-section-head::before { + content: "+ "; +} + +.filter-menu-section-head[aria-pressed="true"]::before { + content: "− "; +} + +.filter-menu-section-head:hover, +.filter-menu-section-head[aria-pressed="true"] { + color: var(--primary); +} + + .chip-row, .detail-actions, .ppe-list { @@ -506,6 +695,8 @@ p { .chip, .view-toggle button, +.filter-toggle, +.filter-clear, .doc-chip { border: 1px solid var(--outline); background: transparent; @@ -531,8 +722,11 @@ p { .chip:hover, .view-toggle button:hover, +.filter-toggle:hover, +.filter-clear:hover, .doc-chip:hover, .chip-active, +.filter-toggle.is-active, .view-toggle .is-active { border-color: var(--primary); background: var(--primary); @@ -544,10 +738,31 @@ p { gap: 8px; } +.view-toggle button { + white-space: nowrap; +} + +.filter-toggle { + display: none; + height: 36px; + padding-inline: 10px; + font-family: var(--font-mono); + text-transform: uppercase; + white-space: nowrap; +} + +.filter-clear { + height: 36px; + padding-inline: 10px; + font-family: var(--font-mono); + text-transform: uppercase; + white-space: nowrap; +} + .tool-grid { display: grid; - grid-template-columns: repeat(1, minmax(0, 1fr)); - gap: 20px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; } .tool-table { @@ -607,13 +822,12 @@ p { position: sticky; top: var(--sticky-chrome-height); z-index: 5; - padding: 14px 16px; + padding: 6px 16px; background: var(--surface-container-high); color: var(--on-surface); font-size: 12px; font-weight: 800; letter-spacing: 0.12em; - pointer-events: none; margin-bottom: 2px; box-shadow: 0 6px 12px -8px rgba(0, 0, 0, 0.18); } @@ -624,10 +838,42 @@ p { color: var(--on-surface); } -.tool-table-head > span:first-child { +/* Sortable column headers. */ +.tool-table-sort { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 8px 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + letter-spacing: inherit; + text-transform: uppercase; + text-align: left; + cursor: pointer; + transition: color 160ms ease-in; +} + +.tool-table-sort:first-child { padding-left: 54px; } +.tool-table-sort:hover, +.tool-table-sort.is-active { + color: var(--primary); +} + +.sort-indicator { + font-size: 9px; + opacity: 0.55; +} + +.tool-table-sort.is-active .sort-indicator { + opacity: 1; +} + .empty-state { padding: 64px 32px; background: var(--surface-container-low); @@ -986,6 +1232,7 @@ p { grid-template-rows: auto 1fr auto; width: min(360px, calc(100vw - 32px)); max-height: min(560px, calc(100vh - 128px)); + min-height: 360px; overflow: hidden; border: 1px solid var(--outline); background: var(--surface-container); @@ -1041,6 +1288,7 @@ p { } .chat-body { + min-height: 0; padding: 20px 18px 12px; overflow-y: auto; } @@ -1489,38 +1737,167 @@ p { @media (min-width: 768px) { .tool-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); } } @media (min-width: 1024px) { .tool-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); } } @media (min-width: 1280px) { .tool-grid { - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(5, minmax(0, 1fr)); } } @media (max-width: 860px) { + :root { + --nav-height: 105px; + } + .top-nav { - align-items: flex-start; - flex-direction: column; - gap: 16px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px 12px; + padding: 12px 16px 10px; + } + + .brand-lockup { + overflow: hidden; + } + + .brand-lockup span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .brand-lockup span:last-child { + display: none; } .primary-nav { + grid-column: 1 / -1; + grid-row: 2; width: 100%; justify-content: space-between; + gap: 8px; + font-size: 11px; + } + + .primary-nav a { + flex: 1; + padding: 8px 4px; + text-align: center; + } + + .nav-actions { + grid-column: 2; + grid-row: 1; + justify-self: end; + } + + .status-strip { + position: static; + } + + .lang-select { + width: 32px; + height: 32px; + } + + .lang-select-glyph { + inset-inline-start: 50%; + transform: translateX(-50%); + font-size: 10px; + white-space: nowrap; + } + + .lang-select select { + width: 32px; + padding: 0; + color: transparent; + text-indent: -999px; + } + + .lang-select select:hover, + .lang-select select:focus-visible { + color: transparent; } .page-shell { padding: 24px 16px 96px; } + .gallery-header { + gap: 20px; + margin-bottom: 18px; + } + + .filter-console { + grid-template-columns: 1fr; + } + + /* Toolbar wraps: Filters on its own row, then Clear + the Grid/Table + switcher share the next row. */ + .filter-toolbar { + grid-column: 1; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; + } + + .filter-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 1 1 100%; + } + + .filter-clear, + .view-toggle { + flex: 1 1 0; + } + + .filter-row { + display: none; + grid-column: 1; + grid-row: auto; + grid-template-columns: 1fr; + padding-top: 2px; + } + + .filter-row.is-open { + display: grid; + } + + /* Stack the filters and put the dropdown panels back in normal flow so they + push content instead of floating in the cramped mobile sheet. */ + .filter-controls { + flex-direction: column; + flex-wrap: nowrap; + } + + .filter-dropdown-group, + .filter-select-group { + flex: none; + max-width: none; + width: 100%; + } + + .filter-dropdown-panel { + position: static; + width: auto; + min-width: 0; + max-width: none; + margin-top: 8px; + } + .detail-hero, .unit-row { grid-template-columns: 1fr; @@ -1534,43 +1911,135 @@ p { grid-template-columns: 1fr; } + /* Keep the desktop columnar table on mobile — just let it scroll sideways + instead of collapsing the columns. */ + .tool-table { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + .tool-table-row { - grid-template-columns: 1fr; + min-width: 600px; } .chat-fab { - right: 16px; - bottom: 16px; + right: 14px; + bottom: 14px; + width: 48px; + height: 48px; + } + + .chat-scrim { + display: none; } .chat-sheet { - right: 16px; - bottom: 88px; + position: fixed; + inset: 0; + width: 100vw; + height: 100dvh; + min-height: 0; + max-height: none; + border: 0; + border-radius: 0 !important; + box-shadow: none !important; + } + + .chat-header { + padding: calc(12px + env(safe-area-inset-top)) 14px 12px; + } + + .chat-body { + padding: 16px 14px 12px; + } + + .chat-composer { + padding: 10px 10px calc(10px + env(safe-area-inset-bottom)); } } @media (max-width: 560px) { + :root { + --nav-height: 104px; + } + .status-strip { padding-inline: 16px; } - .filter-row, - .filter-group, - .view-toggle { - align-items: stretch; - width: 100%; + h1 { + font-size: 36px; } - .chip-row, - .view-toggle { - flex: 1; + .title-row { + gap: 10px; + } + + .target-glyph { + width: 34px; + height: 34px; + font-size: 30px; + } + + .filter-console { + gap: 12px; + padding: 12px; + } + + .search-row { + grid-template-columns: 38px 1fr; + } + + .search-row input { + padding: 12px; + } + + .filter-controls { + grid-template-columns: 1fr; + gap: 8px; + } + + .filter-toolbar { + width: 100%; } - .chip, .view-toggle button { flex: 1; } + .tool-grid { + gap: 10px; + } + + /* Two columns of compact square cards on phones (image on top, label + below) — replaces the older horizontal list-row layout. */ + .tool-card { + display: flex; + flex-direction: column; + } + + .tool-card-image { + aspect-ratio: 1 / 1; + padding: 10px; + } + + .tool-card-body { + gap: 6px; + padding: 10px 10px 12px; + } + + .tool-card-body h2 { + font-size: 13px; + line-height: 1.2; + } + + .tool-card-tag { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .spec-table div { display: grid; } @@ -1579,6 +2048,14 @@ p { padding: 0 12px 12px; text-align: left; } + + .chat-msg { + max-width: 94%; + } + + .chat-suggestion { + padding: 12px 13px; + } } .loading-line { @@ -2376,20 +2853,59 @@ p { } @media (max-width: 560px) { + .tool-detail { + width: 100%; + margin: 14px auto 32px; + padding: 0 14px; + gap: 16px; + overflow-x: hidden; + } + + .td-breadcrumbs { + margin: 0; + font-size: 12px; + } + .td-hero, .td-panel, .td-safety { + min-width: 0; padding: 18px; } + .td-hero { + gap: 18px; + } + .td-hero-image { min-height: 220px; } + .td-hero-copy { + min-width: 0; + } + .td-hero-copy h1 { font-size: 26px; } + .td-hero-copy > p { + font-size: 15px; + overflow-wrap: anywhere; + } + + .td-chip-row { + gap: 8px; + } + + .td-chip { + max-width: 100%; + height: auto; + min-height: 34px; + padding: 8px 10px; + white-space: normal; + } + .td-actions { display: grid; } @@ -2406,6 +2922,22 @@ p { grid-column: 1 / -1; width: fit-content; } + + .td-kv-table th, + .td-kv-table td { + display: block; + width: 100%; + padding-inline: 0; + } + + .td-kv-table th { + padding-bottom: 2px; + border-bottom: none; + } + + .td-kv-table td { + padding-top: 0; + } } /* --------------------------------------------------------------------------- @@ -2432,9 +2964,14 @@ p { } @media (max-width: 860px) { - [dir="rtl"] .chat-fab, - [dir="rtl"] .chat-sheet { + [dir="rtl"] .chat-fab { right: auto; left: 16px; } + + [dir="rtl"] .chat-sheet { + inset: 0; + right: 0; + left: 0; + } }