|  | 
|  | 1 | +// Copyright 2025 The Flutter Authors. All rights reserved. | 
|  | 2 | +// Use of this source code is governed by a BSD-style license that can be | 
|  | 3 | +// found in the LICENSE file. | 
|  | 4 | + | 
|  | 5 | +import 'dart:math'; | 
|  | 6 | + | 
|  | 7 | +import 'package:jaspr/jaspr.dart'; | 
|  | 8 | +import 'package:universal_web/js_interop.dart'; | 
|  | 9 | +import 'package:universal_web/web.dart' as web; | 
|  | 10 | + | 
|  | 11 | +import '../../analytics/analytics.dart'; | 
|  | 12 | +import '../../models/learning_resource_model.dart'; | 
|  | 13 | +import '../util/global_event_listener.dart'; | 
|  | 14 | +import 'learning_resource_filters_sidebar.dart'; | 
|  | 15 | + | 
|  | 16 | +class ResourceInfo { | 
|  | 17 | +  ResourceInfo({ | 
|  | 18 | +    required this.name, | 
|  | 19 | +    required this.type, | 
|  | 20 | +    required this.tags, | 
|  | 21 | +    required this.description, | 
|  | 22 | +  }); | 
|  | 23 | + | 
|  | 24 | +  factory ResourceInfo.fromElement(web.Element element) { | 
|  | 25 | +    final dataType = element.getAttribute('data-type') ?? ''; | 
|  | 26 | +    final dataTags = element.getAttribute('data-tags') ?? ''; | 
|  | 27 | +    final dataDescription = element.getAttribute('data-description') ?? ''; | 
|  | 28 | + | 
|  | 29 | +    return ResourceInfo( | 
|  | 30 | +      name: element.id, | 
|  | 31 | +      type: LearningResourceType.values.firstWhere( | 
|  | 32 | +        (type) => type.id == dataType, | 
|  | 33 | +        orElse: () => LearningResourceType.sample, | 
|  | 34 | +      ), | 
|  | 35 | +      tags: dataTags | 
|  | 36 | +          .split(',') | 
|  | 37 | +          .map((t) => t.trim().toLowerCase()) | 
|  | 38 | +          .map((tagId) { | 
|  | 39 | +            return LearningResourceTag.values | 
|  | 40 | +                .where((tag) => tag.id == tagId) | 
|  | 41 | +                .firstOrNull; | 
|  | 42 | +          }) | 
|  | 43 | +          .nonNulls | 
|  | 44 | +          .toList(), | 
|  | 45 | +      description: dataDescription, | 
|  | 46 | +    ); | 
|  | 47 | +  } | 
|  | 48 | + | 
|  | 49 | +  final String name; | 
|  | 50 | +  final LearningResourceType type; | 
|  | 51 | +  final List<LearningResourceTag> tags; | 
|  | 52 | +  final String description; | 
|  | 53 | +} | 
|  | 54 | + | 
|  | 55 | +@client | 
|  | 56 | +class LearningResourceFilters extends StatefulComponent { | 
|  | 57 | +  const LearningResourceFilters({super.key}); | 
|  | 58 | + | 
|  | 59 | +  @override | 
|  | 60 | +  State<LearningResourceFilters> createState() => | 
|  | 61 | +      _LearningResourceFiltersState(); | 
|  | 62 | +} | 
|  | 63 | + | 
|  | 64 | +class _LearningResourceFiltersState extends State<LearningResourceFilters> { | 
|  | 65 | +  String searchQuery = ''; | 
|  | 66 | + | 
|  | 67 | +  FiltersNotifier get filters => LearningResourceFiltersSidebar.filters; | 
|  | 68 | + | 
|  | 69 | +  final List<ResourceInfo> resourceInfos = []; | 
|  | 70 | +  int filteredResourcesCount = 0; | 
|  | 71 | + | 
|  | 72 | +  @override | 
|  | 73 | +  void initState() { | 
|  | 74 | +    super.initState(); | 
|  | 75 | + | 
|  | 76 | +    if (kIsWeb) { | 
|  | 77 | +      filters.addListener(setFilters); | 
|  | 78 | + | 
|  | 79 | +      final resourceGrid = web.document.getElementById('all-resources-grid'); | 
|  | 80 | +      if (resourceGrid == null) { | 
|  | 81 | +        return; | 
|  | 82 | +      } | 
|  | 83 | + | 
|  | 84 | +      final resourceCards = resourceGrid.querySelectorAll('.card'); | 
|  | 85 | +      loadResourceInfos(resourceCards); | 
|  | 86 | +      shuffleCards(resourceGrid); | 
|  | 87 | +    } | 
|  | 88 | +  } | 
|  | 89 | + | 
|  | 90 | +  void loadResourceInfos(web.NodeList resourceCards) { | 
|  | 91 | +    for (var i = 0; i < resourceCards.length; i++) { | 
|  | 92 | +      final element = resourceCards.item(i) as web.Element; | 
|  | 93 | +      final info = ResourceInfo.fromElement(element); | 
|  | 94 | +      resourceInfos.add(info); | 
|  | 95 | + | 
|  | 96 | +      element.addEventListener( | 
|  | 97 | +        'click', | 
|  | 98 | +        ((web.Event event) { | 
|  | 99 | +          analytics.sendEvent('learning_resource_index_click', { | 
|  | 100 | +            'learning_resource_type': info.type.id, | 
|  | 101 | +            'learning_resource_title': info.name, | 
|  | 102 | +          }); | 
|  | 103 | +        }).toJS, | 
|  | 104 | +      ); | 
|  | 105 | +    } | 
|  | 106 | +  } | 
|  | 107 | + | 
|  | 108 | +  void shuffleCards(web.Element container) { | 
|  | 109 | +    final r = Random(); | 
|  | 110 | +    final elements = container.childNodes; | 
|  | 111 | +    for (var i = elements.length; i > 0; i--) { | 
|  | 112 | +      final card = elements.item(r.nextInt(i)); | 
|  | 113 | +      container.appendChild(card!); | 
|  | 114 | +    } | 
|  | 115 | +  } | 
|  | 116 | + | 
|  | 117 | +  /// Update the filter state and re-evaluate which resources to show. | 
|  | 118 | +  /// | 
|  | 119 | +  /// Use like the `setState` method by passing a callback that updates | 
|  | 120 | +  /// the relevant state variables. | 
|  | 121 | +  /// | 
|  | 122 | +  /// Example: | 
|  | 123 | +  /// | 
|  | 124 | +  /// ```dart | 
|  | 125 | +  /// setFilters(() { | 
|  | 126 | +  ///   searchQuery = '...'; | 
|  | 127 | +  /// }); | 
|  | 128 | +  /// ``` | 
|  | 129 | +  void setFilters([void Function()? callback]) { | 
|  | 130 | +    setState(callback ?? () {}); | 
|  | 131 | + | 
|  | 132 | +    final resourcesToShow = filters.filterResources(resourceInfos, searchQuery); | 
|  | 133 | +    filteredResourcesCount = resourcesToShow.length; | 
|  | 134 | +    for (final info in resourceInfos) { | 
|  | 135 | +      final element = | 
|  | 136 | +          web.document.getElementById(info.name) as web.HTMLElement?; | 
|  | 137 | +      if (element == null) { | 
|  | 138 | +        continue; | 
|  | 139 | +      } | 
|  | 140 | + | 
|  | 141 | +      if (resourcesToShow.contains(info)) { | 
|  | 142 | +        element.classList.remove('hidden'); | 
|  | 143 | +      } else { | 
|  | 144 | +        element.classList.add('hidden'); | 
|  | 145 | +      } | 
|  | 146 | +    } | 
|  | 147 | +  } | 
|  | 148 | + | 
|  | 149 | +  @override | 
|  | 150 | +  void dispose() { | 
|  | 151 | +    if (kIsWeb) { | 
|  | 152 | +      filters.removeListener(setFilters); | 
|  | 153 | +    } | 
|  | 154 | +    super.dispose(); | 
|  | 155 | +  } | 
|  | 156 | + | 
|  | 157 | +  @override | 
|  | 158 | +  Component build(BuildContext context) { | 
|  | 159 | +    return div(id: 'resource-search-group', classes: 'chip-filters-group', [ | 
|  | 160 | +      div(classes: 'top-row', [ | 
|  | 161 | +        div(classes: 'search-wrapper', id: 'resource-search', [ | 
|  | 162 | +          span( | 
|  | 163 | +            classes: 'material-symbols leading-icon', | 
|  | 164 | +            attributes: {'aria-hidden': 'true', 'translate': 'no'}, | 
|  | 165 | +            [text('search')], | 
|  | 166 | +          ), | 
|  | 167 | +          input( | 
|  | 168 | +            type: InputType.search, | 
|  | 169 | +            attributes: { | 
|  | 170 | +              'placeholder': 'Try "button" or "networking"...', | 
|  | 171 | +              'aria-label': 'Search learning resources by name and category', | 
|  | 172 | +            }, | 
|  | 173 | +            value: searchQuery, | 
|  | 174 | +            onInput: (value) { | 
|  | 175 | +              setFilters(() { | 
|  | 176 | +                searchQuery = value as String; | 
|  | 177 | +              }); | 
|  | 178 | +            }, | 
|  | 179 | +          ), | 
|  | 180 | +        ]), | 
|  | 181 | +        GlobalEventListener( | 
|  | 182 | +          onClick: (event) { | 
|  | 183 | +            final target = event.target as web.Element?; | 
|  | 184 | +            // If clicking outside the filters or toggle, close the filters. | 
|  | 185 | +            if (target?.closest('#resource-filter-group-wrapper') == null && | 
|  | 186 | +                target?.closest('.show-filters-button') == null) { | 
|  | 187 | +              final toggle = | 
|  | 188 | +                  web.document.getElementById('open-filter-toggle') | 
|  | 189 | +                      as web.HTMLInputElement?; | 
|  | 190 | +              toggle?.checked = false; | 
|  | 191 | +            } | 
|  | 192 | +          }, | 
|  | 193 | +          button( | 
|  | 194 | +            classes: 'icon-button show-filters-button', | 
|  | 195 | +            onClick: () { | 
|  | 196 | +              final toggle = | 
|  | 197 | +                  web.document.getElementById('open-filter-toggle') | 
|  | 198 | +                      as web.HTMLInputElement?; | 
|  | 199 | +              toggle?.checked = !toggle.checked; | 
|  | 200 | +            }, | 
|  | 201 | +            [ | 
|  | 202 | +              span( | 
|  | 203 | +                classes: 'material-symbols', | 
|  | 204 | +                attributes: {'aria-hidden': 'true', 'translate': 'no'}, | 
|  | 205 | +                [text('filter_list')], | 
|  | 206 | +              ), | 
|  | 207 | +            ], | 
|  | 208 | +          ), | 
|  | 209 | +        ), | 
|  | 210 | +      ]), | 
|  | 211 | +      div(classes: 'label-row', [ | 
|  | 212 | +        label( | 
|  | 213 | +          attributes: {'for': 'resource-search'}, | 
|  | 214 | +          [ | 
|  | 215 | +            text('Showing '), | 
|  | 216 | +            span([text('$filteredResourcesCount')]), | 
|  | 217 | +            text(' / '), | 
|  | 218 | +            span([text('${resourceInfos.length}')]), | 
|  | 219 | +          ], | 
|  | 220 | +        ), | 
|  | 221 | +        button( | 
|  | 222 | +          attributes: { | 
|  | 223 | +            if (searchQuery.isEmpty && | 
|  | 224 | +                filters.selectedTags.isEmpty && | 
|  | 225 | +                filters.selectedTypes.isEmpty) | 
|  | 226 | +              'disabled': 'true', | 
|  | 227 | +          }, | 
|  | 228 | +          onClick: () { | 
|  | 229 | +            setState(() { | 
|  | 230 | +              searchQuery = ''; | 
|  | 231 | +            }); | 
|  | 232 | +            filters.reset(); | 
|  | 233 | +          }, | 
|  | 234 | +          [ | 
|  | 235 | +            span( | 
|  | 236 | +              classes: 'material-symbols', | 
|  | 237 | +              attributes: {'aria-hidden': 'true', 'translate': 'no'}, | 
|  | 238 | +              [text('close_small')], | 
|  | 239 | +            ), | 
|  | 240 | +            span([text('Clear filters')]), | 
|  | 241 | +          ], | 
|  | 242 | +        ), | 
|  | 243 | +      ]), | 
|  | 244 | +    ]); | 
|  | 245 | +  } | 
|  | 246 | +} | 
0 commit comments