diff --git a/VERSION b/VERSION index 98d9bcb..3c03207 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -17 +18 diff --git a/default.nix b/default.nix index 4f439d7..b3440fa 100644 --- a/default.nix +++ b/default.nix @@ -31,6 +31,8 @@ pkgs.stdenv.mkDerivation { name = "${package.name}-${package.version}"; src = pkgs.lib.cleanSource ./.; + preferLocalBuild = true; + buildInputs = [ yarnPkg diff --git a/import-scripts/default.nix b/import-scripts/default.nix index 9fc3d73..84c6df9 100644 --- a/import-scripts/default.nix +++ b/import-scripts/default.nix @@ -13,12 +13,13 @@ mkPoetryApplication { ''; }); }); + preferLocalBuild = true; nativeBuildInputs = with pkgs; [ poetry fd entr + nixStable ]; - #doCheck = false; checkPhase = '' export PYTHONPATH=$PWD:$PYTHONPATH black --diff --check import_scripts/ tests/ @@ -27,8 +28,13 @@ mkPoetryApplication { pytest -vv tests/ ''; postInstall = '' - wrapProgram $out/bin/import-channel --set INDEX_SCHEMA_VERSION "${version}" - wrapProgram $out/bin/channel-diff --set INDEX_SCHEMA_VERSION "${version}" + wrapProgram $out/bin/import-channel \ + --set INDEX_SCHEMA_VERSION "${version}" \ + --prefix PATH : "${pkgs.nixStable}/bin" + wrapProgram $out/bin/channel-diff \ + --set INDEX_SCHEMA_VERSION "${version}" \ + --prefix PATH : "${pkgs.nixStable}/bin" + ''; shellHook = '' cd import-scripts/ diff --git a/import-scripts/import_scripts/channel.py b/import-scripts/import_scripts/channel.py index 1e75fa6..94258a2 100644 --- a/import-scripts/import_scripts/channel.py +++ b/import-scripts/import_scripts/channel.py @@ -36,6 +36,7 @@ CHANNELS = { "20.03": "nixos/20.03/nixos-20.03.", "20.09": "nixos/20.09/nixos-20.09.", } +ALLOWED_PLATFORMS = ["x86_64-linux", "aarch64-linux", "x86_64-darwin", "i686-linux"] ANALYSIS = { "normalizer": { "lowercase": {"type": "custom", "char_filter": [], "filter": ["lowercase"]} @@ -88,42 +89,34 @@ MAPPING = { }, "package_attr_name": { "type": "keyword", - "normalizer": "lowercase", "fields": {"edge": {"type": "text", "analyzer": "edge"}}, }, "package_attr_name_reverse": { "type": "keyword", - "normalizer": "lowercase", "fields": {"edge": {"type": "text", "analyzer": "edge"}}, }, "package_attr_name_query": { "type": "keyword", - "normalizer": "lowercase", "fields": {"edge": {"type": "text", "analyzer": "edge"}}, }, "package_attr_name_query_reverse": { "type": "keyword", - "normalizer": "lowercase", "fields": {"edge": {"type": "text", "analyzer": "edge"}}, }, "package_attr_set": { "type": "keyword", - "normalizer": "lowercase", "fields": {"edge": {"type": "text", "analyzer": "edge"}}, }, "package_attr_set_reverse": { "type": "keyword", - "normalizer": "lowercase", "fields": {"edge": {"type": "text", "analyzer": "edge"}}, }, "package_pname": { "type": "keyword", - "normalizer": "lowercase", "fields": {"edge": {"type": "text", "analyzer": "edge"}}, }, "package_pname_reverse": { "type": "keyword", - "normalizer": "lowercase", "fields": {"edge": {"type": "text", "analyzer": "edge"}}, }, "package_pversion": {"type": "keyword"}, @@ -151,6 +144,7 @@ MAPPING = { "type": "nested", "properties": {"fullName": {"type": "text"}, "url": {"type": "text"}}, }, + "package_license_set": {"type": "keyword"}, "package_maintainers": { "type": "nested", "properties": { @@ -159,6 +153,7 @@ MAPPING = { "github": {"type": "text"}, }, }, + "package_maintainers_set": {"type": "keyword"}, "package_platforms": {"type": "keyword"}, "package_position": {"type": "text"}, "package_homepage": {"type": "keyword"}, @@ -166,22 +161,18 @@ MAPPING = { # Options fields "option_name": { "type": "keyword", - "normalizer": "lowercase", "fields": {"edge": {"type": "text", "analyzer": "edge"}}, }, "option_name_reverse": { "type": "keyword", - "normalizer": "lowercase", "fields": {"edge": {"type": "text", "analyzer": "edge"}}, }, "option_name_query": { "type": "keyword", - "normalizer": "lowercase", "fields": {"edge": {"type": "text", "analyzer": "edge"}}, }, "option_name_query_reverse": { "type": "keyword", - "normalizer": "lowercase", "fields": {"edge": {"type": "text", "analyzer": "edge"}}, }, "option_description": { @@ -396,7 +387,7 @@ def remove_attr_set(name): @backoff.on_exception(backoff.expo, subprocess.CalledProcessError) def get_packages_raw(evaluation): logger.debug( - f"get_packages: Retrieving list of packages for '{evaluation['git_revision']}' revision" + f"get_packages_raw: Retrieving list of packages for '{evaluation['git_revision']}' revision" ) result = subprocess.run( shlex.split( @@ -417,7 +408,7 @@ def get_packages(evaluation, evaluation_builds): licenses = data["meta"].get("license") if licenses: if type(licenses) == str: - licenses = [dict(fullName=licenses)] + licenses = [dict(fullName=licenses, url=None)] elif type(licenses) == dict: licenses = [licenses] licenses = [ @@ -427,24 +418,27 @@ def get_packages(evaluation, evaluation_builds): for license in licenses ] else: - licenses = [] + licenses = [dict(fullName="No license", url=None)] maintainers = get_maintainer(data["meta"].get("maintainers", [])) + if len(maintainers) == 0: + maintainers = [dict(name="No maintainers", email=None, github=None)] platforms = [ - type(platform) == str and platform or None + platform for platform in data["meta"].get("platforms", []) + if type(platform) == str and platform in ALLOWED_PLATFORMS ] - attr_set = None + attr_set = "No package set" if "." in attr_name: - attr_set = attr_name.split(".")[0] + maybe_attr_set = attr_name.split(".")[0] if ( - not attr_set.endswith("Packages") - and not attr_set.endswith("Plugins") - and not attr_set.endswith("Extensions") + maybe_attr_set.endswith("Packages") + or maybe_attr_set.endswith("Plugins") + or maybe_attr_set.endswith("Extensions") ): - attr_set = None + attr_set = maybe_attr_set hydra = None if data["name"] in evaluation_builds: @@ -492,8 +486,10 @@ def get_packages(evaluation, evaluation_builds): package_longDescription=package_longDescription, package_longDescription_reverse=field_reverse(package_longDescription), package_license=licenses, + package_license_set=[i["fullName"] for i in licenses], package_maintainers=maintainers, - package_platforms=[i for i in platforms if i], + package_maintainers_set=[i["name"] for i in maintainers if i["name"]], + package_platforms=platforms, package_position=position, package_homepage=data["meta"].get("homepage"), package_system=data["system"], diff --git a/src/Main.elm b/src/Main.elm index 0fdfb91..2d21c1b 100644 --- a/src/Main.elm +++ b/src/Main.elm @@ -32,7 +32,6 @@ import Page.Packages import Route import Search import Url -import Url.Builder @@ -129,6 +128,7 @@ attemptQuery (( model, _ ) as pair) = (Maybe.withDefault "" searchModel.query) searchModel.from searchModel.size + searchModel.buckets searchModel.sort ] ) @@ -365,7 +365,7 @@ viewNavigation route = f searchArgs _ -> - f <| Route.SearchArgs Nothing Nothing Nothing Nothing Nothing Nothing + f <| Route.SearchArgs Nothing Nothing Nothing Nothing Nothing Nothing Nothing in li [] [ a [ href "https://nixos.org" ] [ text "Back to nixos.org" ] ] :: List.map diff --git a/src/Page/Options.elm b/src/Page/Options.elm index fefae4a..91b505f 100644 --- a/src/Page/Options.elm +++ b/src/Page/Options.elm @@ -14,24 +14,17 @@ import Html ( Html , a , code - , dd , div - , dl - , dt + , li , pre - , span - , table - , tbody - , td + , strong , text - , th - , thead - , tr + , ul ) import Html.Attributes exposing ( class - , colspan + , classList , href , target ) @@ -42,8 +35,6 @@ import Html.Events import Html.Parser import Html.Parser.Util import Json.Decode -import Json.Encode -import Regex import Route import Search @@ -53,7 +44,7 @@ import Search type alias Model = - Search.Model ResultItemSource + Search.Model ResultItemSource ResultAggregations type alias ResultItemSource = @@ -66,6 +57,16 @@ type alias ResultItemSource = } +type alias ResultAggregations = + { all : AggregationsAll + } + + +type alias AggregationsAll = + { doc_count : Int + } + + init : Route.SearchArgs -> Maybe Model -> ( Model, Cmd Msg ) init searchArgs model = let @@ -82,7 +83,7 @@ init searchArgs model = type Msg - = SearchMsg (Search.Msg ResultItemSource) + = SearchMsg (Search.Msg ResultItemSource ResultAggregations) update : @@ -111,125 +112,62 @@ update navKey msg model = view : Model -> Html Msg view model = Search.view { toRoute = Route.Options, categoryName = "options" } - "Search NixOS options" + [ text "Search more than " + , strong [] [ text "10 000 options" ] + ] model viewSuccess + viewBuckets SearchMsg +viewBuckets : + Maybe String + -> Search.SearchResult ResultItemSource ResultAggregations + -> List (Html Msg) +viewBuckets _ _ = + [] + + viewSuccess : String + -> Bool -> Maybe String - -> Search.SearchResult ResultItemSource + -> List (Search.ResultItem ResultItemSource) -> Html Msg -viewSuccess channel show result = - div [ class "search-result" ] - [ table [ class "table table-hover" ] - [ thead [] - [ tr [] - [ th [] [ text "Option name" ] - ] - ] - , tbody - [] - (List.concatMap - (viewResultItem channel show) - result.hits.hits - ) - ] - ] +viewSuccess channel showNixOSDetails show hits = + ul [] + (List.map + (viewResultItem channel showNixOSDetails show) + hits + ) viewResultItem : String + -> Bool -> Maybe String -> Search.ResultItem ResultItemSource - -> List (Html Msg) -viewResultItem channel show item = - let - packageDetails = - if Just item.source.name == show then - [ td [ colspan 1 ] [ viewResultItemDetails channel item ] - ] - - else - [] - - open = - SearchMsg (Search.ShowDetails item.source.name) - in - [] - -- DEBUG: |> List.append - -- DEBUG: [ tr [] - -- DEBUG: [ td [ colspan 1 ] - -- DEBUG: [ div [] [ text <| "score: " ++ String.fromFloat (Maybe.withDefault 0 item.score) ] - -- DEBUG: , div [] - -- DEBUG: [ text <| - -- DEBUG: "matched queries: " - -- DEBUG: , ul [] - -- DEBUG: (item.matched_queries - -- DEBUG: |> Maybe.withDefault [] - -- DEBUG: |> List.sort - -- DEBUG: |> List.map (\q -> li [] [ text q ]) - -- DEBUG: ) - -- DEBUG: ] - -- DEBUG: ] - -- DEBUG: ] - -- DEBUG: ] - |> List.append - (tr - [ onClick open - , Search.elementId item.source.name - ] - [ td [] - [ Html.button - [ class "search-result-button" - , Html.Events.custom "click" <| - Json.Decode.succeed - { message = open - , stopPropagation = True - , preventDefault = True - } - ] - [ text item.source.name - ] - ] - ] - :: packageDetails - ) - - -viewResultItemDetails : - String - -> Search.ResultItem ResultItemSource -> Html Msg -viewResultItemDetails channel item = +viewResultItem channel _ show item = let - default = - "Not given" - - asText value = - span [] <| + showHtml value = + div [] <| case Html.Parser.run value of Ok nodes -> Html.Parser.Util.toVirtualDom nodes - Err e -> + Err _ -> [] + default = + "Not given" + asPre value = pre [] [ text value ] - asCode value = - code [] [ text value ] - asPreCode value = - div [] [ pre [] [ code [] [ text value ] ] ] - - encodeHtml value = - value - |> String.replace "<" "<" - |> String.replace ">" ">" + div [] [ pre [] [ code [ class "code-block" ] [ text value ] ] ] githubUrlPrefix branch = "https://github.com/NixOS/nixpkgs/blob/" ++ branch ++ "/" @@ -248,19 +186,11 @@ viewResultItemDetails channel item = [ href <| githubUrlPrefix channelDetails.branch ++ (value |> String.replace ":" "#L") , target "_blank" ] - [ text <| value ] + [ text value ] Nothing -> text <| cleanPosition value - wrapped wrapWith value = - case value of - "" -> - wrapWith <| "\"" ++ value ++ "\"" - - _ -> - wrapWith value - withEmpty wrapWith maybe = case maybe of Nothing -> @@ -271,21 +201,56 @@ viewResultItemDetails channel item = Just value -> wrapWith value + + wrapped wrapWith value = + case value of + "" -> + wrapWith <| "\"" ++ value ++ "\"" + + _ -> + wrapWith value + + showDetails = + if Just item.source.name == show then + div [ Html.Attributes.map SearchMsg Search.trapClick ] + [ div [] [ text "Default value" ] + , div [] [ withEmpty (wrapped asPreCode) item.source.default ] + , div [] [ text "Type" ] + , div [] [ withEmpty asPre item.source.type_ ] + , div [] [ text "Example" ] + , div [] [ withEmpty (wrapped asPreCode) item.source.example ] + , div [] [ text "Declared in" ] + , div [] [ withEmpty asGithubLink item.source.source ] + ] + |> Just + + else + Nothing + + toggle = + SearchMsg (Search.ShowDetails item.source.name) + + isOpen = + Just item.source.name == show in - dl [ class "dl-horizontal" ] - [ dt [] [ text "Name" ] - , dd [] [ withEmpty asText (Just (encodeHtml item.source.name)) ] - , dt [] [ text "Description" ] - , dd [] [ withEmpty asText item.source.description ] - , dt [] [ text "Default value" ] - , dd [] [ withEmpty (wrapped asPreCode) item.source.default ] - , dt [] [ text "Type" ] - , dd [] [ withEmpty asPre item.source.type_ ] - , dt [] [ text "Example value" ] - , dd [] [ withEmpty (wrapped asPreCode) item.source.example ] - , dt [] [ text "Declared in" ] - , dd [] [ withEmpty asGithubLink item.source.source ] + li + [ class "option" + , classList [ ( "opened", isOpen ) ] + , Search.elementId item.source.name ] + <| + List.filterMap identity + [ Just <| + Html.button + [ class "search-result-button" + , onClick toggle + ] + [ text item.source.name ] + , Maybe.map showHtml item.source.description + , Just <| + Search.showMoreButton toggle isOpen + , showDetails + ] @@ -298,9 +263,10 @@ makeRequest : -> String -> Int -> Int + -> Maybe String -> Search.Sort -> Cmd Msg -makeRequest options channel query from size sort = +makeRequest options channel query from size _ sort = Search.makeRequest (Search.makeRequestBody (String.trim query) @@ -309,6 +275,8 @@ makeRequest options channel query from size sort = sort "option" "option_name" + [] + [] [ ( "option_name", 6.0 ) , ( "option_name_query", 3.0 ) , ( "option_description", 1.0 ) @@ -316,6 +284,7 @@ makeRequest options channel query from size sort = ) ("latest-" ++ String.fromInt options.mappingSchemaVersion ++ "-" ++ channel) decodeResultItemSource + decodeResultAggregations options Search.QueryResponse (Just "query-options") @@ -335,3 +304,15 @@ decodeResultItemSource = (Json.Decode.field "option_default" (Json.Decode.nullable Json.Decode.string)) (Json.Decode.field "option_example" (Json.Decode.nullable Json.Decode.string)) (Json.Decode.field "option_source" (Json.Decode.nullable Json.Decode.string)) + + +decodeResultAggregations : Json.Decode.Decoder ResultAggregations +decodeResultAggregations = + Json.Decode.map ResultAggregations + (Json.Decode.field "all" decodeResultAggregationsAll) + + +decodeResultAggregationsAll : Json.Decode.Decoder AggregationsAll +decodeResultAggregationsAll = + Json.Decode.map AggregationsAll + (Json.Decode.field "doc_count" Json.Decode.int) diff --git a/src/Page/Packages.elm b/src/Page/Packages.elm index e9189b7..443b6fd 100644 --- a/src/Page/Packages.elm +++ b/src/Page/Packages.elm @@ -13,39 +13,33 @@ import Html exposing ( Html , a - , code - , dd , div - , dl - , dt + , em + , h4 , li + , p , pre - , table - , tbody - , td + , span + , strong , text - , th - , thead - , tr , ul ) import Html.Attributes exposing ( class - , colspan + , classList , href + , id , target ) -import Html.Events - exposing - ( onClick - ) +import Html.Events exposing (onClick) import Json.Decode import Json.Decode.Pipeline import Json.Encode import Regex import Route import Search +import Utils @@ -53,7 +47,7 @@ import Search type alias Model = - Search.Model ResultItemSource + Search.Model ResultItemSource ResultAggregations type alias ResultItemSource = @@ -103,6 +97,51 @@ type alias ResultPackageHydraPath = } +type alias ResultAggregations = + { all : Aggregations + , package_platforms : Search.Aggregation + , package_attr_set : Search.Aggregation + , package_maintainers_set : Search.Aggregation + , package_license_set : Search.Aggregation + } + + +type alias Aggregations = + { doc_count : Int + , package_platforms : Search.Aggregation + , package_attr_set : Search.Aggregation + , package_maintainers_set : Search.Aggregation + , package_license_set : Search.Aggregation + } + + +type alias Buckets = + { packageSets : List String + , licenses : List String + , maintainers : List String + , platforms : List String + } + + +emptyBuckets : Buckets +emptyBuckets = + { packageSets = [] + , licenses = [] + , maintainers = [] + , platforms = [] + } + + +initBuckets : + Maybe String + -> Buckets +initBuckets bucketsAsString = + bucketsAsString + |> Maybe.map (Json.Decode.decodeString decodeBuckets) + |> Maybe.andThen Result.toMaybe + |> Maybe.withDefault emptyBuckets + + init : Route.SearchArgs -> Maybe Model -> ( Model, Cmd Msg ) init searchArgs model = let @@ -119,7 +158,7 @@ init searchArgs model = type Msg - = SearchMsg (Search.Msg ResultItemSource) + = SearchMsg (Search.Msg ResultItemSource ResultAggregations) update : @@ -148,259 +187,369 @@ update navKey msg model = view : Model -> Html Msg view model = Search.view { toRoute = Route.Packages, categoryName = "packages" } - "Search NixOS packages" + [ text "Search more than " + , strong [] [ text "80 000 packages" ] + ] model viewSuccess + viewBuckets SearchMsg +viewBuckets : + Maybe String + -> Search.SearchResult ResultItemSource ResultAggregations + -> List (Html Msg) +viewBuckets bucketsAsString result = + let + initialBuckets = + initBuckets bucketsAsString + + selectedBucket = + initialBuckets + + createBucketsMsg getBucket mergeBuckets value = + value + |> Utils.toggleList (getBucket initialBuckets) + |> mergeBuckets initialBuckets + |> encodeBuckets + |> Json.Encode.encode 0 + |> Search.BucketsChange + |> SearchMsg + + sortBuckets items = + items + |> List.sortBy .doc_count + |> List.reverse + in + [] + |> viewBucket + "Package sets" + (result.aggregations.package_attr_set.buckets |> sortBuckets) + (createBucketsMsg .packageSets (\s v -> { s | packageSets = v })) + selectedBucket.packageSets + |> viewBucket + "Licenses" + (result.aggregations.package_license_set.buckets |> sortBuckets) + (createBucketsMsg .licenses (\s v -> { s | licenses = v })) + selectedBucket.licenses + |> viewBucket + "Maintainers" + (result.aggregations.package_maintainers_set.buckets |> sortBuckets) + (createBucketsMsg .maintainers (\s v -> { s | maintainers = v })) + selectedBucket.maintainers + |> viewBucket + "Platforms" + (result.aggregations.package_platforms.buckets |> sortBuckets) + (createBucketsMsg .platforms (\s v -> { s | platforms = v })) + selectedBucket.platforms + + +viewBucket : + String + -> List Search.AggregationsBucketItem + -> (String -> Msg) + -> List String + -> List (Html Msg) + -> List (Html Msg) +viewBucket title buckets searchMsgFor selectedBucket sets = + List.append + sets + (if List.isEmpty buckets then + [] + + else + [ li [] + [ ul [] + (List.append + [ li [ class "header" ] [ text title ] ] + (List.map + (\bucket -> + li [] + [ a + [ href "#" + , onClick <| searchMsgFor bucket.key + , classList + [ ( "selected" + , List.member bucket.key selectedBucket + ) + ] + ] + [ span [] [ text bucket.key ] + , span [] [ span [ class "badge" ] [ text <| String.fromInt bucket.doc_count ] ] + ] + ] + ) + buckets + ) + ) + ] + ] + ) + + viewSuccess : String + -> Bool -> Maybe String - -> Search.SearchResult ResultItemSource + -> List (Search.ResultItem ResultItemSource) -> Html Msg -viewSuccess channel show result = - div [ class "search-result" ] - [ table [ class "table table-hover" ] - [ thead [] - [ tr [] - [ th [] [ text "Attribute name" ] - , th [] [ text "Name" ] - , th [] [ text "Version" ] - , th [] [ text "Description" ] - ] - ] - , tbody - [] - (List.concatMap - (viewResultItem channel show) - result.hits.hits - ) - ] - ] +viewSuccess channel showNixOSDetails show hits = + ul [] + (List.map + (viewResultItem channel showNixOSDetails show) + hits + ) viewResultItem : String + -> Bool -> Maybe String -> Search.ResultItem ResultItemSource - -> List (Html Msg) -viewResultItem channel show item = - let - packageDetails = - if Just item.source.attr_name == show then - [ td [ colspan 4 ] [ viewResultItemDetails channel item ] - ] - - else - [] - - open = - SearchMsg (Search.ShowDetails item.source.attr_name) - in - [] - -- DEBUG: |> List.append - -- DEBUG: [ tr [] - -- DEBUG: [ td [ colspan 4 ] - -- DEBUG: [ div [] - -- DEBUG: [ text <| - -- DEBUG: "score: " - -- DEBUG: ++ (item.score - -- DEBUG: |> Maybe.map String.fromFloat - -- DEBUG: |> Maybe.withDefault "No score" - -- DEBUG: ) - -- DEBUG: ] - -- DEBUG: , div [] - -- DEBUG: [ text <| - -- DEBUG: "matched queries: " - -- DEBUG: , ul [] - -- DEBUG: (item.matched_queries - -- DEBUG: |> Maybe.withDefault [] - -- DEBUG: |> List.sort - -- DEBUG: |> List.map (\q -> li [] [ text q ]) - -- DEBUG: ) - -- DEBUG: ] - -- DEBUG: ] - -- DEBUG: ] - -- DEBUG: ] - |> List.append - (tr - [ onClick open - , Search.elementId item.source.attr_name - ] - [ td [] - [ Html.button - [ class "search-result-button" - , Html.Events.custom "click" <| - Json.Decode.succeed - { message = open - , stopPropagation = True - , preventDefault = True - } - ] - [ text item.source.attr_name ] - ] - , td [] [ text item.source.pname ] - , td [] [ text item.source.pversion ] - , td [] [ text <| Maybe.withDefault "" item.source.description ] - ] - :: packageDetails - ) - - -viewResultItemDetails : - String - -> Search.ResultItem ResultItemSource -> Html Msg -viewResultItemDetails channel item = +viewResultItem channel showNixOSDetails show item = let - default = - "Not specified" - - asText = - text - - asLink value = - a [ href value ] [ text value ] - - githubUrlPrefix branch = - "https://github.com/NixOS/nixpkgs/blob/" ++ branch ++ "/" - cleanPosition = Regex.fromString "^[0-9a-f]+\\.tar\\.gz\\/" |> Maybe.withDefault Regex.never >> (\reg -> Regex.replace reg (\_ -> "")) - asGithubLink value = - case Search.channelDetailsFromId channel of - Just channelDetails -> - a - [ href <| githubUrlPrefix channelDetails.branch ++ (value |> String.replace ":" "#L" |> cleanPosition) - , target "_blank" - ] - [ text <| cleanPosition value ] + createGithubUrl branch value = + let + uri = + value + |> String.replace ":" "#L" + |> cleanPosition + in + "https://github.com/NixOS/nixpkgs/blob/" ++ branch ++ "/" ++ uri - Nothing -> - text <| cleanPosition value - - mainPlatforms platform = - List.member platform - [ "x86_64-linux" - , "aarch64-linux" - , "x86_64-darwin" - , "i686-linux" + createShortDetailsItem title url = + a + [ href url + , target "_blank" ] + [ text title ] - getHydraDetailsForPlatform hydra platform = - hydra - |> Maybe.andThen - (\hydras -> - hydras - |> List.filter (\x -> x.platform == platform) + shortPackageDetails = + ul [] + ((item.source.position + |> Maybe.map + (\position -> + case Search.channelDetailsFromId channel of + Nothing -> + [] + + Just channelDetails -> + [ li [ trapClick ] + [ createShortDetailsItem + "Source" + (createGithubUrl channelDetails.branch position) + ] + ] + ) + |> Maybe.withDefault [] + ) + |> List.append + (item.source.homepage |> List.head - ) + |> Maybe.map + (\x -> + [ li [ trapClick ] + [ createShortDetailsItem "Homepage" x ] + ] + ) + |> Maybe.withDefault [] + ) + |> List.append + (item.source.licenses + |> List.filterMap + (\license -> + case ( license.fullName, license.url ) of + ( Nothing, Nothing ) -> + Nothing - showPlatforms hydra platforms = - platforms - |> List.filter mainPlatforms - |> List.map (showPlatform hydra) + ( Just fullName, Nothing ) -> + Just (text fullName) - showPlatform hydra platform = - case - ( getHydraDetailsForPlatform hydra platform - , Search.channelDetailsFromId channel + ( Nothing, Just url ) -> + Just (createShortDetailsItem "Unknown" url) + + ( Just fullName, Just url ) -> + Just (createShortDetailsItem fullName url) + ) + |> List.intersperse (text ", ") + |> (\x -> [ li [] (List.append [ text "Licenses: " ] x) ]) + ) + |> List.append + [ text "Name: " + , li [] [ text item.source.pname ] + , text "Version: " + , li [] [ text item.source.pversion ] + ] ) - of - ( Just hydraDetails, _ ) -> - a - [ href <| "https://hydra.nixos.org/build/" ++ String.fromInt hydraDetails.build_id - ] - [ text platform - ] - - ( Nothing, Just channelDetails ) -> - a - [ href <| "https://hydra.nixos.org/job/" ++ channelDetails.jobset ++ "/nixpkgs." ++ item.source.attr_name ++ "." ++ platform - ] - [ text platform - ] - - ( _, _ ) -> - text platform - - showLicence license = - case ( license.fullName, license.url ) of - ( Nothing, Nothing ) -> - text default - - ( Just fullName, Nothing ) -> - text fullName - - ( Nothing, Just url ) -> - a [ href url ] [ text default ] - - ( Just fullName, Just url ) -> - a [ href url ] [ text fullName ] showMaintainer maintainer = - a - [ href <| - case maintainer.github of - Just github -> - "https://github.com/" ++ github + li [] + [ a + [ href <| + case maintainer.github of + Just github -> + "https://github.com/" ++ github - Nothing -> - "#" + Nothing -> + "#" + ] + [ text <| Maybe.withDefault "" maintainer.name ++ " <" ++ Maybe.withDefault "" maintainer.email ++ ">" ] ] - [ text <| Maybe.withDefault "" maintainer.name ++ " <" ++ Maybe.withDefault "" maintainer.email ++ ">" ] - asPre value = - pre [] [ text value ] + showPlatform platform = + case Search.channelDetailsFromId channel of + Just channelDetails -> + let + url = + "https://hydra.nixos.org/job/" ++ channelDetails.jobset ++ "/nixpkgs." ++ item.source.attr_name ++ "." ++ platform + in + li [] + [ a + [ href url + ] + [ text platform ] + ] - asCode value = - code [] [ text value ] - - asList list = - case list of - [] -> - asPre default - - _ -> - ul [ class "inline" ] <| List.map (\i -> li [] [ i ]) list - - withEmpty wrapWith maybe = - case maybe of Nothing -> - asPre default + li [] [ text platform ] - Just "" -> - asPre default + maintainersAndPlatforms = + [ div [] + [ div [] + (List.append [ h4 [] [ text "Maintainers" ] ] + (if List.isEmpty item.source.maintainers then + [ p [] [ text "This package has no maintainers." ] ] - Just value -> - wrapWith value + else + [ ul [] (List.map showMaintainer item.source.maintainers) ] + ) + ) + , div [] + (List.append [ h4 [] [ text "Platforms" ] ] + (if List.isEmpty item.source.platforms then + [ p [] [ text "This package is not available on any platform." ] ] + + else + [ ul [] (List.map showPlatform item.source.platforms) ] + ) + ) + ] + ] + + longerPackageDetails = + if Just item.source.attr_name == show then + [ div [ trapClick ] + (maintainersAndPlatforms + |> List.append + (item.source.longDescription + |> Maybe.map (\desc -> [ p [] [ text desc ] ]) + |> Maybe.withDefault [] + ) + |> List.append + [ div [] + [ h4 [] + [ text "How to install " + , em [] [ text item.source.attr_name ] + , text "?" + ] + , ul [ class "nav nav-tabs" ] + [ li + [ classList + [ ( "active", showNixOSDetails ) + , ( "pull-right", True ) + ] + ] + [ a + [ href "#" + , Search.onClickStop <| + SearchMsg <| + Search.ShowNixOSDetails True + ] + [ text "On NixOS" ] + ] + , li + [ classList + [ ( "active", not showNixOSDetails ) + , ( "pull-right", True ) + ] + ] + [ a + [ href "#" + , Search.onClickStop <| + SearchMsg <| + Search.ShowNixOSDetails False + ] + [ text "On non-NixOS" ] + ] + ] + , div + [ class "tab-content" ] + [ div + [ classList + [ ( "active", not showNixOSDetails ) + ] + , class "tab-pane" + , id "package-details-nixpkgs" + ] + [ pre [ class "code-block" ] + [ text "nix-env -iA nixpkgs." + , strong [] [ text item.source.attr_name ] + ] + ] + , div + [ classList + [ ( "tab-pane", True ) + , ( "active", showNixOSDetails ) + ] + ] + [ pre [ class "code-block" ] + [ text <| "nix-env -iA nixos." + , strong [] [ text item.source.attr_name ] + ] + ] + ] + ] + ] + ) + ] + + else + [] + + toggle = + SearchMsg (Search.ShowDetails item.source.attr_name) + + trapClick = + Html.Attributes.map SearchMsg Search.trapClick + + isOpen = + Just item.source.attr_name == show in - dl [ class "dl-horizontal" ] - [ dt [] [ text "Attribute Name" ] - , dd [] [ withEmpty asText (Just item.source.attr_name) ] - , dt [] [ text "Name" ] - , dd [] [ withEmpty asText (Just item.source.pname) ] - , dt [] [ text "Install command" ] - , dd [] [ withEmpty asCode (Just ("nix-env -iA nixos." ++ item.source.attr_name)) ] - , dt [] [ text "Nix expression" ] - , dd [] [ withEmpty asGithubLink item.source.position ] - , dt [] [ text "Platforms" ] - , dd [] [ asList (showPlatforms item.source.hydra item.source.platforms) ] - , dt [] [ text "Homepage" ] - , dd [] <| List.intersperse (Html.text ", ") <| List.map asLink item.source.homepage - , dt [] [ text "Licenses" ] - , dd [] [ asList (List.map showLicence item.source.licenses) ] - , dt [] [ text "Maintainers" ] - , dd [] [ asList (List.map showMaintainer item.source.maintainers) ] - , dt [] [ text "Description" ] - , dd [] [ withEmpty asText item.source.description ] - , dt [] [ text "Long description" ] - , dd [] [ withEmpty asText item.source.longDescription ] + li + [ class "package" + , classList [ ( "opened", isOpen ) ] + , Search.elementId item.source.attr_name ] + ([] + |> List.append longerPackageDetails + |> List.append + [ Html.button + [ class "search-result-button" + , onClick toggle + ] + [ text item.source.attr_name ] + , div [] [ text <| Maybe.withDefault "" item.source.description ] + , shortPackageDetails + , Search.showMoreButton toggle isOpen + ] + ) @@ -413,9 +562,60 @@ makeRequest : -> String -> Int -> Int + -> Maybe String -> Search.Sort -> Cmd Msg -makeRequest options channel query from size sort = +makeRequest options channel query from size maybeBuckets sort = + let + currentBuckets = + initBuckets maybeBuckets + + aggregations = + [ ( "package_attr_set", currentBuckets.packageSets ) + , ( "package_license_set", currentBuckets.licenses ) + , ( "package_maintainers_set", currentBuckets.maintainers ) + , ( "package_platforms", currentBuckets.platforms ) + ] + + filterByBucket field value = + [ ( "term" + , Json.Encode.object + [ ( field + , Json.Encode.object + [ ( "value", Json.Encode.string value ) + , ( "_name", Json.Encode.string <| "filter_bucket_" ++ field ) + ] + ) + ] + ) + ] + + filterByBuckets = + [ ( "bool" + , Json.Encode.object + [ ( "must" + , Json.Encode.list Json.Encode.object + (List.map + (\( aggregation, buckets ) -> + [ ( "bool" + , Json.Encode.object + [ ( "should" + , Json.Encode.list Json.Encode.object <| + List.map + (filterByBucket aggregation) + buckets + ) + ] + ) + ] + ) + aggregations + ) + ) + ] + ) + ] + in Search.makeRequest (Search.makeRequestBody (String.trim query) @@ -424,6 +624,12 @@ makeRequest options channel query from size sort = sort "package" "package_attr_name" + [ "package_attr_set" + , "package_license_set" + , "package_maintainers_set" + , "package_platforms" + ] + filterByBuckets [ ( "package_attr_name", 9.0 ) , ( "package_pname", 6.0 ) , ( "package_attr_name_query", 4.0 ) @@ -433,6 +639,7 @@ makeRequest options channel query from size sort = ) ("latest-" ++ String.fromInt options.mappingSchemaVersion ++ "-" ++ channel) decodeResultItemSource + decodeResultAggregations options Search.QueryResponse (Just "query-packages") @@ -443,20 +650,25 @@ makeRequest options channel query from size sort = -- JSON -decodeHomepage : Json.Decode.Decoder (List String) -decodeHomepage = - Json.Decode.oneOf - -- null becomes [] (empty list) - [ Json.Decode.null [] - - -- "foo" becomes ["foo"] - , Json.Decode.map List.singleton Json.Decode.string - - -- arrays are decoded to list as expected - , Json.Decode.list Json.Decode.string +encodeBuckets : Buckets -> Json.Encode.Value +encodeBuckets options = + Json.Encode.object + [ ( "package_attr_set", Json.Encode.list Json.Encode.string options.packageSets ) + , ( "package_license_set", Json.Encode.list Json.Encode.string options.licenses ) + , ( "package_maintainers_set", Json.Encode.list Json.Encode.string options.maintainers ) + , ( "package_platforms", Json.Encode.list Json.Encode.string options.platforms ) ] +decodeBuckets : Json.Decode.Decoder Buckets +decodeBuckets = + Json.Decode.map4 Buckets + (Json.Decode.field "package_attr_set" (Json.Decode.list Json.Decode.string)) + (Json.Decode.field "package_license_set" (Json.Decode.list Json.Decode.string)) + (Json.Decode.field "package_maintainers_set" (Json.Decode.list Json.Decode.string)) + (Json.Decode.field "package_platforms" (Json.Decode.list Json.Decode.string)) + + decodeResultItemSource : Json.Decode.Decoder ResultItemSource decodeResultItemSource = Json.Decode.succeed ResultItemSource @@ -474,6 +686,20 @@ decodeResultItemSource = |> Json.Decode.Pipeline.required "package_hydra" (Json.Decode.nullable (Json.Decode.list decodeResultPackageHydra)) +decodeHomepage : Json.Decode.Decoder (List String) +decodeHomepage = + Json.Decode.oneOf + -- null becomes [] (empty list) + [ Json.Decode.null [] + + -- "foo" becomes ["foo"] + , Json.Decode.map List.singleton Json.Decode.string + + -- arrays are decoded to list as expected + , Json.Decode.list Json.Decode.string + ] + + decodeResultPackageLicense : Json.Decode.Decoder ResultPackageLicense decodeResultPackageLicense = Json.Decode.map2 ResultPackageLicense @@ -507,3 +733,23 @@ decodeResultPackageHydraPath = Json.Decode.map2 ResultPackageHydraPath (Json.Decode.field "output" Json.Decode.string) (Json.Decode.field "path" Json.Decode.string) + + +decodeResultAggregations : Json.Decode.Decoder ResultAggregations +decodeResultAggregations = + Json.Decode.map5 ResultAggregations + (Json.Decode.field "all" decodeAggregations) + (Json.Decode.field "package_platforms" Search.decodeAggregation) + (Json.Decode.field "package_attr_set" Search.decodeAggregation) + (Json.Decode.field "package_maintainers_set" Search.decodeAggregation) + (Json.Decode.field "package_license_set" Search.decodeAggregation) + + +decodeAggregations : Json.Decode.Decoder Aggregations +decodeAggregations = + Json.Decode.map5 Aggregations + (Json.Decode.field "doc_count" Json.Decode.int) + (Json.Decode.field "package_platforms" Search.decodeAggregation) + (Json.Decode.field "package_attr_set" Search.decodeAggregation) + (Json.Decode.field "package_maintainers_set" Search.decodeAggregation) + (Json.Decode.field "package_license_set" Search.decodeAggregation) diff --git a/src/Route.elm b/src/Route.elm index 2675aba..06f6ff8 100644 --- a/src/Route.elm +++ b/src/Route.elm @@ -28,6 +28,7 @@ type alias SearchArgs = , show : Maybe String , from : Maybe Int , size : Maybe Int + , buckets : Maybe String -- TODO: embed sort type , sort : Maybe String @@ -53,6 +54,7 @@ searchQueryParser url = Url.Parser.Query.string "show" Url.Parser.Query.int "from" Url.Parser.Query.int "size" + Url.Parser.Query.string "buckets" Url.Parser.Query.string "sort" @@ -63,6 +65,7 @@ searchArgsToUrl args = , Maybe.map (Url.Builder.string "show") args.show , Maybe.map (Url.Builder.int "from") args.from , Maybe.map (Url.Builder.int "size") args.size + , Maybe.map (Url.Builder.string "buckets") args.buckets , Maybe.map (Url.Builder.string "sort") args.sort ] , Maybe.map (Tuple.pair "query") args.query diff --git a/src/Search.elm b/src/Search.elm index 6b634ab..7772e0c 100644 --- a/src/Search.elm +++ b/src/Search.elm @@ -1,5 +1,7 @@ module Search exposing - ( Model + ( Aggregation + , AggregationsBucketItem + , Model , Msg(..) , Options , ResultItem @@ -7,13 +9,17 @@ module Search exposing , Sort(..) , channelDetailsFromId , channels + , decodeAggregation , decodeResult , elementId , fromSortId , init , makeRequest , makeRequestBody + , onClickStop , shouldLoad + , showMoreButton + , trapClick , update , view ) @@ -27,16 +33,14 @@ import Html , a , button , div - , em , form , h1 + , h2 , h4 , input - , label , li - , option , p - , select + , span , strong , text , ul @@ -50,7 +54,6 @@ import Html.Attributes , href , id , placeholder - , selected , type_ , value ) @@ -64,27 +67,28 @@ import Http import Json.Decode import Json.Encode import RemoteData -import Route exposing (Route) +import Route import Route.SearchQuery import Set import Task -import Url -import Url.Builder -type alias Model a = +type alias Model a b = { channel : String , query : Maybe String - , result : RemoteData.WebData (SearchResult a) + , result : RemoteData.WebData (SearchResult a b) , show : Maybe String , from : Int , size : Int + , buckets : Maybe String , sort : Sort + , showNixOSDetails : Bool } -type alias SearchResult a = +type alias SearchResult a b = { hits : ResultHits a + , aggregations : b } @@ -111,56 +115,82 @@ type alias ResultItem a = } +type alias Aggregation = + { doc_count_error_upper_bound : Int + , sum_other_doc_count : Int + , buckets : List AggregationsBucketItem + } + + +type alias AggregationsBucketItem = + { doc_count : Int + , key : String + } + + type Sort = Relevance | AlphabeticallyAsc | AlphabeticallyDesc -init : Route.SearchArgs -> Maybe (Model a) -> ( Model a, Cmd (Msg a) ) -init args model = +init : + Route.SearchArgs + -> Maybe (Model a b) + -> ( Model a b, Cmd (Msg a b) ) +init args maybeModel = let - channel = - model - |> Maybe.map (\x -> x.channel) - |> Maybe.withDefault defaultChannel + getField getFn default = + maybeModel + |> Maybe.map getFn + |> Maybe.withDefault default - from = - model - |> Maybe.map (\x -> x.from) - |> Maybe.withDefault 0 + modelChannel = + getField .channel defaultChannel - size = - model - |> Maybe.map (\x -> x.size) - |> Maybe.withDefault 30 + modelFrom = + getField .from 0 + + modelSize = + getField .size 50 in - ( { channel = Maybe.withDefault channel args.channel - , query = Maybe.andThen Route.SearchQuery.searchQueryToString args.query - , result = - model - |> Maybe.map (\x -> x.result) - |> Maybe.withDefault RemoteData.NotAsked + ( { channel = + args.channel + |> Maybe.withDefault modelChannel + , query = + args.query + |> Maybe.andThen Route.SearchQuery.searchQueryToString + , result = getField .result RemoteData.NotAsked , show = args.show - , from = Maybe.withDefault from args.from - , size = Maybe.withDefault size args.size + , from = + args.from + |> Maybe.withDefault modelFrom + , size = + args.size + |> Maybe.withDefault modelSize + , buckets = args.buckets , sort = args.sort |> Maybe.withDefault "" |> fromSortId |> Maybe.withDefault Relevance + , showNixOSDetails = False } |> ensureLoading , Browser.Dom.focus "search-query-input" |> Task.attempt (\_ -> NoOp) ) -shouldLoad : Model a -> Bool +shouldLoad : + Model a b + -> Bool shouldLoad model = model.result == RemoteData.Loading -ensureLoading : Model a -> Model a +ensureLoading : + Model a b + -> Model a b ensureLoading model = if model.query /= Nothing && model.query /= Just "" && List.member model.channel channels then { model | result = RemoteData.Loading } @@ -180,18 +210,22 @@ elementId str = -- --------------------------- -type Msg a +type Msg a b = NoOp - | SortChange String + | SortChange Sort + | BucketsChange String | ChannelChange String | QueryInput String | QueryInputSubmit - | QueryResponse (RemoteData.WebData (SearchResult a)) + | QueryResponse (RemoteData.WebData (SearchResult a b)) | ShowDetails String | ChangePage Int + | ShowNixOSDetails Bool -scrollToEntry : Maybe String -> Cmd (Msg a) +scrollToEntry : + Maybe String + -> Cmd (Msg a b) scrollToEntry val = let doScroll id = @@ -205,9 +239,9 @@ scrollToEntry val = update : Route.SearchRoute -> Browser.Navigation.Key - -> Msg a - -> Model a - -> ( Model a, Cmd (Msg a) ) + -> Msg a b + -> Model a b + -> ( Model a b, Cmd (Msg a b) ) update toRoute navKey msg model = case msg of NoOp -> @@ -215,9 +249,24 @@ update toRoute navKey msg model = , Cmd.none ) - SortChange sortId -> + SortChange sort -> { model - | sort = fromSortId sortId |> Maybe.withDefault Relevance + | sort = sort + , show = Nothing + , from = 0 + } + |> ensureLoading + |> pushUrl toRoute navKey + + BucketsChange buckets -> + { model + | buckets = + if buckets == "" then + Nothing + + else + Just buckets + , show = Nothing , from = 0 } |> ensureLoading @@ -226,6 +275,8 @@ update toRoute navKey msg model = ChannelChange channel -> { model | channel = channel + , show = Nothing + , buckets = Nothing , from = 0 } |> ensureLoading @@ -237,12 +288,18 @@ update toRoute navKey msg model = ) QueryInputSubmit -> - { model | from = 0 } + { model + | from = 0 + , show = Nothing + , buckets = Nothing + } |> ensureLoading |> pushUrl toRoute navKey QueryResponse result -> - ( { model | result = result } + ( { model + | result = result + } , scrollToEntry model.show ) @@ -262,8 +319,16 @@ update toRoute navKey msg model = |> ensureLoading |> pushUrl toRoute navKey + ShowNixOSDetails show -> + { model | showNixOSDetails = show } + |> pushUrl toRoute navKey -pushUrl : Route.SearchRoute -> Browser.Navigation.Key -> Model a -> ( Model a, Cmd msg ) + +pushUrl : + Route.SearchRoute + -> Browser.Navigation.Key + -> Model a b + -> ( Model a b, Cmd msg ) pushUrl toRoute navKey model = Tuple.pair model <| if model.query == Nothing || model.query == Just "" then @@ -273,7 +338,10 @@ pushUrl toRoute navKey model = Browser.Navigation.pushUrl navKey <| createUrl toRoute model -createUrl : Route.SearchRoute -> Model a -> String +createUrl : + Route.SearchRoute + -> Model a b + -> String createUrl toRoute model = Route.routeToString <| toRoute @@ -282,6 +350,7 @@ createUrl toRoute model = , show = model.show , from = Just model.from , size = Just model.size + , buckets = model.buckets , sort = Just <| toSortId model.sort } @@ -363,6 +432,47 @@ sortBy = ] +toAggregations : + List String + -> ( String, Json.Encode.Value ) +toAggregations bucketsFields = + let + fields = + List.map + (\field -> + ( field + , Json.Encode.object + [ ( "terms" + , Json.Encode.object + [ ( "field" + , Json.Encode.string field + ) + ] + ) + ] + ) + ) + bucketsFields + + allFields = + [ ( "all" + , Json.Encode.object + [ ( "global" + , Json.Encode.object [] + ) + , ( "aggregations" + , Json.Encode.object fields + ) + ] + ) + ] + in + ( "aggregations" + , Json.Encode.object <| + List.append fields allFields + ) + + toSortQuery : Sort -> String @@ -399,7 +509,7 @@ toSortTitle sort = "Alphabetically Descending" Relevance -> - "Relevance" + "Best match" toSortId : Sort -> String @@ -435,205 +545,321 @@ view : { toRoute : Route.SearchRoute , categoryName : String } - -> String - -> Model a - -> (String -> Maybe String -> SearchResult a -> Html b) - -> (Msg a -> b) - -> Html b -view { toRoute, categoryName } title model viewSuccess outMsg = - div - [ class "search-page" - ] - [ h1 [ class "page-header" ] [ text title ] - , div - [ class "search-input" - ] - [ form [ onSubmit (outMsg QueryInputSubmit) ] - [ p - [] - ([] - |> List.append - (if List.member model.channel channels then - [] - - else - [ p [ class "alert alert-error" ] - [ h4 [] [ text "Wrong channel selected!" ] - , text <| "Please select one of the channels above!" - ] - ] - ) - |> List.append - [ p [] - [ strong [] - [ text "Channel: " ] - , div - [ class "btn-group" - , attribute "data-toggle" "buttons-radio" - ] - (List.filterMap - (\channel_id -> - channelDetailsFromId channel_id - |> Maybe.map - (\channel -> - button - [ type_ "button" - , classList - [ ( "btn", True ) - , ( "active", channel.id == model.channel ) - ] - , onClick <| outMsg (ChannelChange channel.id) - ] - [ text channel.title ] - ) - ) - channels - ) - ] - ] - ) - , p - [ class "input-append" - ] - [ input - [ type_ "text" - , id "search-query-input" - , autofocus True - , placeholder <| "Search for " ++ categoryName - , onInput (outMsg << QueryInput) - , value <| Maybe.withDefault "" model.query - ] - [] - , div [ class "loader" ] [] - , div [ class "btn-group" ] - [ button [ class "btn", type_ "submit" ] - [ text "Search" ] - ] - ] - ] - ] - , div [] <| + -> List (Html c) + -> Model a b + -> + (String + -> Bool + -> Maybe String + -> List (ResultItem a) + -> Html c + ) + -> + (Maybe String + -> SearchResult a b + -> List (Html c) + ) + -> (Msg a b -> c) + -> Html c +view { toRoute, categoryName } title model viewSuccess viewBuckets outMsg = + let + resultStatus = case model.result of RemoteData.NotAsked -> - [ text "" ] + "not-asked" RemoteData.Loading -> - [ p [] [ em [] [ text "Searching..." ] ] - , div [] - [ viewSortSelection outMsg model - , viewPager outMsg model 0 toRoute - ] - , div [ class "loader-wrapper" ] [ div [ class "loader" ] [ text "Loading..." ] ] - , viewPager outMsg model 0 toRoute - ] + "loading" - RemoteData.Success result -> - if result.hits.total.value == 0 then - [ h4 [] [ text <| "No " ++ categoryName ++ " found!" ] - , text "How to " - , Html.a [ href "https://nixos.org/manual/nixpkgs/stable/#chap-quick-start"] [ text "add" ] - , text " or " - , a [ href "https://github.com/NixOS/nixpkgs/issues/new?assignees=&labels=0.kind%3A+packaging+request&template=packaging_request.md&title="] [ text "request" ] - , text " package to nixpkgs?" - ] + RemoteData.Success _ -> + "success" - else - [ p [] - [ em [] - [ text - ("Showing results " - ++ String.fromInt model.from - ++ "-" - ++ String.fromInt - (if model.from + model.size > result.hits.total.value then - result.hits.total.value - - else - model.from + model.size - ) - ++ " of " - ++ (if result.hits.total.value == 10000 then - "more than 10000 results, please provide more precise search terms." - - else - String.fromInt result.hits.total.value - ++ "." - ) - ) - ] - ] - , div [] - [ viewSortSelection outMsg model - , viewPager outMsg model result.hits.total.value toRoute - ] - , viewSuccess model.channel model.show result - , viewPager outMsg model result.hits.total.value toRoute - ] - - RemoteData.Failure error -> - let - ( errorTitle, errorMessage ) = - case error of - Http.BadUrl text -> - ( "Bad Url!", text ) - - Http.Timeout -> - ( "Timeout!", "Request to the server timeout." ) - - Http.NetworkError -> - ( "Network Error!", "A network request bonsaisearch.net domain failed. This is either due to a content blocker or a networking issue." ) - - Http.BadStatus code -> - ( "Bad Status", "Server returned " ++ String.fromInt code ) - - Http.BadBody text -> - ( "Bad Body", text ) - in - [ div [ class "alert alert-error" ] - [ h4 [] [ text errorTitle ] - , text errorMessage - ] - ] + RemoteData.Failure _ -> + "failure" + in + div [ class <| "search-page " ++ resultStatus ] + [ h1 [] title + , viewSearchInput outMsg categoryName model.channel model.query + , viewResult outMsg toRoute categoryName model viewSuccess viewBuckets ] -viewSortSelection : (Msg a -> b) -> Model a -> Html b -viewSortSelection outMsg model = - form [ class "form-horizontal pull-right sort-form" ] - [ div [ class "control-group sort-group" ] - [ label [ class "control-label" ] [ text "Sort by:" ] - , div - [ class "controls" ] - [ select - [ onInput (\x -> outMsg (SortChange x)) +viewResult : + (Msg a b -> c) + -> Route.SearchRoute + -> String + -> Model a b + -> + (String + -> Bool + -> Maybe String + -> List (ResultItem a) + -> Html c + ) + -> + (Maybe String + -> SearchResult a b + -> List (Html c) + ) + -> Html c +viewResult outMsg toRoute categoryName model viewSuccess viewBuckets = + case model.result of + RemoteData.NotAsked -> + div [] [ text "" ] + + RemoteData.Loading -> + div [ class "loader-wrapper" ] + [ div [ class "loader" ] [ text "Loading..." ] + , h2 [] [ text "Searching..." ] + ] + + RemoteData.Success result -> + let + buckets = + viewBuckets model.buckets result + in + if result.hits.total.value == 0 && List.length buckets == 0 then + viewNoResults categoryName + + else if List.length buckets > 0 then + div [ class "search-results" ] + [ ul [] buckets + , div [] + (viewResults model result viewSuccess toRoute outMsg categoryName) ] - (List.map - (\sort -> - option - [ selected (model.sort == sort) - , value (toSortId sort) - ] - [ text <| toSortTitle sort ] - ) - sortBy + + else + div [ class "search-results" ] + [ div [] + (viewResults model result viewSuccess toRoute outMsg categoryName) + ] + + RemoteData.Failure error -> + let + ( errorTitle, errorMessage ) = + case error of + Http.BadUrl text -> + ( "Bad Url!", text ) + + Http.Timeout -> + ( "Timeout!", "Request to the server timeout." ) + + Http.NetworkError -> + ( "Network Error!", "A network request bonsaisearch.net domain failed. This is either due to a content blocker or a networking issue." ) + + Http.BadStatus code -> + ( "Bad Status", "Server returned " ++ String.fromInt code ) + + Http.BadBody text -> + ( "Bad Body", text ) + in + div [] + [ div [ class "alert alert-error" ] + [ h4 [] [ text errorTitle ] + , text errorMessage + ] + ] + + +viewNoResults : + String + -> Html c +viewNoResults categoryName = + div [ class "search-no-results" ] + [ h2 [] [ text <| "No " ++ categoryName ++ " found!" ] + , text "How to " + , Html.a [ href "https://nixos.org/manual/nixpkgs/stable/#chap-quick-start" ] [ text "add" ] + , text " or " + , a [ href "https://github.com/NixOS/nixpkgs/issues/new?assignees=&labels=0.kind%3A+packaging+request&template=packaging_request.md&title=" ] [ text "request" ] + , text " package to nixpkgs?" + ] + + +viewSearchInput : + (Msg a b -> c) + -> String + -> String + -> Maybe String + -> Html c +viewSearchInput outMsg categoryName selectedChannel searchQuery = + form + [ onSubmit (outMsg QueryInputSubmit) + , class "search-input" + ] + [ div [] + [ div [] + [ input + [ type_ "text" + , id "search-query-input" + , autofocus True + , placeholder <| "Search for " ++ categoryName + , onInput (outMsg << QueryInput) + , value <| Maybe.withDefault "" searchQuery + ] + [] + ] + , button [ class "btn", type_ "submit" ] + [ text "Search" ] + ] + , div [] (viewChannels outMsg selectedChannel) + ] + + +viewChannels : + (Msg a b -> c) + -> String + -> List (Html c) +viewChannels outMsg selectedChannel = + List.append + [ div [] + [ h4 [] [ text "Channel: " ] + , div + [ class "btn-group" + , attribute "data-toggle" "buttons-radio" + ] + (List.filterMap + (\channelId -> + channelDetailsFromId channelId + |> Maybe.map + (\channel -> + button + [ type_ "button" + , classList + [ ( "btn", True ) + , ( "active", channel.id == selectedChannel ) + ] + , onClick <| outMsg (ChannelChange channel.id) + ] + [ text channel.title ] + ) ) + channels + ) + ] + ] + (if List.member selectedChannel channels then + [] + + else + [ p [ class "alert alert-error" ] + [ h4 [] [ text "Wrong channel selected!" ] + , text <| "Please select one of the channels above!" ] ] + ) + + +viewResults : + Model a b + -> SearchResult a b + -> + (String + -> Bool + -> Maybe String + -> List (ResultItem a) + -> Html c + ) + -> Route.SearchRoute + -> (Msg a b -> c) + -> String + -> List (Html c) +viewResults model result viewSuccess toRoute outMsg categoryName = + let + from = + String.fromInt (model.from + 1) + + to = + String.fromInt + (if model.from + model.size > result.hits.total.value then + result.hits.total.value + + else + model.from + model.size + ) + + total = + String.fromInt result.hits.total.value + in + [ div [] + [ Html.map outMsg <| viewSortSelection toRoute model + , div [] + (List.append + [ text "Showing results " + , text from + , text "-" + , text to + , text " of " + ] + (if result.hits.total.value == 10000 then + [ text "more than 10000." + , p [] [ text "Please provide more precise search terms." ] + ] + + else + [ strong [] + [ text total + , text " " + , text categoryName + ] + , text "." + ] + ) + ) + ] + , viewSuccess model.channel model.showNixOSDetails model.show result.hits.hits + , Html.map outMsg <| viewPager model result.hits.total.value + ] + + +viewSortSelection : + Route.SearchRoute + -> Model a b + -> Html (Msg a b) +viewSortSelection toRoute model = + div [ class "btn-group dropdown pull-right" ] + [ button + [ class "btn" + , attribute "data-toggle" "dropdown" + ] + [ span [] [ text <| "Sort: " ] + , span [ class "selected" ] [ text <| toSortTitle model.sort ] + , span [ class "caret" ] [] + ] + , ul [ class "dropdown-menu pull-right" ] + (List.append + [ li [ class " header" ] [ text "Sort options" ] + , li [ class "divider" ] [] + ] + (List.map + (\sort -> + li + [ classList + [ ( "selected", model.sort == sort ) + ] + ] + [ a + [ href "#" + , onClick <| SortChange sort + ] + [ text <| toSortTitle sort ] + ] + ) + sortBy + ) + ) ] viewPager : - (Msg a -> b) - -> Model a + Model a b -> Int - -> Route.SearchRoute - -> Html b -viewPager outMsg model total toRoute = - Html.map outMsg <| - ul [ class "pager" ] + -> Html (Msg a b) +viewPager model total = + div [] + [ ul [ class "pager" ] [ li [ classList [ ( "disabled", model.from == 0 ) ] ] [ a - [ Html.Events.onClick <| + [ onClick <| if model.from == 0 then NoOp @@ -644,7 +870,7 @@ viewPager outMsg model total toRoute = ] , li [ classList [ ( "disabled", model.from == 0 ) ] ] [ a - [ Html.Events.onClick <| + [ onClick <| if model.from - model.size < 0 then NoOp @@ -655,7 +881,7 @@ viewPager outMsg model total toRoute = ] , li [ classList [ ( "disabled", model.from + model.size >= total ) ] ] [ a - [ Html.Events.onClick <| + [ onClick <| if model.from + model.size >= total then NoOp @@ -666,7 +892,7 @@ viewPager outMsg model total toRoute = ] , li [ classList [ ( "disabled", model.from + model.size >= total ) ] ] [ a - [ Html.Events.onClick <| + [ onClick <| if model.from + model.size >= total then NoOp @@ -684,6 +910,7 @@ viewPager outMsg model total toRoute = [ text "Last" ] ] ] + ] @@ -698,10 +925,10 @@ type alias Options = } -filter_by_type : +filterByType : String -> List ( String, Json.Encode.Value ) -filter_by_type type_ = +filterByType type_ = [ ( "term" , Json.Encode.object [ ( "type" @@ -774,9 +1001,11 @@ makeRequestBody : -> Sort -> String -> String + -> List String + -> List ( String, Json.Encode.Value ) -> List ( String, Float ) -> Http.Body -makeRequestBody query from sizeRaw sort type_ sortField fields = +makeRequestBody query from sizeRaw sort type_ sortField bucketsFields filterByBuckets fields = let -- you can not request more then 10000 results otherwise it will return 404 size = @@ -788,62 +1017,49 @@ makeRequestBody query from sizeRaw sort type_ sortField fields = in Http.jsonBody (Json.Encode.object - [ ( "from" - , Json.Encode.int from - ) - , ( "size" - , Json.Encode.int size - ) - , toSortQuery sort sortField - , ( "query" - , Json.Encode.object - [ ( "bool" - , Json.Encode.object - [ ( "filter" - , Json.Encode.list Json.Encode.object - [ filter_by_type type_ ] - ) - , ( "must" - , Json.Encode.list Json.Encode.object - [ [ ( "dis_max" - , Json.Encode.object - [ ( "tie_breaker", Json.Encode.float 0.7 ) - , ( "queries" - , Json.Encode.list Json.Encode.object - (searchFields query fields) - -- [ [ ( "bool" - -- , Json.Encode.object - -- [ ( "must" - -- , Json.Encode.list Json.Encode.object <| - -- searchFields query fields - -- ) - -- ] - -- ) - -- ] - -- ] - -- , [ ( "bool" - -- , Json.Encode.object - -- [ ( "must" - -- , Json.Encode.list Json.Encode.object <| - -- searchFields - -- 0.8 - -- (String.words query |> List.map String.reverse) - -- ) - -- ] - -- ) - -- ] - --] - ) - ] - ) - ] - ] - ) - ] - ) - ] - ) - ] + (List.append + [ ( "from" + , Json.Encode.int from + ) + , ( "size" + , Json.Encode.int size + ) + , toSortQuery sort sortField + , toAggregations bucketsFields + , ( "query" + , Json.Encode.object + [ ( "bool" + , Json.Encode.object + [ ( "filter" + , Json.Encode.list Json.Encode.object + [ filterByType type_ ] + ) + , ( "must" + , Json.Encode.list Json.Encode.object + [ [ ( "dis_max" + , Json.Encode.object + [ ( "tie_breaker", Json.Encode.float 0.7 ) + , ( "queries" + , Json.Encode.list Json.Encode.object + (searchFields query fields) + ) + ] + ) + ] + ] + ) + ] + ) + ] + ) + ] + (if List.isEmpty filterByBuckets then + [] + + else + [ ( "post_filter", Json.Encode.object filterByBuckets ) ] + ) + ) ) @@ -851,11 +1067,12 @@ makeRequest : Http.Body -> String -> Json.Decode.Decoder a + -> Json.Decode.Decoder b -> Options - -> (RemoteData.WebData (SearchResult a) -> Msg a) + -> (RemoteData.WebData (SearchResult a b) -> Msg a b) -> Maybe String - -> Cmd (Msg a) -makeRequest body index decodeResultItemSource options responseMsg tracker = + -> Cmd (Msg a b) +makeRequest body index decodeResultItemSource decodeResultAggregations options responseMsg tracker = Http.riskyRequest { method = "POST" , headers = @@ -866,7 +1083,7 @@ makeRequest body index decodeResultItemSource options responseMsg tracker = , expect = Http.expectJson (RemoteData.fromResult >> responseMsg) - (decodeResult decodeResultItemSource) + (decodeResult decodeResultItemSource decodeResultAggregations) , timeout = Nothing , tracker = tracker } @@ -878,10 +1095,12 @@ makeRequest body index decodeResultItemSource options responseMsg tracker = decodeResult : Json.Decode.Decoder a - -> Json.Decode.Decoder (SearchResult a) -decodeResult decodeResultItemSource = - Json.Decode.map SearchResult + -> Json.Decode.Decoder b + -> Json.Decode.Decoder (SearchResult a b) +decodeResult decodeResultItemSource decodeResultAggregations = + Json.Decode.map2 SearchResult (Json.Decode.field "hits" (decodeResultHits decodeResultItemSource)) + (Json.Decode.field "aggregations" decodeResultAggregations) decodeResultHits : Json.Decode.Decoder a -> Json.Decode.Decoder (ResultHits a) @@ -908,3 +1127,60 @@ decodeResultItem decodeResultItemSource = (Json.Decode.field "_source" decodeResultItemSource) (Json.Decode.maybe (Json.Decode.field "text" Json.Decode.string)) (Json.Decode.maybe (Json.Decode.field "matched_queries" (Json.Decode.list Json.Decode.string))) + + +decodeAggregation : Json.Decode.Decoder Aggregation +decodeAggregation = + Json.Decode.map3 Aggregation + (Json.Decode.field "doc_count_error_upper_bound" Json.Decode.int) + (Json.Decode.field "sum_other_doc_count" Json.Decode.int) + (Json.Decode.field "buckets" (Json.Decode.list decodeAggregationBucketItem)) + + +decodeAggregationBucketItem : Json.Decode.Decoder AggregationsBucketItem +decodeAggregationBucketItem = + Json.Decode.map2 AggregationsBucketItem + (Json.Decode.field "doc_count" Json.Decode.int) + (Json.Decode.field "key" Json.Decode.string) + + + +-- Html Helper elemetnts + + +showMoreButton : msg -> Bool -> Html msg +showMoreButton toggle isOpen = + div [ class "result-item-show-more-wrapper" ] + [ a + [ href "#" + , onClick toggle + , class "result-item-show-more" + ] + [ text <| + if isOpen then + "▲▲▲ Hide package details ▲▲▲" + + else + "▾▾▾ Show more package details ▾▾▾" + ] + ] + + + +-- Html Event Helpers + + +onClickStop : msg -> Html.Attribute msg +onClickStop message = + Html.Events.custom "click" <| + Json.Decode.succeed + { message = message + , stopPropagation = True + , preventDefault = True + } + + +trapClick : Html.Attribute (Msg a b) +trapClick = + Html.Events.stopPropagationOn "click" <| + Json.Decode.succeed ( NoOp, True ) diff --git a/src/Utils.elm b/src/Utils.elm new file mode 100644 index 0000000..9f0956c --- /dev/null +++ b/src/Utils.elm @@ -0,0 +1,13 @@ +module Utils exposing (toggleList) + + +toggleList : + List a + -> a + -> List a +toggleList list item = + if List.member item list then + List.filter (\x -> x /= item) list + + else + List.append list [ item ] diff --git a/src/index.html b/src/index.html index e8a0c69..f8a3423 100644 --- a/src/index.html +++ b/src/index.html @@ -20,11 +20,12 @@ - + + + diff --git a/src/index.js b/src/index.js index cd83fed..f32e29a 100644 --- a/src/index.js +++ b/src/index.js @@ -11,4 +11,4 @@ Elm.Main.init({ elasticsearchUsername : process.env.ELASTICSEARCH_USERNAME || 'z3ZFJ6y2mR', elasticsearchPassword : process.env.ELASTICSEARCH_PASSWORD || 'ds8CEvALPf9pui7XG' } -}); \ No newline at end of file +}); diff --git a/src/index.less b/src/index.less index c9e5553..7d8da92 100644 --- a/src/index.less +++ b/src/index.less @@ -1,7 +1,66 @@ +/* ------------------------------------------------------------------------- */ +/* -- Utils ---------------------------------------------------------------- */ +/* ------------------------------------------------------------------------- */ + +.terminal() { + background: #333; + color: #fff; + margin: 0; + + &:before { + content: "$ " + } +} + + +.search-result-item() { + .result-item-show-more-wrapper { + text-align: center; + } + + // show longer details link + .result-item-show-more { + margin: 0 auto; + display: none; + text-align: center; + text-decoration: none; + line-height: 1.5em; + color: #666; + background: #FFF; + padding: 0 1em; + position: relative; + top: 0.75em; + outline: none; + } + &.opened, + &:hover { + padding-bottom: 0; + + .result-item-show-more { + display: inline-block; + padding-top: 0.5em; + } + } +} + +/* ------------------------------------------------------------------------- */ +/* -- Layout --------------------------------------------------------------- */ +/* ------------------------------------------------------------------------- */ + body { position: relative; min-height: 100vh; - overflow-y: scroll; + overflow-y: auto; + + & > div:first-child { + position: relative; + min-height: 100vh; + } +} + +.code-block { + display: block; + cursor: text; } #content { @@ -30,91 +89,394 @@ header .navbar.navbar-static-top { } } -.search-input { - .input-append { - input { - font-size: 18px; - height: 40px; - width: 25em; - } - div.loader { - display: none; - z-index: 100; - position: absolute; - margin: 0; - top: 37px; - right: 125px; - font-size: 6px; - color: #999999; - } - button { - font-size: 24px; - height: 50px; - min-width: 4em; +// Search seatch +.search-page { + + &.not-asked { + + & > h1 { + margin-top: 2.5em; + margin-bottom: 0.8em; } } - form > p > strong { - vertical-align: middle; - font-size: 1.2em; - margin-left: 0.2em; - } -} -.search-result { - tbody > tr { - cursor: pointer; - } - tbody > td > dl > dd > ul.inline { - margin: 0; - li { - margin: 0; - padding: 0; - } - li::after { - content: ", "; - padding-right: 0.5em; - } - li:last-child::after { - content: ""; + + // Search section title title + & > h1 { + font-weight: normal; + font-size: 2.3em; + + &:before { + content: "\2315"; + display: inline-block; + font-size: 1.5em; + margin-right: 0.2em; + -moz-transform: scale(-1, 1); + -webkit-transform: scale(-1, 1); + -o-transform: scale(-1, 1); + -ms-transform: scale(-1, 1); + transform: scale(-1, 1); } } - tbody > td > dl > dd > pre { - background: transparent; - border: 0; - padding: 0; - line-height: 20px; - margin: 0; + + // Search input section + & > .search-input { + + // Search Input and Button + & > div:nth-child(1) { + display: grid; + grid-template-columns: auto 8em; + + & > div > input { + font-size: 18px; + height: 40px; + width: 100%; + } + + & > button { + font-size: 24px; + height: 50px; + min-width: 4em; + } + } + + // List of channels + & > div:nth-child(2) { + margin-bottom: 0.5em; + + // "Channels: " label + & > div > h4 { + display: inline; + vertical-align: middle; + font-size: 1.2em; + margin-left: 0.2em; + } + } } - tbody > td > dl > dt, - tbody > td > dl > dd { - margin-bottom: 1em; + + // Loader during loading the search results + & > .loader-wrapper > h2 { + position: absolute; + top: 3em; + width: 100%; + text-align: center; } + & > .search-no-results { + padding: 2em 1em; + text-align: center; + margin-bottom: 2em; + + & > h2 { + margin-top: 0; + } + } + + .search-result-button { - margin: 0; - padding: 0; - border: 0; - background: transparent; - display: inline; - font: inherit; - color: inherit; - text-align: inherit; + &:hover { + text-decoration: underline; + } + } + + & > .search-results { + display: flex; + flex-direction: row; + + // Buckets + & > ul { + list-style: none; + margin: 0 1em 0 0; + + & > li { + margin-bottom: 1em; + border: 1px solid #ccc; + padding: 1em; + border-radius: 4px; + + & > ul { + list-style: none; + margin: 0; + + & > li { + margin-bottom: 0.2em; + + &.header { + font-size: 1.2em; + font-weight: bold; + margin-bottom: 0.5em; + } + + & > a { + display: grid; + grid-template-columns: auto auto; + color: #333; + padding: 0.5em 0.5em 0.5em 1em; + text-decoration: none; + + &:hover { + text-decoration: none; + background: #eee; + border-radius: 4px; + } + + & > span:first-child { + overflow: hidden; + } + & > span:last-child { + text-align: right; + margin-left: 0.3em; + } + + &.selected { + background: #0081c2; + color: #FFF; + border-radius: 4px; + position: relative; + + &:after { + content: "\2715"; + font-size: 1.7em; + color: #fff; + position: absolute; + top: 4px; + right: 4px; + background: #0081c2; + bottom: 4px; + width: 1em; + padding: 2px; + } + + & > span:last-child { + display: none; + + } + } + } + } + } + } + } + + // Results section + & > div { + width: 100%; + + // Search results header + & > :nth-child(1) { + + // Dropdown to show sorting options + & > div:nth-child(1) { + + & > button { + & > .selected { + margin-right: 0.5em; + } + } + + & > ul > li { + + & > a { + padding: 3px 10px; + } + + & > a:before { + display: inline-block; + content: " "; + width: 24.5px; + } + + &.selected > a:before { + content: "\2714"; + } + } + + & > ul > li.header { + font-weight: bold; + padding: 3px 10px; + } + + & > ul > li.header:before, + & > ul > li.divider:before { + display: none; + } + } + + // Text that displays number of results + & > div:nth-child(2) { + font-size: 1.7em; + line-height: 1.3em; + + & > p { + font-size: 0.7em; + } + } + } + + // Search results list + & > :nth-child(2) { + list-style: none; + margin: 2em 0 0 0; + + // Result item + & > li { + border-bottom: 1px solid #ccc; + padding-bottom: 2em; + margin-bottom: 2em; + + &:last-child { + border-bottom: 0; + } + + // Attribute name or option name + & > :nth-child(1) { + background: inherit; + border: 0; + padding: 0; + color: #08c; + font-size: 1.5em; + margin-bottom: 0.5em; + text-align: left; + display: block; + } + + // Description + & > :nth-child(2) { + font-size: 1.2em; + margin-bottom: 0.5em; + text-align: left; + } + + &.package { + .search-result-item(); + + // short details of a pacakge + & > :nth-child(3) { + color: #666; + list-style: none; + text-align: left; + margin: 0; + + & > li { + display: inline-block; + margin-right: 1em; + + & > a { + color: #666; + } + } + & > li:last-child { + margin-right: 0; + } + } + + // longer details of a pacakge + & > :nth-child(5) { + margin: 2em 0 1em 1em; + text-align: left; + + // how to install a package + & > :nth-child(1) { + + h4 { + font-size: 1.2em; + line-height: 1em; + float: left; + } + + ul.nav-tabs { + margin: 0; + + & > li > a { + margin-right: 0; + } + } + + div.tab-content { + padding: 1em; + border: 1px solid #ddd; + border-top: 0; + } + + pre { + .terminal(); + } + + } + + // long description of a package + & > :nth-child(2) { + margin-top: 1em; + } + + // maintainers and platforms + & > :nth-child(3) { + margin-top: 1em; + display: grid; + grid-template-columns: auto auto; + } + } + } + + &.option { + .search-result-item(); + + // short details of a pacakge + & > :nth-child(4) { + margin: 2em 0 1em 1em; + display: grid; + grid-template-columns: 100px 1fr; + column-gap: 1em; + row-gap: 0.5em; + + & > div:nth-child(2n+1) { + font-weight: bold; + text-align: right; + } + + & > div:nth-child(2n) { + pre { + background: transparent; + margin: 0; + padding: 0; + border: 0; + vertical-align: inherit; + display: inline; + } + + pre code { + background: #333; + color: #fff; + padding: 0.5em + } + } + + } + } + } + + } + + // Search results footer + & > :nth-child(3) { + margin-top: 1em; + + & > ul > li > a { + cursor: pointer; + margin: 0 2px; + } + } + } } } -.sort-form, -.sort-form > .sort-group { - margin-bottom: 0; -} -.pager { - & > li > a { - cursor: pointer; - margin: 0 2px; - } -} +/* ------------------------------------------------------------------------- */ +/* -- Loader --------------------------------------------------------------- */ +/* ------------------------------------------------------------------------- */ .loader-wrapper { height: 200px; overflow: hidden; + position: relative; } .loader, .loader:before,