diff --git a/src/query.nim b/src/query.nim index 2b64036..a9fc972 100644 --- a/src/query.nim +++ b/src/query.nim @@ -1,4 +1,4 @@ -import strutils, strformat, sequtils +import strutils, strformat, sequtils, tables import types @@ -11,13 +11,6 @@ const "replies", "retweets", "nativeretweets", "verified", "safe" ] - commonFilters* = @[ - "media", "videos", "images", "links", "news", "quote" - ] - advancedFilters* = @[ - "mentions", "verified", "safe", "twimg", "native_video", - "consumer_video", "pro_video" - ] # Experimental, this might break in the future # Till then, it results in shorter urls @@ -25,18 +18,22 @@ const posPrefix = "thGAVUV0VFVBa" posSuffix = "EjUAFQAlAFUAFQAA" -proc initQuery*(filters, includes, excludes, separator, text: string; name=""): Query = - var sep = separator.strip().toUpper() - Query( - kind: custom, - text: text, - filters: filters.split(",").filterIt(it in validFilters), - includes: includes.split(",").filterIt(it in validFilters), - excludes: excludes.split(",").filterIt(it in validFilters), +template `@`(param: string): untyped = + if param in pms: pms[param] + else: "" + +proc initQuery*(pms: Table[string, string]; name=""): Query = + result = Query( + kind: parseEnum[QueryKind](@"kind", custom), + text: @"text", fromUser: @[name], - sep: if sep in separators: sep else: "" + filters: validFilters.filterIt("f-" & it in pms), + excludes: validFilters.filterIt("e-" & it in pms), ) + if @"e-nativeretweets".len == 0: + result.includes.add "nativeretweets" + proc getMediaQuery*(name: string): Query = Query( kind: media, @@ -88,16 +85,15 @@ proc genQueryUrl*(query: Query): string = result &= &"/search?" var params = @[&"kind={query.kind}"] - if query.filters.len > 0: - params &= "filter=" & query.filters.join(",") - if query.includes.len > 0: - params &= "include=" & query.includes.join(",") - if query.excludes.len > 0: - params &= "not=" & query.excludes.join(",") - if query.sep.len > 0: - params &= "sep=" & query.sep if query.text.len > 0: - params &= "text=" & query.text + params.add "text=" & query.text + for f in query.filters: + params.add "f-" & f & "=on" + for e in query.excludes: + params.add "e-" & e & "=on" + for i in query.excludes: + params.add "i-" & i & "=on" + if params.len > 0: result &= params.join("&") diff --git a/src/routes/search.nim b/src/routes/search.nim index 5e19da7..54d43a4 100644 --- a/src/routes/search.nim +++ b/src/routes/search.nim @@ -1,4 +1,4 @@ -import strutils, uri +import strutils, sequtils, uri import jester @@ -14,24 +14,7 @@ proc createSearchRouter*(cfg: Config) = if @"text".len > 200: resp Http400, showError("Search input too long.", cfg.title) - let kind = parseEnum[QueryKind](@"kind", custom) - var query = Query(kind: kind, text: @"text") - - if @"retweets".len == 0: - query.excludes.add "nativeretweets" - else: - query.includes.add "nativeretweets" - - if @"replies".len == 0: - query.excludes.add "replies" - else: - query.includes.add "replies" - - for f in validFilters: - if "f-" & f in params(request): - query.filters.add f - if "e-" & f in params(request): - query.excludes.add f + let query = initQuery(params(request)) case query.kind of users: diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 8b40507..e35f741 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -64,7 +64,7 @@ proc showTimeline*(name, after: string; query: Option[Query]; else: let timeline = await fetchMultiTimeline(names, after, agent, query) - html = renderTimelineSearch(timeline, prefs, path) + html = renderTweetSearch(timeline, prefs, path) return renderMain(html, prefs, title, "Multi") template respTimeline*(timeline: typed) = @@ -84,9 +84,9 @@ proc createTimelineRouter*(cfg: Config) = get "/@name/search": cond '.' notin @"name" - let query = some initQuery(@"filter", @"include", @"not", @"sep", @"text", @"name") - respTimeline(await showTimeline(@"name", @"after", query, - cookiePrefs(), getPath(), cfg.title, "")) + let query = some initQuery(params(request), name=(@"name")) + respTimeline(await showTimeline(@"name", @"after", query, cookiePrefs(), + getPath(), cfg.title, "")) get "/@name/replies": cond '.' notin @"name" diff --git a/src/sass/general.scss b/src/sass/general.scss index 982cd0e..a92c6e3 100644 --- a/src/sass/general.scss +++ b/src/sass/general.scss @@ -35,106 +35,3 @@ margin-right: 8px; } } - -.search-field { - margin: 2px 5px; - - .pref-group.pref-input { - display: inline-block; - width: calc(90% - 11px); - } - - input[type="text"] { - width: calc(100% - 8px); - } - - .panel-label { - background-color: #121212; - color: #F8F8F2; - border: 1px solid #FF6C6091; - padding: 1px 6px 2px 6px; - font-size: 14px; - cursor: pointer; - margin-left: -2px; - } - - .panel-label:hover { - border: 1px solid #FF6C60; - } -} - - -#panel-toggle { - display: none; - - &:checked ~ .search-panel { - max-height: 180px; - } -} - -.pannel-label { - display: inline; -} - -.search-panel { - max-height: 0; - overflow: hidden; - transition: max-height 0.4s; - - margin: 5px; - font-weight: initial; - text-align: left; - - > div { - line-height: 1.7em; - } - - .checkbox-container { - display: inline; - padding-right: unset; - margin-left: 23px; - } - - .checkbox { - right: unset; - left: -22px; - } - - .checkbox-container .checkbox:after { - top: -4px; - } - - .search-title { - font-weight: bold; - min-width: 60px; - display: inline-block; - } - - .exclude-extras { - max-height: 0; - overflow: hidden; - transition: max-height 0.4s; - } - - #exclude-toggle { - display: none; - - &:checked ~ .exclude-extras { - max-height: 50px; - } - } - - .filter-extras { - max-height: 0; - overflow: hidden; - transition: max-height 0.4s; - } - - #filter-toggle { - display: none; - - &:checked ~ .filter-extras { - max-height: 50px; - } - } -} diff --git a/src/sass/include/_mixins.css b/src/sass/include/_mixins.css index 7042876..47c07a7 100644 --- a/src/sass/include/_mixins.css +++ b/src/sass/include/_mixins.css @@ -58,3 +58,29 @@ border-color: $accent_light; } } + +@mixin search-resize($width, $rows, $height) { + @media(max-width: $width) { + .search-toggles { + grid-template-columns: repeat($rows, auto); + } + + #search-panel-toggle:checked ~ .search-panel { + max-height: $height !important; + } + } +} + +@mixin create-toggle($elem, $height) { + ##{$elem}-toggle { + display: none; + + &:checked ~ .#{$elem} { + max-height: $height; + } + + &:checked ~ label .icon-down:before { + transform: rotate(180deg) translateY(-1px); + } + } +} diff --git a/src/sass/index.scss b/src/sass/index.scss index 7e36f63..0d8c50c 100644 --- a/src/sass/index.scss +++ b/src/sass/index.scss @@ -6,6 +6,7 @@ @import 'navbar'; @import 'inputs'; @import 'timeline'; +@import 'search'; body { background-color: $bg_color; diff --git a/src/sass/profile/photo-rail.scss b/src/sass/profile/photo-rail.scss index bae9e8e..e67c864 100644 --- a/src/sass/profile/photo-rail.scss +++ b/src/sass/profile/photo-rail.scss @@ -16,13 +16,9 @@ &-header-mobile { padding: 5px 12px 0; display: none; - } - - &-label { - width: 100%; + width: calc(100% - 24px); float: unset; color: $accent; - display: flex; justify-content: space-between; } @@ -57,13 +53,9 @@ } } -#photo-rail-toggle { - display: none; - - &:checked ~ .photo-rail-grid { - max-height: 600px; - padding-bottom: 12px; - } +@include create-toggle(photo-rail-grid, 640px); +#photo-rail-grid-toggle:checked ~ .photo-rail-grid { + padding-bottom: 12px; } @media(max-width: 600px) { @@ -72,7 +64,7 @@ } .photo-rail-header-mobile { - display: block; + display: flex; } .photo-rail-grid { diff --git a/src/sass/search.scss b/src/sass/search.scss new file mode 100644 index 0000000..521dfc6 --- /dev/null +++ b/src/sass/search.scss @@ -0,0 +1,83 @@ +@import '_variables'; +@import '_mixins'; + +.search-title { + font-weight: bold; + display: inline-block; + margin-top: 4px; +} + +.search-field { + display: flex; + flex-wrap: wrap; + + button { + margin: 0 2px 2px 0; + } + + .pref-input { + margin: 0 4px 2px 0; + flex-grow: 1; + } + + input[type="text"] { + height: 20px; + width: calc(100% - 8px); + } + + > label { + display: inline; + background-color: #121212; + color: #F8F8F2; + border: 1px solid #FF6C6091; + padding: 1px 6px 2px 6px; + font-size: 14px; + cursor: pointer; + margin-bottom: 2px; + + @include input-colors; + } + + @include create-toggle(search-panel, 140px); +} + +.search-panel { + max-height: 0; + overflow: hidden; + transition: max-height 0.4s; + + flex-grow: 1; + font-weight: initial; + text-align: left; + + > div { + line-height: 1.7em; + } + + .checkbox-container { + display: inline; + padding-right: unset; + margin-bottom: unset; + margin-left: 23px; + } + + .checkbox { + right: unset; + left: -22px; + } + + .checkbox-container .checkbox:after { + top: -4px; + } +} + +.search-toggles { + flex-grow: 1; + display: grid; + grid-template-columns: repeat(6, auto); + grid-column-gap: 10px; +} + +@include search-resize(530px, 5, 185px); +@include search-resize(475px, 4, 185px); +@include search-resize(406px, 3, 250px); diff --git a/src/sass/timeline.scss b/src/sass/timeline.scss index ff8b048..759da37 100644 --- a/src/sass/timeline.scss +++ b/src/sass/timeline.scss @@ -10,21 +10,16 @@ > div:not(:last-child) { border-bottom: 1px solid $border_grey; } - } .timeline-header { background-color: $bg_panel; text-align: center; - padding: 10px; + padding: 8px; display: block; font-weight: bold; margin-bottom: 5px; - input[type="text"] { - height: 20px; - } - button { float: unset; } @@ -74,11 +69,6 @@ padding: 6px 0; } -.timeline-header { - background-color: $bg_panel; - padding: 6px 0; -} - .timeline-protected { text-align: center; diff --git a/src/types.nim b/src/types.nim index de28cfe..327976a 100644 --- a/src/types.nim +++ b/src/types.nim @@ -57,7 +57,7 @@ dbFromTypes("cache.db", "", "", "", [Profile, Video]) type QueryKind* = enum - replies, media, multi, users, custom + posts, replies, media, multi, users, custom Query* = object kind*: QueryKind diff --git a/src/views/profile.nim b/src/views/profile.nim index 87445ef..9fd2d6b 100644 --- a/src/views/profile.nim +++ b/src/views/profile.nim @@ -1,7 +1,7 @@ import strutils, strformat import karax/[karaxdsl, vdom, vstyles] -import tweet, timeline, renderutils +import renderutils, search import ".."/[types, utils, formatters] proc renderStat(num, class: string; text=""): VNode = @@ -54,11 +54,10 @@ proc renderPhotoRail(profile: Profile; photoRail: seq[GalleryPhoto]): VNode = a(href=(&"/{profile.username}/media")): icon "picture", $profile.media & " Photos and videos" - input(id="photo-rail-toggle", `type`="checkbox") - tdiv(class="photo-rail-header-mobile"): - label(`for`="photo-rail-toggle", class="photo-rail-label"): - icon "picture", $profile.media & " Photos and videos" - icon "down" + input(id="photo-rail-grid-toggle", `type`="checkbox") + label(`for`="photo-rail-grid-toggle", class="photo-rail-header-mobile"): + icon "picture", $profile.media & " Photos and videos" + icon "down" tdiv(class="photo-rail-grid"): for i, photo in photoRail: @@ -76,13 +75,17 @@ proc renderBanner(profile: Profile): VNode = genImg(profile.banner) proc renderProtected(username: string): VNode = - buildHtml(tdiv(class="timeline-container timeline")): - tdiv(class="timeline-header timeline-protected"): - h2: text "This account's tweets are protected." - p: text &"Only confirmed followers have access to @{username}'s tweets." + buildHtml(tdiv(class="timeline-container")): + tdiv(class="timeline-container timeline"): + tdiv(class="timeline-header timeline-protected"): + h2: text "This account's tweets are protected." + p: text &"Only confirmed followers have access to @{username}'s tweets." proc renderProfile*(profile: Profile; timeline: Timeline; photoRail: seq[GalleryPhoto]; prefs: Prefs; path: string): VNode = + if timeline.query.isNone: + timeline.query = some Query(fromUser: @[profile.username]) + buildHtml(tdiv(class="profile-tabs")): if not prefs.hideBanner: tdiv(class="profile-banner"): @@ -94,9 +97,7 @@ proc renderProfile*(profile: Profile; timeline: Timeline; if photoRail.len > 0: renderPhotoRail(profile, photoRail) - tdiv(class="timeline-container"): - if profile.protected: - renderProtected(profile.username) - else: - renderProfileTabs(timeline.query, profile.username) - renderTimelineTweets(timeline, prefs, path) + if profile.protected: + renderProtected(profile.username) + else: + renderTweetSearch(timeline, prefs, path) diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index a8ebb04..7cc7692 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -59,8 +59,7 @@ proc buttonReferer*(action, text, path: string; class=""; `method`="post"): VNod text text proc genCheckbox*(pref, label: string; state: bool): VNode = - buildHtml(tdiv(class="pref-group")): - label(class="checkbox-container"): + buildHtml(label(class="pref-group checkbox-container")): text label if state: input(name=pref, `type`="checkbox", checked="") else: input(name=pref, `type`="checkbox") @@ -83,4 +82,3 @@ proc genSelect*(pref, label, state: string; options: seq[string]): VNode = option(value=opt, selected=""): text opt else: option(value=opt): text opt - diff --git a/src/views/search.nim b/src/views/search.nim index 3bf0983..c041da4 100644 --- a/src/views/search.nim +++ b/src/views/search.nim @@ -1,9 +1,24 @@ -import strutils, strformat, unicode +import strutils, strformat, unicode, tables import karax/[karaxdsl, vdom, vstyles] import renderutils, timeline import ".."/[types, formatters, query] +let toggles = { + "nativeretweets": "Retweets", + "media": "Media", + "videos": "Videos", + "news": "News", + "verified": "Verified", + "native_video": "Native videos", + "replies": "Replies", + "links": "Links", + "images": "Images", + "safe": "Safe", + "quote": "Quotes", + "pro_video": "Pro videos" +}.toOrderedTable + proc renderSearch*(): VNode = buildHtml(tdiv(class="panel-container")): tdiv(class="search-bar"): @@ -12,55 +27,77 @@ proc renderSearch*(): VNode = input(`type`="text", name="text", autofocus="", placeholder="Enter username...") button(`type`="submit"): icon "search" -proc renderTimelineSearch*(timeline: Timeline; prefs: Prefs; path: string): VNode = - let users = - if timeline.query.isSome: get(timeline.query).fromUser - else: @[] +proc getTabClass(query: Option[Query]; tab: string): string = + var classes = @["tab-item"] - buildHtml(tdiv(class="timeline-container")): - tdiv(class="timeline-header"): - text users.join(" | ") + if query.isNone or get(query).kind == multi: + if tab == "posts": + classes.add "active" + elif $get(query).kind == tab: + classes.add "active" - renderProfileTabs(timeline.query, users.join(",")) - renderTimelineTweets(timeline, prefs, path) + return classes.join(" ") + +proc renderProfileTabs*(query: Option[Query]; username: string): VNode = + let link = "/" & username + buildHtml(ul(class="tab")): + li(class=query.getTabClass("posts")): + a(href=link): text "Tweets" + li(class=query.getTabClass("replies")): + a(href=(link & "/replies")): text "Tweets & Replies" + li(class=query.getTabClass("media")): + a(href=(link & "/media")): text "Media" + li(class=query.getTabClass("custom")): + a(href=(link & "/search")): text "Custom" + +proc renderSearchTabs*(query: Option[Query]): VNode = + var q = if query.isSome: get(query) else: Query() + buildHtml(ul(class="tab")): + li(class=query.getTabClass("custom")): + q.kind = custom + a(href=genQueryUrl(q)): text "Tweets" + li(class=query.getTabClass("users")): + q.kind = users + a(href=genQueryUrl(q)): text "Users" + +proc renderSearchPanel*(query: Query): VNode = + let user = query.fromUser.join(",") + let action = if user.len > 0: &"/{user}/search" else: "/search" + buildHtml(form(`method`="get", action=action, class="search-field")): + hiddenField("kind", "custom") + genInput("text", "", query.text, "Enter search...", class="pref-inline") + button(`type`="submit"): icon "search" + input(id="search-panel-toggle", `type`="checkbox") + label(`for`="search-panel-toggle"): + icon "down" + tdiv(class="search-panel"): + for f in @["filter", "exclude"]: + span(class="search-title"): text capitalize(f) + tdiv(class="search-toggles"): + for k, v in toggles: + let state = + if f == "filter": k in query.filters + else: k in query.excludes + genCheckbox(&"{f[0]}-{k}", v, state) proc renderTweetSearch*(tweets: Result[Tweet]; prefs: Prefs; path: string): VNode = - let query = if tweets.query.isSome: get(tweets.query) else: Query(kind: custom) + let query = + if tweets.query.isSome: get(tweets.query) + else: Query(kind: custom) buildHtml(tdiv(class="timeline-container")): - tdiv(class="timeline-header"): - form(`method`="get", action="/search", class="search-field"): - hiddenField("kind", "custom") - genInput("text", "", query.text, "Enter search...", class="pref-inline") - button(`type`="submit"): icon "search" - input(id="panel-toggle", `type`="checkbox") - label(`for`="panel-toggle", class="panel-label"): - icon "down" - tdiv(class="search-panel"): - tdiv: - span(class="search-title"): text "Include: " - genCheckbox("retweets", "Retweets", "nativeretweets" in query.includes) - genCheckbox("replies", "Replies", "replies" in query.includes) + if query.fromUser.len > 1: + tdiv(class="timeline-header"): + text query.fromUser.join(" | ") + if query.fromUser.len == 0 or query.kind == custom: + tdiv(class="timeline-header"): + renderSearchPanel(query) - for f in @["filter", "exclude"]: - tdiv: - span(class="search-title"): text capitalize(f) & ":" - for i in commonFilters: - let state = - if f == "filter": i in query.filters - else: i in query.excludes - genCheckbox(&"{f[0]}-{i}", capitalize(i), state) - input(id=(&"{f}-toggle"), `type`="checkbox") - label(`for`=(&"{f}-toggle"), class=(&"{f}-label")): - icon "down" - tdiv(class=(&"{f}-extras")): - for i in advancedFilters: - let state = - if f == "filter": i in query.filters - else: i in query.excludes - genCheckbox(&"{f[0]}-{i}", i, state) + if query.fromUser.len > 0: + renderProfileTabs(tweets.query, query.fromUser.join(",")) + else: + renderSearchTabs(tweets.query) - renderSearchTabs(tweets.query) renderTimelineTweets(tweets, prefs, path) proc renderUserSearch*(users: Result[Profile]; prefs: Prefs): VNode = @@ -74,9 +111,6 @@ proc renderUserSearch*(users: Result[Profile]; prefs: Prefs): VNode = hiddenField("kind", "users") genInput("text", "", searchText, "Enter username...", class="pref-inline") button(`type`="submit"): icon "search" - input(id="panel-toggle", `type`="checkbox") - label(`for`="panel-toggle", class="panel-label"): - icon "down" renderSearchTabs(users.query) renderTimelineUsers(users, prefs) diff --git a/src/views/timeline.nim b/src/views/timeline.nim index 12aa167..362f178 100644 --- a/src/views/timeline.nim +++ b/src/views/timeline.nim @@ -12,38 +12,6 @@ proc getQuery(query: Option[Query]): string = if result[^1] != '?': result &= "&" -proc getTabClass(query: Option[Query]; tab: string): string = - var classes = @["tab-item"] - - if query.isNone or get(query).kind == multi: - if tab == "posts": - classes.add "active" - elif $get(query).kind == tab: - classes.add "active" - - return classes.join(" ") - -proc renderProfileTabs*(query: Option[Query]; username: string): VNode = - let link = "/" & username - buildHtml(ul(class="tab")): - li(class=query.getTabClass("posts")): - a(href=link): text "Tweets" - li(class=query.getTabClass("replies")): - a(href=(link & "/replies")): text "Tweets & Replies" - li(class=query.getTabClass("media")): - a(href=(link & "/media")): text "Media" - -proc renderSearchTabs*(query: Option[Query]): VNode = - var q = if query.isSome: get(query) else: Query() - - buildHtml(ul(class="tab")): - li(class=query.getTabClass("custom")): - q.kind = custom - a(href=genQueryUrl(q)): text "Tweets" - li(class=query.getTabClass("users")): - q.kind = users - a(href=genQueryUrl(q)): text "Users" - proc renderNewer(query: Option[Query]): VNode = buildHtml(tdiv(class="timeline-item show-more")): a(href=(getQuery(query).strip(chars={'?', '&'}))):