mirror of
https://github.com/zedeus/nitter
synced 2026-10-26 20:53:25 +00:00
Merge branch 'zedeus:master' into master
This commit is contained in:
commit
162a15f7a8
40 changed files with 1552 additions and 178 deletions
|
|
@ -1,25 +1,76 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import options
|
||||
import karax/[karaxdsl, vdom]
|
||||
from jester import Request
|
||||
|
||||
import ".."/[types, formatters]
|
||||
import ".."/[types, formatters, prefs]
|
||||
import general, tweet
|
||||
|
||||
const doctype = "<!DOCTYPE html>\n"
|
||||
const
|
||||
doctype = "<!DOCTYPE html>\n"
|
||||
embedResizeJs = staticRead("../../public/js/embedResize.js")
|
||||
|
||||
proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string =
|
||||
let
|
||||
let
|
||||
video = tweet.getVideos()[0]
|
||||
thumb = video.thumb
|
||||
vidUrl = getVideoEmbed(cfg, tweet.id)
|
||||
prefs = Prefs(hlsPlayback: true, mp4Playback: true)
|
||||
prefs = Prefs(hlsPlayback: true, mp4Playback: true, proxyVideos: defaultPrefs.proxyVideos)
|
||||
tweetUrl = getLink(tweet)
|
||||
|
||||
let node = buildHtml(html(lang="en")):
|
||||
renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb]))
|
||||
base(target="_blank")
|
||||
|
||||
body:
|
||||
tdiv(class="embed-video"):
|
||||
renderVideo(video, prefs, "")
|
||||
a(class="video-overlay-link", href=tweetUrl):
|
||||
text "Watch on " & cfg.hostname
|
||||
|
||||
script:
|
||||
verbatim embedResizeJs
|
||||
|
||||
result = doctype & $node
|
||||
|
||||
proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string =
|
||||
let node = buildHtml(html(lang="en")):
|
||||
renderHead(prefs, cfg, req)
|
||||
base(target="_blank")
|
||||
|
||||
body:
|
||||
tdiv(class="embed-wrapper"):
|
||||
tdiv(class="tweet-embed"):
|
||||
a(class="tweet-link", href=getLink(tweet))
|
||||
renderTweet(tweet, prefs, path, mainTweet=true)
|
||||
a(class="embed-footer", href=getLink(tweet)):
|
||||
text "Read more on " & cfg.hostname
|
||||
|
||||
script:
|
||||
verbatim embedResizeJs
|
||||
|
||||
result = doctype & $node
|
||||
|
||||
proc renderErrorEmbed*(error: string; prefs: Prefs; cfg: Config; req: Request;
|
||||
tweetId = ""; username = ""): string =
|
||||
let link = if tweetId.len > 0:
|
||||
if username.len > 0: "/" & username & "/status/" & tweetId
|
||||
else: "/i/status/" & tweetId
|
||||
else: "/"
|
||||
|
||||
let node = buildHtml(html(lang="en")):
|
||||
renderHead(prefs, cfg, req)
|
||||
base(target="_blank")
|
||||
|
||||
body:
|
||||
tdiv(class="embed-wrapper"):
|
||||
tdiv(class="tweet-embed error-embed"):
|
||||
a(class="tweet-link", href=link)
|
||||
tdiv(class="error-panel"):
|
||||
span: text error
|
||||
a(class="embed-footer", href=link):
|
||||
text "Read more on " & cfg.hostname
|
||||
|
||||
script:
|
||||
verbatim embedResizeJs
|
||||
|
||||
result = doctype & $node
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
|
|||
|
||||
proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
||||
video=""; images: seq[string] = @[]; banner=""; ogTitle="";
|
||||
rss=""; alternate=""): VNode =
|
||||
rss=""; alternate=""; oembed=""): VNode =
|
||||
let theme = prefs.theme.toTheme
|
||||
|
||||
let ogType =
|
||||
|
|
@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
|
||||
|
||||
buildHtml(head):
|
||||
link(rel="stylesheet", type="text/css", href="/css/style.css?v=45")
|
||||
link(rel="stylesheet", type="text/css", href="/css/style.css?v=106")
|
||||
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=7")
|
||||
|
||||
if theme.len > 0:
|
||||
|
|
@ -70,6 +70,10 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||
if rss.len > 0:
|
||||
link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
|
||||
|
||||
if oembed.len > 0:
|
||||
let oembedTitle = if titleText.len > 0: titleText else: "oEmbed"
|
||||
link(rel="alternate", type="application/json+oembed", href=oembed, title=oembedTitle)
|
||||
|
||||
if prefs.hlsPlayback:
|
||||
script(src="/js/hls.min.js", `defer`="")
|
||||
script(src="/js/hlsPlayback.js?v=1", `defer`="")
|
||||
|
|
@ -124,7 +128,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
|
|||
proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
|
||||
titleText=""; desc=""; ogTitle=""; rss=""; video="";
|
||||
images: seq[string] = @[]; banner="";
|
||||
twitterLink=""): string =
|
||||
twitterLink=""; oembed=""): string =
|
||||
|
||||
let twitterLink =
|
||||
if twitterLink.len > 0: twitterLink
|
||||
|
|
@ -132,7 +136,7 @@ proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
|
|||
|
||||
let node = buildHtml(html(lang="en")):
|
||||
renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
|
||||
rss, twitterLink)
|
||||
rss, twitterLink, oembed)
|
||||
|
||||
let bodyClass = if prefs.stickyNav: "fixed-nav" else: ""
|
||||
body(class=bodyClass):
|
||||
|
|
|
|||
7
src/views/oembed.nimf
Normal file
7
src/views/oembed.nimf
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#? stdtmpl(subsChar = '$', metaChar = '#')
|
||||
## SPDX-License-Identifier: AGPL-3.0-only
|
||||
#proc renderOembedIframe*(embedUrl: string; maxwidth = 550): string =
|
||||
# result = ""
|
||||
<iframe src="$embedUrl" style="width:100%;max-width:${maxwidth}px;height:250px;border:none" scrolling="no" loading="lazy" onload="let c=new MessageChannel;c.port1.onmessage=e=>this.style.height=e.data+'px';this.contentWindow.postMessage('','*',[c.port2])"></iframe>
|
||||
# result = result.strip()
|
||||
#end proc
|
||||
|
|
@ -115,7 +115,7 @@ proc renderProtected*(username: string): VNode =
|
|||
proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode =
|
||||
profile.tweets.query.fromUser = @[profile.user.username]
|
||||
let
|
||||
isGalleryView = profile.tweets.query.kind == media and
|
||||
isGalleryView = profile.tweets.query.kind == QueryKind.media and
|
||||
profile.tweets.query.view == "gallery"
|
||||
viewClass = if isGalleryView: " media-only" else: ""
|
||||
|
||||
|
|
|
|||
|
|
@ -40,28 +40,45 @@ proc renderProfileTabs*(query: Query; username: string): VNode =
|
|||
li(class=query.getTabClass(tweets)):
|
||||
a(href=(link & "/search")): text "Search"
|
||||
|
||||
proc renderMediaViewTabs*(query: Query; username: string): VNode =
|
||||
proc mediaViewUrl(query: Query; view: string): string =
|
||||
var q = query
|
||||
q.view = view
|
||||
"?" & genQueryUrl(q)
|
||||
|
||||
proc renderMediaViewTabs*(query: Query): VNode =
|
||||
let currentView = if query.view.len > 0: query.view else: "timeline"
|
||||
let base = "/" & username & "/media?view="
|
||||
func cls(view: string): string =
|
||||
if currentView == view: "tab-item active" else: "tab-item"
|
||||
buildHtml(ul(class="tab media-view-tabs")):
|
||||
li(class=cls("timeline")):
|
||||
a(href=(base & "timeline")): text "Timeline"
|
||||
a(href=query.mediaViewUrl("timeline")): text "Timeline"
|
||||
li(class=cls("grid")):
|
||||
a(href=(base & "grid")): text "Grid"
|
||||
a(href=query.mediaViewUrl("grid")): text "Grid"
|
||||
li(class=cls("gallery")):
|
||||
a(href=(base & "gallery")): text "Gallery"
|
||||
a(href=query.mediaViewUrl("gallery")): text "Gallery"
|
||||
|
||||
proc renderSearchTabs*(query: Query): VNode =
|
||||
var q = query
|
||||
# the media view mode only applies to the Media tab
|
||||
q.view = ""
|
||||
buildHtml(ul(class="tab")):
|
||||
li(class=query.getTabClass(top)):
|
||||
q.kind = top
|
||||
a(href=("?" & genQueryUrl(q))): text "Top"
|
||||
li(class=query.getTabClass(tweets)):
|
||||
q.kind = tweets
|
||||
a(href=("?" & genQueryUrl(q))): text "Tweets"
|
||||
a(href=("?" & genQueryUrl(q))): text "Latest"
|
||||
li(class=query.getTabClass(media)):
|
||||
q.kind = media
|
||||
q.view = query.view
|
||||
a(href=("?" & genQueryUrl(q))): text "Media"
|
||||
li(class=query.getTabClass(users)):
|
||||
q.kind = users
|
||||
q.view = ""
|
||||
a(href=("?" & genQueryUrl(q))): text "Users"
|
||||
li(class=query.getTabClass(lists)):
|
||||
q.kind = lists
|
||||
a(href=("?" & genQueryUrl(q))): text "Lists"
|
||||
|
||||
proc isPanelOpen(q: Query): bool =
|
||||
q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or
|
||||
|
|
@ -72,7 +89,7 @@ proc renderSearchPanel*(query: Query): VNode =
|
|||
let action = if user.len > 0: &"/{user}/search" else: "/search"
|
||||
buildHtml(form(`method`="get", action=action,
|
||||
class="search-field", autocomplete="off")):
|
||||
hiddenField("f", "tweets")
|
||||
hiddenField("f", $query.kind)
|
||||
genInput("q", "", query.text, "Enter search...", class="pref-inline")
|
||||
button(`type`="submit"): icon "search"
|
||||
|
||||
|
|
@ -103,33 +120,52 @@ proc renderSearchPanel*(query: Query): VNode =
|
|||
proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string;
|
||||
pinned=none(Tweet)): VNode =
|
||||
let query = results.query
|
||||
buildHtml(tdiv(class="timeline-container")):
|
||||
let containerClass =
|
||||
if query.fromUser.len == 0 and query.kind == QueryKind.media and
|
||||
query.view == "gallery": "timeline-container media-only"
|
||||
else: "timeline-container"
|
||||
buildHtml(tdiv(class=containerClass)):
|
||||
if query.fromUser.len > 1:
|
||||
tdiv(class="timeline-header"):
|
||||
text query.fromUser.join(" | ")
|
||||
|
||||
if query.fromUser.len > 0:
|
||||
if query.kind != media or query.view != "gallery":
|
||||
if query.kind != QueryKind.media or query.view != "gallery":
|
||||
renderProfileTabs(query, query.fromUser.join(","))
|
||||
if query.kind == media and query.fromUser.len == 1:
|
||||
renderMediaViewTabs(query, query.fromUser[0])
|
||||
if query.kind == QueryKind.media and query.fromUser.len == 1:
|
||||
renderMediaViewTabs(query)
|
||||
|
||||
if query.fromUser.len == 0 or query.kind == tweets:
|
||||
if query.fromUser.len == 0 or query.kind == QueryKind.tweets:
|
||||
tdiv(class="timeline-header"):
|
||||
renderSearchPanel(query)
|
||||
|
||||
if query.fromUser.len == 0:
|
||||
renderSearchTabs(query)
|
||||
if query.kind == QueryKind.media:
|
||||
renderMediaViewTabs(query)
|
||||
|
||||
renderTimelineTweets(results, prefs, path, pinned)
|
||||
|
||||
proc renderSearchForm(kind, placeholder, value: string): VNode =
|
||||
buildHtml(form(`method`="get", action="/search",
|
||||
class="search-field", autocomplete="off")):
|
||||
hiddenField("f", kind)
|
||||
genInput("q", "", value, placeholder, class="pref-inline")
|
||||
button(`type`="submit"): icon "search"
|
||||
|
||||
proc renderUserSearch*(results: Result[User]; prefs: Prefs): VNode =
|
||||
buildHtml(tdiv(class="timeline-container")):
|
||||
tdiv(class="timeline-header"):
|
||||
form(`method`="get", action="/search", class="search-field", autocomplete="off"):
|
||||
hiddenField("f", "users")
|
||||
genInput("q", "", results.query.text, "Enter username...", class="pref-inline")
|
||||
button(`type`="submit"): icon "search"
|
||||
renderSearchForm("users", "Enter username...", results.query.text)
|
||||
|
||||
renderSearchTabs(results.query)
|
||||
renderTimelineUsers(results, prefs)
|
||||
|
||||
proc renderListSearch*(results: Result[ListSearchResult]; prefs: Prefs;
|
||||
path: string): VNode =
|
||||
buildHtml(tdiv(class="timeline-container")):
|
||||
tdiv(class="timeline-header"):
|
||||
renderSearchForm("lists", "Enter search...", results.query.text)
|
||||
|
||||
renderSearchTabs(results.query)
|
||||
renderTimelineLists(results, prefs, path)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
import sequtils
|
||||
import karax/[karaxdsl, vdom]
|
||||
|
||||
import ".."/[types, formatters]
|
||||
|
|
@ -28,21 +29,46 @@ proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode =
|
|||
if thread.hasMore:
|
||||
renderMoreReplies(thread)
|
||||
|
||||
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string; tweet: Tweet = nil): VNode =
|
||||
proc renderReplySort(sort: RankingMode): VNode =
|
||||
buildHtml(tdiv(class="reply-sort")):
|
||||
span(class="reply-sort-label"): text "Sort replies:"
|
||||
for mode in RankingMode:
|
||||
let
|
||||
cls = if mode == sort: "reply-sort-option active"
|
||||
else: "reply-sort-option"
|
||||
label = case mode
|
||||
of Relevance: "Relevant"
|
||||
of Recency: "Recent"
|
||||
of Likes: "Liked"
|
||||
a(class=cls, href=("?sort=" & $mode & "#r")):
|
||||
text label
|
||||
|
||||
proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string;
|
||||
tweet: Tweet = nil; sort = Relevance): VNode =
|
||||
buildHtml(tdiv(class="replies", id="r")):
|
||||
var hasReplies = false
|
||||
var replyCount = 0
|
||||
for thread in replies.content:
|
||||
if thread.content.len == 0: continue
|
||||
if thread.content.len == 0 or thread.related: continue
|
||||
hasReplies = true
|
||||
replyCount += thread.content.len
|
||||
renderReplyThread(thread, prefs, path)
|
||||
|
||||
if hasReplies and replies.bottom.len > 0:
|
||||
if tweet == nil or not replies.beginning or replyCount < tweet.stats.replies:
|
||||
renderMore(Query(), replies.bottom, focus="#r")
|
||||
let extra = if sort == Relevance: "" else: "sort=" & $sort & "&"
|
||||
renderMore(Query(), replies.bottom, focus="#r", extra=extra)
|
||||
|
||||
proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode =
|
||||
proc renderRelated(replies: Result[Chain]; prefs: Prefs; path: string): VNode =
|
||||
buildHtml(tdiv(class="related-tweets")):
|
||||
tdiv(class="related-header"):
|
||||
text "Related tweets"
|
||||
for thread in replies.content:
|
||||
if thread.content.len == 0 or not thread.related: continue
|
||||
renderReplyThread(thread, prefs, path)
|
||||
|
||||
proc renderConversation*(conv: Conversation; prefs: Prefs; path: string;
|
||||
sort = Relevance): VNode =
|
||||
let hasAfter = conv.after.content.len > 0
|
||||
let threadId = conv.tweet.threadId
|
||||
buildHtml(tdiv(class="conversation")):
|
||||
|
|
@ -75,7 +101,12 @@ proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode
|
|||
if not conv.replies.beginning:
|
||||
renderNewer(Query(), getLink(conv.tweet), focus="#r")
|
||||
if conv.replies.content.len > 0 or conv.replies.bottom.len > 0:
|
||||
renderReplies(conv.replies, prefs, path, conv.tweet)
|
||||
renderReplySort(sort)
|
||||
renderReplies(conv.replies, prefs, path, conv.tweet, sort)
|
||||
|
||||
if not prefs.hideRelated:
|
||||
if conv.replies.content.anyIt(it.related and it.content.len > 0):
|
||||
renderRelated(conv.replies, prefs, path)
|
||||
|
||||
renderToTop(focus="#m")
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import ".."/[types, query, formatters]
|
|||
import tweet, renderutils
|
||||
|
||||
proc timelineViewClass(query: Query): string =
|
||||
if query.kind != media:
|
||||
if query.kind != QueryKind.media:
|
||||
return "timeline"
|
||||
|
||||
case query.view
|
||||
|
|
@ -50,9 +50,9 @@ proc renderNewer*(query: Query; path: string; focus=""): VNode =
|
|||
a(href=(p & url)):
|
||||
text "Load newest"
|
||||
|
||||
proc renderMore*(query: Query; cursor: string; focus=""): VNode =
|
||||
proc renderMore*(query: Query; cursor: string; focus=""; extra=""): VNode =
|
||||
buildHtml(tdiv(class="show-more")):
|
||||
a(href=(&"?{getQuery(query)}cursor={encodeUrl(cursor, usePlus=false)}{focus}")):
|
||||
a(href=(&"?{extra}{getQuery(query)}cursor={encodeUrl(cursor, usePlus=false)}{focus}")):
|
||||
text "Load more"
|
||||
|
||||
proc renderNoMore(): VNode =
|
||||
|
|
@ -114,6 +114,84 @@ proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode =
|
|||
else:
|
||||
renderNoMore()
|
||||
|
||||
proc mentionUsername(word: string): string =
|
||||
# "@user" -> "user" for well-formed mentions, "" otherwise
|
||||
if word.len > 1 and word[0] == '@' and
|
||||
word[1 .. ^1].allCharsInSet({'A'..'Z', 'a'..'z', '0'..'9', '_'}):
|
||||
word[1 .. ^1]
|
||||
else: ""
|
||||
|
||||
proc mentionedUser(s: string): string =
|
||||
# last @mention in strings like "65 followers including @user"
|
||||
let words = s.split(' ')
|
||||
for i in countdown(words.high, 0):
|
||||
result = mentionUsername(words[i])
|
||||
if result.len > 0: return
|
||||
|
||||
proc renderMentionedText(s: string): VNode =
|
||||
# linkify @mentions in plain API strings like "65 followers including @user"
|
||||
let words = s.split(' ')
|
||||
buildHtml(span):
|
||||
for i in 0 ..< words.len:
|
||||
if i > 0: text " "
|
||||
let username = mentionUsername(words[i])
|
||||
if username.len > 0:
|
||||
a(href=("/" & username)): text words[i]
|
||||
else:
|
||||
text words[i]
|
||||
|
||||
proc renderListCard(r: ListSearchResult): VNode =
|
||||
let listUrl = "/i/lists/" & r.list.id
|
||||
buildHtml(tdiv(class="timeline-item list-result")):
|
||||
a(class="tweet-link", href=listUrl)
|
||||
a(class="list-result-banner", href=listUrl):
|
||||
if r.list.banner.len > 0:
|
||||
genImg(r.list.banner)
|
||||
tdiv(class="list-result-body"):
|
||||
tdiv(class="list-result-title fullname-and-username"):
|
||||
a(class="list-name fullname", href=listUrl): text r.list.name
|
||||
span(class="list-members"):
|
||||
text &"· {insertSep($r.list.members, ',')} members"
|
||||
tdiv(class="list-result-context"):
|
||||
if r.followersContext.len > 0:
|
||||
# the first facepile belongs to the "including @user" account
|
||||
let mentioned = mentionedUser(r.followersContext)
|
||||
for i in 0 ..< r.facepiles.len:
|
||||
if i == 0 and mentioned.len > 0:
|
||||
a(class="facepile-link", href=("/" & mentioned)):
|
||||
genImg(r.facepiles[i], class="list-facepile")
|
||||
else:
|
||||
genImg(r.facepiles[i], class="list-facepile")
|
||||
renderMentionedText(r.followersContext)
|
||||
else:
|
||||
if r.owner.username.len > 0:
|
||||
a(class="facepile-link", href=("/" & r.owner.username)):
|
||||
genImg(r.owner.getUserPic("_mini"), class="list-facepile")
|
||||
else:
|
||||
genImg(r.owner.getUserPic("_mini"), class="list-facepile")
|
||||
linkUser(r.owner, class="fullname")
|
||||
linkUser(r.owner, class="username")
|
||||
if r.list.description.len > 0:
|
||||
tdiv(class="list-result-description"):
|
||||
text r.list.description
|
||||
|
||||
proc renderTimelineLists*(results: Result[ListSearchResult]; prefs: Prefs;
|
||||
path=""): VNode =
|
||||
buildHtml(tdiv(class="timeline")):
|
||||
if not results.beginning:
|
||||
renderNewer(results.query, path)
|
||||
|
||||
if results.content.len > 0:
|
||||
for list in results.content:
|
||||
renderListCard(list)
|
||||
if results.bottom.len > 0:
|
||||
renderMore(results.query, results.bottom)
|
||||
renderToTop()
|
||||
elif results.beginning:
|
||||
renderNoneFound()
|
||||
else:
|
||||
renderNoMore()
|
||||
|
||||
proc filterThreads(threads: seq[Tweets]; prefs: Prefs): seq[Tweets] =
|
||||
var retweets: seq[int64]
|
||||
for thread in threads:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ from jester import Request
|
|||
|
||||
import renderutils
|
||||
import ".."/[types, utils, formatters]
|
||||
import general
|
||||
|
||||
const doctype = "<!DOCTYPE html>\n"
|
||||
|
||||
|
|
@ -464,13 +463,3 @@ proc renderTweet*(tweet: Tweet; prefs: Prefs; path: string; class=""; index=0;
|
|||
|
||||
if not prefs.hideTweetStats:
|
||||
renderStats(tweet.stats)
|
||||
|
||||
proc renderTweetEmbed*(tweet: Tweet; path: string; prefs: Prefs; cfg: Config; req: Request): string =
|
||||
let node = buildHtml(html(lang="en")):
|
||||
renderHead(prefs, cfg, req)
|
||||
|
||||
body:
|
||||
tdiv(class="tweet-embed"):
|
||||
renderTweet(tweet, prefs, path, mainTweet=true)
|
||||
|
||||
result = doctype & $node
|
||||
|
|
|
|||
Loading…
Reference in a new issue