mirror of
https://github.com/zedeus/nitter.git
synced 2024-12-23 09:55:30 +00:00
commit
b10d894a11
|
@ -27,17 +27,15 @@ is on implementing missing features.
|
||||||
|
|
||||||
- Search (images/videos, hashtags, etc.)
|
- Search (images/videos, hashtags, etc.)
|
||||||
- Custom timeline filter
|
- Custom timeline filter
|
||||||
- Media-only/gallery view
|
|
||||||
- Nitter link previews
|
- Nitter link previews
|
||||||
- Server configuration
|
- Server configuration
|
||||||
- Caching (waiting for [moigagoo/norm#19](https://github.com/moigagoo/norm/pull/19))
|
- More caching (waiting for [moigagoo/norm#19](https://github.com/moigagoo/norm/pull/19))
|
||||||
- Twitter "Cards" (link previews)
|
|
||||||
- Simple account system with customizable feed
|
- Simple account system with customizable feed
|
||||||
- Emoji support (WIP, needs font)
|
|
||||||
- Video support with hls.js
|
- Video support with hls.js
|
||||||
- Json API endpoints
|
- Json API endpoints
|
||||||
- Themes
|
- Themes
|
||||||
- Nitter logo
|
- Nitter logo
|
||||||
|
- Emoji support (WIP, uses native font for now)
|
||||||
|
|
||||||
## Why?
|
## Why?
|
||||||
|
|
||||||
|
|
133
public/style.css
133
public/style.css
|
@ -237,7 +237,7 @@ nav {
|
||||||
|
|
||||||
.gallery-row .attachment:last-child, .gallery-row .attachments:last-child, .video-container {
|
.gallery-row .attachment:last-child, .gallery-row .attachments:last-child, .video-container {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
max-height: 500px;
|
max-height: 530px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachments .attachment {
|
.attachments .attachment {
|
||||||
|
@ -419,7 +419,6 @@ video {
|
||||||
.profile-banner-color {
|
.profile-banner-color {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-bottom: 25%;
|
padding-bottom: 25%;
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-card {
|
.profile-card {
|
||||||
|
@ -882,6 +881,136 @@ video {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-container {
|
||||||
|
border-radius: 10px;
|
||||||
|
border-width: 1px;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #404040;
|
||||||
|
background-color: #121212;
|
||||||
|
overflow: hidden;
|
||||||
|
color: inherit;
|
||||||
|
display: flex;
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-container:hover {
|
||||||
|
border-color: #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-container .attachments {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large .card-container {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
padding: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-description {
|
||||||
|
margin: 0.3em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-destination {
|
||||||
|
color: hsla(240,1%,73%,.9);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-image-container {
|
||||||
|
width: 98px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large .card-image-container {
|
||||||
|
width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-image-container:before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
padding-top: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large .card-image-container:before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-image {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large .card-image {
|
||||||
|
position: unset;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #404040;
|
||||||
|
border-width: 0;
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-overlay-circle {
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #404040;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
border-width: 5px;
|
||||||
|
border-color: #d8574d;
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-overlay-triangle {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 12px 0 12px 17px;
|
||||||
|
border-color: transparent transparent transparent #d8574d;
|
||||||
|
margin-left: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.poll-meter {
|
.poll-meter {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
46
src/api.nim
46
src/api.nim
|
@ -106,7 +106,11 @@ proc getVideo*(tweet: Tweet; token: string) {.async.} =
|
||||||
await getVideo(tweet, guestToken)
|
await getVideo(tweet, guestToken)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if tweet.card.isNone:
|
||||||
tweet.video = some(parseVideo(json))
|
tweet.video = some(parseVideo(json))
|
||||||
|
else:
|
||||||
|
get(tweet.card).video = some(parseVideo(json))
|
||||||
|
tweet.video = none(Video)
|
||||||
tokenUses.inc
|
tokenUses.inc
|
||||||
|
|
||||||
proc getVideos*(thread: Thread; token="") {.async.} =
|
proc getVideos*(thread: Thread; token="") {.async.} =
|
||||||
|
@ -163,6 +167,36 @@ proc getConversationPolls*(convo: Conversation) {.async.} =
|
||||||
futs.add convo.replies.map(getPolls)
|
futs.add convo.replies.map(getPolls)
|
||||||
await all(futs)
|
await all(futs)
|
||||||
|
|
||||||
|
proc getCard*(tweet: Tweet) {.async.} =
|
||||||
|
if tweet.card.isNone(): return
|
||||||
|
|
||||||
|
let headers = newHttpHeaders({
|
||||||
|
"Accept": cardAccept,
|
||||||
|
"Referer": $(base / getLink(tweet)),
|
||||||
|
"User-Agent": agent,
|
||||||
|
"Authority": "twitter.com",
|
||||||
|
"Accept-Language": lang,
|
||||||
|
})
|
||||||
|
|
||||||
|
let query = get(tweet.card).query.replace("sensitive=true", "sensitive=false")
|
||||||
|
let html = await fetchHtml(base / query, headers)
|
||||||
|
if html == nil: return
|
||||||
|
|
||||||
|
parseCard(get(tweet.card), html)
|
||||||
|
|
||||||
|
proc getCards*(thread: Thread) {.async.} =
|
||||||
|
if thread == nil: return
|
||||||
|
var cards = thread.tweets.filterIt(it.card.isSome)
|
||||||
|
await all(cards.map(getCard))
|
||||||
|
|
||||||
|
proc getConversationCards*(convo: Conversation) {.async.} =
|
||||||
|
var futs: seq[Future[void]]
|
||||||
|
futs.add getCard(convo.tweet)
|
||||||
|
futs.add getCards(convo.before)
|
||||||
|
futs.add getCards(convo.after)
|
||||||
|
futs.add convo.replies.map(getCards)
|
||||||
|
await all(futs)
|
||||||
|
|
||||||
proc getPhotoRail*(username: string): Future[seq[GalleryPhoto]] {.async.} =
|
proc getPhotoRail*(username: string): Future[seq[GalleryPhoto]] {.async.} =
|
||||||
let headers = newHttpHeaders({
|
let headers = newHttpHeaders({
|
||||||
"Accept": jsonAccept,
|
"Accept": jsonAccept,
|
||||||
|
@ -234,9 +268,12 @@ proc getTweet*(username, id: string): Future[Conversation] {.async.} =
|
||||||
|
|
||||||
result = parseConversation(html)
|
result = parseConversation(html)
|
||||||
|
|
||||||
let vidsFut = getConversationVideos(result)
|
let
|
||||||
let pollFut = getConversationPolls(result)
|
vidsFut = getConversationVideos(result)
|
||||||
await all(vidsFut, pollFut)
|
pollFut = getConversationPolls(result)
|
||||||
|
cardFut = getConversationCards(result)
|
||||||
|
|
||||||
|
await all(vidsFut, pollFut, cardFut)
|
||||||
|
|
||||||
proc finishTimeline(json: JsonNode; query: Option[Query]; after: string): Future[Timeline] {.async.} =
|
proc finishTimeline(json: JsonNode; query: Option[Query]; after: string): Future[Timeline] {.async.} =
|
||||||
if json == nil: return Timeline()
|
if json == nil: return Timeline()
|
||||||
|
@ -257,8 +294,9 @@ proc finishTimeline(json: JsonNode; query: Option[Query]; after: string): Future
|
||||||
thread = parseThread(html)
|
thread = parseThread(html)
|
||||||
vidsFut = getVideos(thread)
|
vidsFut = getVideos(thread)
|
||||||
pollFut = getPolls(thread)
|
pollFut = getPolls(thread)
|
||||||
|
cardFut = getCards(thread)
|
||||||
|
|
||||||
await all(vidsFut, pollFut)
|
await all(vidsFut, pollFut, cardFut)
|
||||||
result.tweets = thread.tweets
|
result.tweets = thread.tweets
|
||||||
|
|
||||||
proc getTimeline*(username, after: string): Future[Timeline] {.async.} =
|
proc getTimeline*(username, after: string): Future[Timeline] {.async.} =
|
||||||
|
|
|
@ -10,7 +10,6 @@ const
|
||||||
emailRegex = re"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)"
|
emailRegex = re"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)"
|
||||||
usernameRegex = re"(^|[^A-z0-9_?])@([A-z0-9_]+)"
|
usernameRegex = re"(^|[^A-z0-9_?])@([A-z0-9_]+)"
|
||||||
picRegex = re"pic.twitter.com/[^ ]+"
|
picRegex = re"pic.twitter.com/[^ ]+"
|
||||||
cardRegex = re"(https?://)?cards.twitter.com/[^ ]+"
|
|
||||||
ellipsisRegex = re" ?…"
|
ellipsisRegex = re" ?…"
|
||||||
nbsp = $Rune(0x000A0)
|
nbsp = $Rune(0x000A0)
|
||||||
|
|
||||||
|
@ -60,7 +59,6 @@ proc linkifyText*(text: string): string =
|
||||||
proc stripTwitterUrls*(text: string): string =
|
proc stripTwitterUrls*(text: string): string =
|
||||||
result = text
|
result = text
|
||||||
result = result.replace(picRegex, "")
|
result = result.replace(picRegex, "")
|
||||||
result = result.replace(cardRegex, "")
|
|
||||||
result = result.replace(ellipsisRegex, "")
|
result = result.replace(ellipsisRegex, "")
|
||||||
|
|
||||||
proc getUserpic*(userpic: string; style=""): string =
|
proc getUserpic*(userpic: string; style=""): string =
|
||||||
|
|
|
@ -75,7 +75,7 @@ proc parseTweet*(node: XmlNode): Tweet =
|
||||||
)
|
)
|
||||||
|
|
||||||
result.getTweetMedia(tweet)
|
result.getTweetMedia(tweet)
|
||||||
result.getTweetCards(tweet)
|
result.getTweetCard(tweet)
|
||||||
|
|
||||||
let by = tweet.selectText(".js-retweet-text > a > b")
|
let by = tweet.selectText(".js-retweet-text > a > b")
|
||||||
if by.len > 0:
|
if by.len > 0:
|
||||||
|
@ -178,3 +178,20 @@ proc parsePhotoRail*(node: XmlNode): seq[GalleryPhoto] =
|
||||||
tweetId: img.attr("data-tweet-id"),
|
tweetId: img.attr("data-tweet-id"),
|
||||||
color: img.attr("background-color").replace("style: ", "")
|
color: img.attr("background-color").replace("style: ", "")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
proc parseCard*(card: var Card; node: XmlNode) =
|
||||||
|
card.title = node.selectText("h2.TwitterCard-title")
|
||||||
|
card.text = node.selectText("p.tcu-resetMargin")
|
||||||
|
card.dest = node.selectText("span.SummaryCard-destination")
|
||||||
|
|
||||||
|
if card.url.len == 0:
|
||||||
|
card.url = node.select("a").attr("href")
|
||||||
|
|
||||||
|
let image = node.select(".tcu-imageWrapper img")
|
||||||
|
if image != nil:
|
||||||
|
# workaround for issue 11713
|
||||||
|
card.image = some(image.attr("data-src").replace("gname", "g&name"))
|
||||||
|
|
||||||
|
if card.kind == liveEvent:
|
||||||
|
card.text = card.title
|
||||||
|
card.title = node.selectText(".TwitterCard-attribution--category")
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import xmltree, htmlparser, strtabs, strformat, times
|
import xmltree, htmlparser, strtabs, strformat, strutils, times
|
||||||
import regex
|
import regex
|
||||||
|
|
||||||
import types, formatters, api
|
import types, formatters, api
|
||||||
|
@ -167,10 +167,36 @@ proc getQuoteMedia*(quote: var Quote; node: XmlNode) =
|
||||||
elif gifBadge != nil:
|
elif gifBadge != nil:
|
||||||
quote.badge = "GIF"
|
quote.badge = "GIF"
|
||||||
|
|
||||||
proc getTweetCards*(tweet: Tweet; node: XmlNode) =
|
proc getTweetCard*(tweet: Tweet; node: XmlNode) =
|
||||||
if node.attr("data-has-cards") == "false": return
|
if node.attr("data-has-cards") == "false": return
|
||||||
if "poll" in node.attr("data-card2-type"):
|
var cardType = node.attr("data-card2-type")
|
||||||
|
|
||||||
|
if ":" in cardType:
|
||||||
|
cardType = cardType.split(":")[^1]
|
||||||
|
|
||||||
|
if "poll" in cardType:
|
||||||
tweet.poll = some(Poll())
|
tweet.poll = some(Poll())
|
||||||
|
return
|
||||||
|
|
||||||
|
let cardDiv = node.select(".card2 > .js-macaw-cards-iframe-container")
|
||||||
|
if cardDiv == nil: return
|
||||||
|
|
||||||
|
var card = Card(
|
||||||
|
id: tweet.id,
|
||||||
|
query: cardDiv.attr("data-src")
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
card.kind = parseEnum[CardKind](cardType)
|
||||||
|
except ValueError:
|
||||||
|
card.kind = summary
|
||||||
|
|
||||||
|
let cardUrl = cardDiv.attr("data-card-url")
|
||||||
|
for n in node.selectAll(".tweet-text a"):
|
||||||
|
if n.attr("href") == cardUrl:
|
||||||
|
card.url = n.attr("data-expanded-url")
|
||||||
|
|
||||||
|
tweet.card = some(card)
|
||||||
|
|
||||||
proc getMoreReplies*(node: XmlNode): int =
|
proc getMoreReplies*(node: XmlNode): int =
|
||||||
let text = node.innerText().strip()
|
let text = node.innerText().strip()
|
||||||
|
|
|
@ -21,7 +21,7 @@ const
|
||||||
proc initQuery*(filters, includes, excludes, separator: string; name=""): Query =
|
proc initQuery*(filters, includes, excludes, separator: string; name=""): Query =
|
||||||
var sep = separator.strip().toUpper()
|
var sep = separator.strip().toUpper()
|
||||||
Query(
|
Query(
|
||||||
queryType: custom,
|
kind: custom,
|
||||||
filters: filters.split(",").filterIt(it in validFilters),
|
filters: filters.split(",").filterIt(it in validFilters),
|
||||||
includes: includes.split(",").filterIt(it in validFilters),
|
includes: includes.split(",").filterIt(it in validFilters),
|
||||||
excludes: excludes.split(",").filterIt(it in validFilters),
|
excludes: excludes.split(",").filterIt(it in validFilters),
|
||||||
|
@ -31,7 +31,7 @@ proc initQuery*(filters, includes, excludes, separator: string; name=""): Query
|
||||||
|
|
||||||
proc getMediaQuery*(name: string): Query =
|
proc getMediaQuery*(name: string): Query =
|
||||||
Query(
|
Query(
|
||||||
queryType: media,
|
kind: media,
|
||||||
filters: @["twimg", "native_video"],
|
filters: @["twimg", "native_video"],
|
||||||
fromUser: name,
|
fromUser: name,
|
||||||
sep: "OR"
|
sep: "OR"
|
||||||
|
@ -39,7 +39,7 @@ proc getMediaQuery*(name: string): Query =
|
||||||
|
|
||||||
proc getReplyQuery*(name: string): Query =
|
proc getReplyQuery*(name: string): Query =
|
||||||
Query(
|
Query(
|
||||||
queryType: replies,
|
kind: replies,
|
||||||
includes: @["nativeretweets"],
|
includes: @["nativeretweets"],
|
||||||
fromUser: name
|
fromUser: name
|
||||||
)
|
)
|
||||||
|
@ -61,8 +61,8 @@ proc genQueryParam*(query: Query): string =
|
||||||
return strip(param & filters.join(&" {query.sep} "))
|
return strip(param & filters.join(&" {query.sep} "))
|
||||||
|
|
||||||
proc genQueryUrl*(query: Query): string =
|
proc genQueryUrl*(query: Query): string =
|
||||||
result = &"/{query.queryType}?"
|
result = &"/{query.kind}?"
|
||||||
if query.queryType != custom: return
|
if query.kind != custom: return
|
||||||
|
|
||||||
var params: seq[string]
|
var params: seq[string]
|
||||||
if query.filters.len > 0:
|
if query.filters.len > 0:
|
||||||
|
|
|
@ -31,11 +31,11 @@ db("cache.db", "", "", ""):
|
||||||
.}: Time
|
.}: Time
|
||||||
|
|
||||||
type
|
type
|
||||||
QueryType* = enum
|
QueryKind* = enum
|
||||||
replies, media, custom = "search"
|
replies, media, custom = "search"
|
||||||
|
|
||||||
Query* = object
|
Query* = object
|
||||||
queryType*: QueryType
|
kind*: QueryKind
|
||||||
filters*: seq[string]
|
filters*: seq[string]
|
||||||
includes*: seq[string]
|
includes*: seq[string]
|
||||||
excludes*: seq[string]
|
excludes*: seq[string]
|
||||||
|
@ -70,6 +70,25 @@ type
|
||||||
status*: string
|
status*: string
|
||||||
leader*: int
|
leader*: int
|
||||||
|
|
||||||
|
CardKind* = enum
|
||||||
|
summary = "summary"
|
||||||
|
summaryLarge = "summary_large_image"
|
||||||
|
promoWebsite = "promo_website"
|
||||||
|
promoVideo = "promo_video_website"
|
||||||
|
player = "player"
|
||||||
|
liveEvent = "live_event"
|
||||||
|
|
||||||
|
Card* = object
|
||||||
|
kind*: CardKind
|
||||||
|
id*: string
|
||||||
|
query*: string
|
||||||
|
url*: string
|
||||||
|
title*: string
|
||||||
|
dest*: string
|
||||||
|
text*: string
|
||||||
|
image*: Option[string]
|
||||||
|
video*: Option[Video]
|
||||||
|
|
||||||
Quote* = object
|
Quote* = object
|
||||||
id*: string
|
id*: string
|
||||||
profile*: Profile
|
profile*: Profile
|
||||||
|
@ -104,6 +123,7 @@ type
|
||||||
stats*: TweetStats
|
stats*: TweetStats
|
||||||
retweet*: Option[Retweet]
|
retweet*: Option[Retweet]
|
||||||
quote*: Option[Quote]
|
quote*: Option[Quote]
|
||||||
|
card*: Option[Card]
|
||||||
gif*: Option[Gif]
|
gif*: Option[Gif]
|
||||||
video*: Option[Video]
|
video*: Option[Video]
|
||||||
photos*: seq[string]
|
photos*: seq[string]
|
||||||
|
|
|
@ -14,7 +14,7 @@ proc getTabClass(timeline: Timeline; tab: string): string =
|
||||||
if timeline.query.isNone:
|
if timeline.query.isNone:
|
||||||
if tab == "tweets":
|
if tab == "tweets":
|
||||||
classes.add "active"
|
classes.add "active"
|
||||||
elif $timeline.query.get().queryType == tab:
|
elif $timeline.query.get().kind == tab:
|
||||||
classes.add "active"
|
classes.add "active"
|
||||||
|
|
||||||
return classes.join(" ")
|
return classes.join(" ")
|
||||||
|
|
|
@ -77,6 +77,32 @@ proc renderPoll(poll: Poll): VNode =
|
||||||
span(class="poll-info"):
|
span(class="poll-info"):
|
||||||
text $poll.votes & " votes • " & poll.status
|
text $poll.votes & " votes • " & poll.status
|
||||||
|
|
||||||
|
proc renderCardImage(card: Card): VNode =
|
||||||
|
buildHtml(tdiv(class="card-image-container")):
|
||||||
|
tdiv(class="card-image"):
|
||||||
|
img(src=get(card.image).getSigUrl("pic"))
|
||||||
|
if card.kind == player:
|
||||||
|
tdiv(class="card-overlay"):
|
||||||
|
tdiv(class="card-overlay-circle"):
|
||||||
|
span(class="card-overlay-triangle")
|
||||||
|
|
||||||
|
proc renderCard(card: Card): VNode =
|
||||||
|
const largeCards = {summaryLarge, liveEvent, promoWebsite, promoVideo}
|
||||||
|
let large = if card.kind in largeCards: " large" else: ""
|
||||||
|
|
||||||
|
buildHtml(tdiv(class=("card" & large))):
|
||||||
|
a(class="card-container", href=card.url):
|
||||||
|
if card.image.isSome:
|
||||||
|
renderCardImage(card)
|
||||||
|
elif card.video.isSome:
|
||||||
|
renderVideo(get(card.video))
|
||||||
|
|
||||||
|
tdiv(class="card-content-container"):
|
||||||
|
tdiv(class="card-content"):
|
||||||
|
h2(class="card-title"): text card.title
|
||||||
|
p(class="card-description"): text card.text
|
||||||
|
span(class="card-destination"): text card.dest
|
||||||
|
|
||||||
proc renderStats(stats: TweetStats): VNode =
|
proc renderStats(stats: TweetStats): VNode =
|
||||||
buildHtml(tdiv(class="tweet-stats")):
|
buildHtml(tdiv(class="tweet-stats")):
|
||||||
span(class="tweet-stat"): text "💬 " & $stats.replies
|
span(class="tweet-stat"): text "💬 " & $stats.replies
|
||||||
|
@ -160,7 +186,9 @@ proc renderTweet*(tweet: Tweet; class=""; index=0; total=(-1); last=false): VNod
|
||||||
if tweet.quote.isSome:
|
if tweet.quote.isSome:
|
||||||
renderQuote(tweet.quote.get())
|
renderQuote(tweet.quote.get())
|
||||||
|
|
||||||
if tweet.photos.len > 0:
|
if tweet.card.isSome:
|
||||||
|
renderCard(tweet.card.get())
|
||||||
|
elif tweet.photos.len > 0:
|
||||||
renderAlbum(tweet)
|
renderAlbum(tweet)
|
||||||
elif tweet.video.isSome:
|
elif tweet.video.isSome:
|
||||||
renderVideo(tweet.video.get())
|
renderVideo(tweet.video.get())
|
||||||
|
|
Loading…
Reference in a new issue