diff --git a/src/Api.elm b/src/Api.elm index e0e435e..5cf6a1d 100644 --- a/src/Api.elm +++ b/src/Api.elm @@ -61,9 +61,18 @@ namespace perspective fqn = Endpoint [ "namespaces", FQN.toString fqn ] queryParams -projects : Endpoint -projects = - Endpoint [ "projects" ] [] +projects : Maybe String -> Endpoint +projects owner = + let + queryParams = + case owner of + Just owner_ -> + [ string "owner" owner_ ] + + Nothing -> + [] + in + Endpoint [ "projects" ] queryParams getDefinition : Perspective -> List String -> Endpoint diff --git a/src/Definition/Readme.elm b/src/Definition/Readme.elm index ce71fc8..d4855f4 100644 --- a/src/Definition/Readme.elm +++ b/src/Definition/Readme.elm @@ -2,10 +2,9 @@ module Definition.Readme exposing (..) import Definition.Doc as Doc exposing (Doc, DocFoldToggles, FoldId) import Definition.Reference exposing (Reference) -import Html exposing (Html, div, header, text) +import Html exposing (Html, div) import Html.Attributes exposing (class) import Json.Decode as Decode -import UI.Icon as Icon {-| Represent the Readme Doc definition of a namespace. This is typically @@ -28,9 +27,7 @@ view : -> Html msg view refToMsg toggleFoldMsg docFoldToggles (Readme doc) = div [ class "readme" ] - [ header [] [ Icon.view Icon.doc, text "README" ] - , Doc.view refToMsg toggleFoldMsg docFoldToggles doc - ] + [ Doc.view refToMsg toggleFoldMsg docFoldToggles doc ] diff --git a/src/PerspectiveLanding.elm b/src/PerspectiveLanding.elm index fcb2e99..90fb66b 100644 --- a/src/PerspectiveLanding.elm +++ b/src/PerspectiveLanding.elm @@ -170,7 +170,12 @@ view env model = Success (Namespace _ _ { readme }) -> case readme of Just r -> - container [ Readme.view OpenReference ToggleDocFold model r ] + container + [ div [ class "perspective-landing-readme" ] + [ header [] [ Icon.view Icon.doc, text "README" ] + , Readme.view OpenReference ToggleDocFold model r + ] + ] Nothing -> viewEmptyStateNamespace fqn diff --git a/src/UI/Card.elm b/src/UI/Card.elm index edbbc7f..1761449 100644 --- a/src/UI/Card.elm +++ b/src/UI/Card.elm @@ -4,18 +4,36 @@ import Html exposing (Html, div, h3, text) import Html.Attributes exposing (class) +type CardType + = Contained + | Uncontained + + type alias Card msg = - { title : Maybe String, items : List (Html msg) } + { type_ : CardType + , title : Maybe String + , items : List (Html msg) + } card : List (Html msg) -> Card msg card items = - { title = Nothing, items = items } + { type_ = Uncontained, title = Nothing, items = items } titled : String -> List (Html msg) -> Card msg titled title items = - { title = Just title, items = items } + { type_ = Uncontained, title = Just title, items = items } + + +withType : CardType -> Card msg -> Card msg +withType type_ card_ = + { card_ | type_ = type_ } + + +asContained : Card msg -> Card msg +asContained card_ = + { card_ | type_ = Contained } withTitle : String -> Card msg -> Card msg @@ -43,5 +61,13 @@ view card_ = Nothing -> card_.items + + typeClass = + case card_.type_ of + Contained -> + "contained" + + Uncontained -> + "uncontained" in - div [ class "card" ] items + div [ class "card", class typeClass ] items diff --git a/src/UnisonShare/App.elm b/src/UnisonShare/App.elm index 030101c..3eb4829 100644 --- a/src/UnisonShare/App.elm +++ b/src/UnisonShare/App.elm @@ -18,7 +18,7 @@ import KeyboardShortcut.KeyboardEvent as KeyboardEvent exposing (KeyboardEvent) import Namespace exposing (NamespaceDetails) import Perspective exposing (Perspective(..)) import PerspectiveLanding -import RemoteData +import RemoteData exposing (RemoteData(..)) import UI import UI.AppHeader as AppHeader import UI.Banner as Banner @@ -30,6 +30,7 @@ import UI.Sidebar as Sidebar import UI.Tooltip as Tooltip import UnisonShare.AppModal as AppModal import UnisonShare.Page.Catalog as Catalog +import UnisonShare.Page.UserPage as UserPage import UnisonShare.Route as Route exposing (Route) import Url exposing (Url) import Workspace @@ -46,6 +47,7 @@ type alias Model = , workspace : Workspace.Model , perspectiveLanding : PerspectiveLanding.Model , catalog : Catalog.Model + , userPage : UserPage.Model , appModal : AppModal.Model , keyboardShortcut : KeyboardShortcut.Model , env : Env @@ -78,7 +80,20 @@ init env route = |> Maybe.withDefault Cmd.none ( catalog, catalogCmd ) = - Catalog.init env + case route of + Route.Catalog -> + Catalog.init env + + _ -> + ( NotAsked, Cmd.none ) + + ( userPage, userPageCmd ) = + case route of + Route.User username -> + UserPage.init env username + + _ -> + ( NotAsked, Cmd.none ) model = { route = route @@ -90,6 +105,7 @@ init env route = , env = env , sidebarToggled = False , catalog = catalog + , userPage = userPage } in ( model @@ -97,6 +113,7 @@ init env route = [ Cmd.map CodebaseTreeMsg codebaseTreeCmd , Cmd.map WorkspaceMsg workspaceCmd , Cmd.map CatalogMsg catalogCmd + , Cmd.map UserPageMsg userPageCmd , fetchNamespaceDetailsCmd ] ) @@ -118,6 +135,7 @@ type Msg -- sub msgs | AppModalMsg AppModal.Msg | CatalogMsg Catalog.Msg + | UserPageMsg UserPage.Msg | WorkspaceMsg Workspace.Msg | PerspectiveLandingMsg PerspectiveLanding.Msg | CodebaseTreeMsg CodebaseTree.Msg @@ -156,6 +174,13 @@ update msg ({ env } as model) = in ( { model2 | catalog = catalog }, Cmd.map CatalogMsg cmd ) + Route.User username -> + let + ( userPage, cmd ) = + UserPage.init model.env username + in + ( { model2 | userPage = userPage }, Cmd.map UserPageMsg cmd ) + Route.Project params (Route.ProjectDefinition ref) -> let ( workspace, cmd ) = @@ -233,6 +258,13 @@ update msg ({ env } as model) = in ( { model | catalog = catalog }, Cmd.map CatalogMsg cmd ) + ( Route.User _, UserPageMsg uMsg ) -> + let + ( userPage, cmd ) = + UserPage.update env uMsg model.userPage + in + ( { model | userPage = userPage }, Cmd.map UserPageMsg cmd ) + ( Route.Project _ _, WorkspaceMsg wMsg ) -> let ( workspace, wCmd, outMsg ) = @@ -683,28 +715,39 @@ view model = , content = PageLayout.PageContent [ pageContent ] } - page = + ( pageId, page ) = case model.route of Route.Catalog -> - Html.map CatalogMsg (Catalog.view model.catalog) + ( "catalog-page", Html.map CatalogMsg (Catalog.view model.catalog) ) + + Route.User _ -> + ( "user-page", Html.map UserPageMsg (UserPage.view model.userPage) ) Route.Project _ Route.ProjectRoot -> - Html.map PerspectiveLandingMsg - (PerspectiveLanding.view - model.env - model.perspectiveLanding - ) - |> withSidebar - |> PageLayout.view + let + page_ = + Html.map PerspectiveLandingMsg + (PerspectiveLanding.view + model.env + model.perspectiveLanding + ) + |> withSidebar + |> PageLayout.view + in + ( "project-page", page_ ) Route.Project _ (Route.ProjectDefinition _) -> - Html.map WorkspaceMsg (Workspace.view model.workspace) - |> withSidebar - |> PageLayout.view + let + page_ = + Html.map WorkspaceMsg (Workspace.view model.workspace) + |> withSidebar + |> PageLayout.view + in + ( "project-page", page_ ) in { title = "Unison Share" , body = - [ div [ id "app" ] + [ div [ id "app", class pageId ] [ appHeader , page , Html.map AppModalMsg (AppModal.view model.env model.appModal) diff --git a/src/UnisonShare/Page/Catalog.elm b/src/UnisonShare/Page/Catalog.elm index 33cec9f..22ed5e7 100644 --- a/src/UnisonShare/Page/Catalog.elm +++ b/src/UnisonShare/Page/Catalog.elm @@ -69,7 +69,7 @@ fetchCatalog env = |> Api.toTask env.apiBasePath Catalog.decodeCatalogMask |> Task.andThen (\catalog -> - Api.projects + Api.projects Nothing |> Api.toTask env.apiBasePath Project.decodeListings |> Task.map (\projects -> ( catalog, projects )) ) diff --git a/src/UnisonShare/Page/UserPage.elm b/src/UnisonShare/Page/UserPage.elm new file mode 100644 index 0000000..581f738 --- /dev/null +++ b/src/UnisonShare/Page/UserPage.elm @@ -0,0 +1,170 @@ +module UnisonShare.Page.UserPage exposing (..) + +import Api +import Definition.Doc as Doc +import Definition.Readme as Readme +import Definition.Reference exposing (Reference) +import Env exposing (Env) +import FullyQualifiedName as FQN +import Html exposing (Html, div, h1, text) +import Html.Attributes exposing (class) +import Http +import Perspective +import Project exposing (ProjectListing) +import RemoteData exposing (RemoteData(..), WebData) +import Task +import UI +import UI.Card as Card +import UI.Click as Click +import UI.PageLayout as PageLayout exposing (PageLayout) +import UnisonShare.Route as Route +import UnisonShare.User as User exposing (UserDetails, Username) + + + +-- MODEL + + +type alias LoadedModel = + { user : UserDetails + , docFoldToggles : Doc.DocFoldToggles + , projects : List ProjectListing + } + + +type alias Model = + WebData LoadedModel + + +init : Env -> Username -> ( Model, Cmd Msg ) +init env username = + ( Loading, fetchUser env username ) + + +{-| Fetch the Catalog in sequence by first fetching the doc, then the +projectListings and finally merging them into a Catalog +-} +fetchUser : Env -> Username -> Cmd Msg +fetchUser env username = + let + perspective = + Perspective.toCodebasePerspective env.perspective + + usernameFqn = + username |> User.usernameToString |> FQN.fromString + in + Api.namespace perspective usernameFqn + |> Api.toTask env.apiBasePath User.decodeDetails + |> Task.andThen + (\userDetails -> + Api.projects (username |> User.usernameToString |> Just) + |> Api.toTask env.apiBasePath Project.decodeListings + |> Task.map (\projects -> ( userDetails, projects )) + ) + |> Task.attempt FetchUserProfileFinished + + + +-- UPDATE + + +type Msg + = OpenReference Reference + | ToggleDocFold Doc.FoldId + | FetchUserProfileFinished (Result Http.Error ( UserDetails, List ProjectListing )) + + +update : Env -> Msg -> Model -> ( Model, Cmd Msg ) +update _ msg model = + case ( model, msg ) of + ( _, FetchUserProfileFinished res ) -> + case res of + Err e -> + ( Failure e, Cmd.none ) + + Ok ( userDetails, projects ) -> + let + m = + { docFoldToggles = Doc.emptyDocFoldToggles + , user = userDetails + , projects = projects + } + in + ( Success m, Cmd.none ) + + ( Success _, OpenReference _ ) -> + ( model, Cmd.none ) + + ( Success m, ToggleDocFold fid ) -> + ( Success { m | docFoldToggles = Doc.toggleFold m.docFoldToggles fid }, Cmd.none ) + + _ -> + ( model, Cmd.none ) + + + +-- VIEW + + +projectUrl : ProjectListing -> String +projectUrl = + Route.forProject >> Route.toUrlString + + +viewProjectListing : ProjectListing -> Html msg +viewProjectListing project = + Project.viewProjectListing (Click.Href (projectUrl project)) project + + +viewLoadedModel : LoadedModel -> PageLayout Msg +viewLoadedModel { user, projects, docFoldToggles } = + let + readmeCard = + user.readme + |> Maybe.map (Readme.view OpenReference ToggleDocFold docFoldToggles) + |> Maybe.map (\r -> Card.titled "readme" [ r ]) + |> Maybe.map Card.asContained + |> Maybe.map Card.view + |> Maybe.withDefault UI.nothing + + projectsCard = + projects + |> List.map viewProjectListing + |> (\ps -> [ div [ class "projects" ] ps ]) + |> Card.titled "projects" + |> Card.view + in + PageLayout.HeroLayout + { hero = + PageLayout.PageHero + (div [ class "user-hero" ] [ h1 [] [ text (User.usernameToString user.username) ] ]) + , content = PageLayout.PageContent [ readmeCard, projectsCard ] + } + + +disabledPage : Html Msg -> PageLayout Msg +disabledPage content = + PageLayout.HeroLayout + { hero = PageLayout.PageHero UI.nothing + , content = PageLayout.PageContent [ content ] + } + + +view : Model -> Html Msg +view model = + let + page = + case model of + NotAsked -> + disabledPage (div [] []) + + Loading -> + disabledPage (div [] []) + + Failure _ -> + disabledPage (div [] []) + + Success m -> + viewLoadedModel m + in + PageLayout.view page diff --git a/src/UnisonShare/Route.elm b/src/UnisonShare/Route.elm index 2ea2955..b61e3cb 100644 --- a/src/UnisonShare/Route.elm +++ b/src/UnisonShare/Route.elm @@ -2,6 +2,7 @@ module UnisonShare.Route exposing ( ProjectRoute(..) , Route(..) , forProject + , forUser , fromUrl , navigate , navigateToCurrentPerspective @@ -27,6 +28,7 @@ import Parser exposing ((|.), (|=), Parser, end, oneOf, succeed) import Perspective exposing (CodebasePerspectiveParam(..), PerspectiveParams(..)) import Project exposing (Project) import Route.Parsers as RP exposing (b, reference, s, slash) +import UnisonShare.User as User exposing (User) import Url exposing (Url) import Url.Builder exposing (relative) @@ -82,6 +84,7 @@ type ProjectRoute type Route = Catalog + | User User.Username | Project PerspectiveParams ProjectRoute @@ -91,6 +94,9 @@ updatePerspectiveParams route params = Catalog -> Catalog + User username_ -> + User username_ + Project _ ProjectRoot -> Project params ProjectRoot @@ -107,6 +113,28 @@ catalog = succeed Catalog |. slash |. s "catalog" +user : Parser Route +user = + succeed User |. slash |. s "users" |. slash |= username |. end + + +username : Parser User.Username +username = + let + handleMaybe mUsername = + case mUsername of + Just u -> + Parser.succeed u + + Nothing -> + Parser.problem "Invalid username" + in + Parser.chompUntilEndOr "/" + |> Parser.getChompedString + |> Parser.map User.usernameFromString + |> Parser.andThen handleMaybe + + perspective : Parser Route perspective = succeed (\pp -> Project pp ProjectRoot) |. slash |= RP.perspectiveParams |. end @@ -119,7 +147,7 @@ definition = toRoute : Parser Route toRoute = - oneOf [ b catalog, b perspective, b definition ] + oneOf [ b catalog, b user, b perspective, b definition ] {-| In environments like Unison Local, the UI is served with a base path @@ -171,12 +199,12 @@ fromUrl basePath url = perspectiveParams : Route -> Maybe PerspectiveParams perspectiveParams route = case route of - Catalog -> - Nothing - Project pp _ -> Just pp + _ -> + Nothing + -- Create @@ -191,6 +219,11 @@ forProject project_ = Project (Perspective.ByNamespace Relative fqn) ProjectRoot +forUser : User a -> Route +forUser user_ = + User user_.username + + -- TRANSFORM @@ -256,6 +289,9 @@ toUrlString route = Catalog -> [ "catalog" ] + User username_ -> + [ "users", User.usernameToString username_ ] + Project pp ProjectRoot -> perspectiveParamsToPath pp False @@ -331,13 +367,13 @@ replacePerspective navKey perspectiveParams_ oldRoute = let newRoute = case oldRoute of - Catalog -> - Catalog - Project _ ProjectRoot -> Project perspectiveParams_ ProjectRoot Project _ (ProjectDefinition ref) -> Project perspectiveParams_ (ProjectDefinition ref) + + _ -> + oldRoute in navigate navKey newRoute diff --git a/src/UnisonShare/User.elm b/src/UnisonShare/User.elm new file mode 100644 index 0000000..0f2369a --- /dev/null +++ b/src/UnisonShare/User.elm @@ -0,0 +1,55 @@ +module UnisonShare.User exposing + ( User + , UserDetails + , Username + , decodeDetails + , usernameFromString + , usernameToString + ) + +import Definition.Readme as Readme exposing (Readme) +import Hash exposing (Hash) +import Json.Decode as Decode exposing (field, maybe, string) +import Project exposing (ProjectListing) + + +type Username + = Username String + + +type alias User u = + { u | hash : Hash, username : Username } + + +type alias UserDetails = + User { readme : Maybe Readme, projects : List ProjectListing } + + + +-- HELPERS + + +usernameFromString : String -> Maybe Username +usernameFromString raw = + Just (Username raw) + + +usernameToString : Username -> String +usernameToString (Username raw) = + raw + + + +-- DECODE + + +decodeDetails : Decode.Decoder UserDetails +decodeDetails = + let + makeDetails hash username readme = + { hash = hash, username = username, readme = readme, projects = [] } + in + Decode.map3 makeDetails + (field "hash" Hash.decode) + (field "fqn" (Decode.map Username string)) + (maybe (field "readme" Readme.decode)) diff --git a/src/css/composites.css b/src/css/composites.css index a87d89d..ed2b1af 100644 --- a/src/css/composites.css +++ b/src/css/composites.css @@ -1,5 +1,5 @@ @import "./composites/app-header.css"; @import "./composites/modal.css"; @import "./composites/codebase-tree.css"; -@import "./composites/readme.css"; @import "./composites/copy-field.css"; +@import "./composites/project-listing.css"; diff --git a/src/css/composites/project-listing.css b/src/css/composites/project-listing.css new file mode 100644 index 0000000..9279a50 --- /dev/null +++ b/src/css/composites/project-listing.css @@ -0,0 +1,15 @@ +/* A combination of a hashvatar and a project name */ + +.project-listing { + display: flex; + flex-direction: row; + align-items: center; + height: 2rem; + padding: 0 0.25rem; + border-radius: var(--border-radius-base); +} + +.project-listing:hover { + text-decoration: none; + background: var(--color-main-hover-bg); +} diff --git a/src/css/composites/readme.css b/src/css/composites/readme.css deleted file mode 100644 index 9eff6e1..0000000 --- a/src/css/composites/readme.css +++ /dev/null @@ -1,23 +0,0 @@ -.readme header { - font-size: var(--font-size-medium); - font-weight: bold; - height: 1.5rem; - line-height: 1; - display: flex; - flex-direction: row; -} - -.readme header .icon { - margin-right: 0.375rem; -} - -.readme .definition-doc { - margin: 1.5rem 0; - padding-left: 1.25rem; -} - -@media only screen and (max-width: 1024px) { - .readme .definition-doc { - padding-left: 0; - } -} diff --git a/src/css/elements/card.css b/src/css/elements/card.css index b9ce301..c246112 100644 --- a/src/css/elements/card.css +++ b/src/css/elements/card.css @@ -8,6 +8,10 @@ background: var(--color-card-bg); } +.card.contained { + border: 1px solid var(--color-card-border); +} + .card .card-title { color: var(--color-card-title); text-transform: uppercase; diff --git a/src/css/perspective-landing.css b/src/css/perspective-landing.css index 2e508c8..4662883 100644 --- a/src/css/perspective-landing.css +++ b/src/css/perspective-landing.css @@ -146,6 +146,24 @@ justify-content: flex-end; } +.perspective-landing-readme header { + font-size: var(--font-size-medium); + font-weight: bold; + height: 1.5rem; + line-height: 1; + display: flex; + flex-direction: row; +} + +.perspective-landing-readme header .icon { + margin-right: 0.375rem; +} + +.perspective-landing-readme .definition-doc { + margin: 1.5rem 0; + padding-left: 1.25rem; +} + @media only screen and (max-width: 1024px) { #perspective-landing-content { width: calc(100vw - 2rem); @@ -155,6 +173,10 @@ align-self: center; width: calc(100vw - 4rem); } + + .perspective-landing-readme .definition-doc { + padding-left: 0; + } } @media only screen and (max-width: 414px) { diff --git a/src/css/themes/unison/light.css b/src/css/themes/unison/light.css index da85c42..86fc66d 100644 --- a/src/css/themes/unison/light.css +++ b/src/css/themes/unison/light.css @@ -8,6 +8,7 @@ --color-main-subtle-fg: var(--color-gray-lighten-30); --color-main-subtle-fg-em: var(--color-gray-lighten-20); --color-main-subtle-bg: var(--color-gray-lighten-60); + --color-main-hover-bg: var(--color-gray-lighten-55); --color-main-border: var(--color-gray-lighten-40); --color-main-subtle-border: var(--color-gray-lighten-50); --color-main-divider: var(--color-gray-lighten-55); @@ -213,6 +214,7 @@ --color-card-bg: var(--color-gray-lighten-100); --color-card-fg: var(--color-gray-darken-30); --color-card-title: var(--color-gray-lighten-30); + --color-card-border: var(--color-gray-lighten-50); /* Badge */ --color-badge-fg: var(--color-gray-base); diff --git a/src/css/ui/page-layout.css b/src/css/ui/page-layout.css index a965ce9..536e159 100644 --- a/src/css/ui/page-layout.css +++ b/src/css/ui/page-layout.css @@ -383,7 +383,7 @@ justify-content: center; background: var(--color-gray-darken-10); color: var(--color-gray-lighten-100); - height: 16rem; + height: var(--page-hero-height); } .page.hero-layout .page-content { diff --git a/src/css/unison-share.css b/src/css/unison-share.css index 95b3ef1..3aee449 100644 --- a/src/css/unison-share.css +++ b/src/css/unison-share.css @@ -1 +1,2 @@ @import "./unison-share/page/catalog.css"; +@import "./unison-share/page/user-page.css"; diff --git a/src/css/unison-share/page/catalog.css b/src/css/unison-share/page/catalog.css index 5e0fe35..45a5b40 100644 --- a/src/css/unison-share/page/catalog.css +++ b/src/css/unison-share/page/catalog.css @@ -200,16 +200,5 @@ } .categories .card .project-listing { - display: flex; - flex-direction: row; - align-items: center; - height: 2rem; - padding: 0 0.25rem; margin-left: -0.25rem; - border-radius: var(--border-radius-base); -} - -.categories .card .project-listing:hover { - text-decoration: none; - background: var(--color-gray-lighten-55); } diff --git a/src/css/unison-share/page/user-page.css b/src/css/unison-share/page/user-page.css new file mode 100644 index 0000000..8156c48 --- /dev/null +++ b/src/css/unison-share/page/user-page.css @@ -0,0 +1,27 @@ +.user-page { + --page-hero-height: 8rem; +} + +.user-page .page-content { + display: flex; + align-items: flex-start; + flex-direction: column; + padding: 4rem 0; + gap: 2rem; +} + +.user-page .page-content .card { + width: 100%; +} + +.user-page .page-content .projects { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 0.5rem; +} + +.user-page .page-content .projects .project-listing { + width: calc(33%); + margin-left: -0.25rem; +}