Add client preferences
This commit is contained in:
parent
c42b2893ff
commit
966b3d5d62
|
@ -277,7 +277,7 @@ nav {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
video {
|
video, .video-container img {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
@ -1073,3 +1073,45 @@ video {
|
||||||
.poll-info {
|
.poll-info {
|
||||||
color: #868687;
|
color: #868687;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preferences-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preferences {
|
||||||
|
background-color: #222222;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
margin: .35em 0 .75em;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
width: 100%;
|
||||||
|
padding: .6em 0 .3em 0;
|
||||||
|
margin-bottom: .2em;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid #888888;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pref-group {
|
||||||
|
margin: .2em; 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pref-submit:hover {
|
||||||
|
background-color: #a8a8a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pref-submit {
|
||||||
|
background-color: #e2e2e2;
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
margin-left: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import asyncdispatch, times
|
import asyncdispatch, times
|
||||||
import types, api
|
import types, api
|
||||||
|
|
||||||
withDb:
|
withCustomDb("cache.db", "", "", ""):
|
||||||
try:
|
try:
|
||||||
createTables()
|
createTables()
|
||||||
except DbError:
|
except DbError:
|
||||||
|
@ -13,7 +13,7 @@ proc isOutdated*(profile: Profile): bool =
|
||||||
getTime() - profile.updated > profileCacheTime
|
getTime() - profile.updated > profileCacheTime
|
||||||
|
|
||||||
proc cache*(profile: var Profile) =
|
proc cache*(profile: var Profile) =
|
||||||
withDb:
|
withCustomDb("cache.db", "", "", ""):
|
||||||
try:
|
try:
|
||||||
let p = Profile.getOne("lower(username) = ?", toLower(profile.username))
|
let p = Profile.getOne("lower(username) = ?", toLower(profile.username))
|
||||||
profile.id = p.id
|
profile.id = p.id
|
||||||
|
@ -23,7 +23,7 @@ proc cache*(profile: var Profile) =
|
||||||
profile.insert()
|
profile.insert()
|
||||||
|
|
||||||
proc hasCachedProfile*(username: string): Option[Profile] =
|
proc hasCachedProfile*(username: string): Option[Profile] =
|
||||||
withDb:
|
withCustomDb("cache.db", "", "", ""):
|
||||||
try:
|
try:
|
||||||
let p = Profile.getOne("lower(username) = ?", toLower(username))
|
let p = Profile.getOne("lower(username) = ?", toLower(username))
|
||||||
doAssert not p.isOutdated
|
doAssert not p.isOutdated
|
||||||
|
@ -32,7 +32,7 @@ proc hasCachedProfile*(username: string): Option[Profile] =
|
||||||
result = none(Profile)
|
result = none(Profile)
|
||||||
|
|
||||||
proc getCachedProfile*(username, agent: string; force=false): Future[Profile] {.async.} =
|
proc getCachedProfile*(username, agent: string; force=false): Future[Profile] {.async.} =
|
||||||
withDb:
|
withCustomDb("cache.db", "", "", ""):
|
||||||
try:
|
try:
|
||||||
result.getOne("lower(username) = ?", toLower(username))
|
result.getOne("lower(username) = ?", toLower(username))
|
||||||
doAssert not result.isOutdated
|
doAssert not result.isOutdated
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import parsecfg except Config
|
import parsecfg except Config
|
||||||
import os, net, types, strutils
|
import net, types, strutils
|
||||||
|
|
||||||
proc get[T](config: parseCfg.Config; s, v: string; default: T): T =
|
proc get[T](config: parseCfg.Config; s, v: string; default: T): T =
|
||||||
let val = config.getSectionValue(s, v)
|
let val = config.getSectionValue(s, v)
|
||||||
|
|
|
@ -3,13 +3,14 @@ from net import Port
|
||||||
|
|
||||||
import jester, regex
|
import jester, regex
|
||||||
|
|
||||||
import api, utils, types, cache, formatters, search, config, agents
|
import api, utils, types, cache, formatters, search, config, prefs, agents
|
||||||
import views/[general, profile, status]
|
import views/[general, profile, status, preferences]
|
||||||
|
|
||||||
const configPath {.strdefine.} = "./nitter.conf"
|
const configPath {.strdefine.} = "./nitter.conf"
|
||||||
let cfg = getConfig(configPath)
|
let cfg = getConfig(configPath)
|
||||||
|
|
||||||
proc showSingleTimeline(name, after, agent: string; query: Option[Query]): Future[string] {.async.} =
|
proc showSingleTimeline(name, after, agent: string; query: Option[Query];
|
||||||
|
prefs: Prefs): Future[string] {.async.} =
|
||||||
let railFut = getPhotoRail(name, agent)
|
let railFut = getPhotoRail(name, agent)
|
||||||
|
|
||||||
var timeline: Timeline
|
var timeline: Timeline
|
||||||
|
@ -34,33 +35,41 @@ proc showSingleTimeline(name, after, agent: string; query: Option[Query]): Futur
|
||||||
if profile.username.len == 0:
|
if profile.username.len == 0:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
let profileHtml = renderProfile(profile, timeline, await railFut)
|
let profileHtml = renderProfile(profile, timeline, await railFut, prefs)
|
||||||
return renderMain(profileHtml, title=cfg.title, titleText=pageTitle(profile), desc=pageDesc(profile))
|
return renderMain(profileHtml, prefs, title=cfg.title, titleText=pageTitle(profile),
|
||||||
|
desc=pageDesc(profile))
|
||||||
|
|
||||||
proc showMultiTimeline(names: seq[string]; after, agent: string; query: Option[Query]): Future[string] {.async.} =
|
proc showMultiTimeline(names: seq[string]; after, agent: string; query: Option[Query];
|
||||||
|
prefs: Prefs): Future[string] {.async.} =
|
||||||
var q = query
|
var q = query
|
||||||
if q.isSome:
|
if q.isSome:
|
||||||
get(q).fromUser = names
|
get(q).fromUser = names
|
||||||
else:
|
else:
|
||||||
q = some(Query(kind: multi, fromUser: names, excludes: @["replies"]))
|
q = some(Query(kind: multi, fromUser: names, excludes: @["replies"]))
|
||||||
|
|
||||||
var timeline = renderMulti(await getTimelineSearch(get(q), after, agent), names.join(","))
|
var timeline = renderMulti(await getTimelineSearch(get(q), after, agent),
|
||||||
return renderMain(timeline, title=cfg.title, titleText="Multi")
|
names.join(","), prefs)
|
||||||
|
|
||||||
proc showTimeline(name, after: string; query: Option[Query]): Future[string] {.async.} =
|
return renderMain(timeline, prefs, title=cfg.title, titleText="Multi")
|
||||||
|
|
||||||
|
proc showTimeline(name, after: string; query: Option[Query];
|
||||||
|
prefs: Prefs): Future[string] {.async.} =
|
||||||
let agent = getAgent()
|
let agent = getAgent()
|
||||||
let names = name.strip(chars={'/'}).split(",").filterIt(it.len > 0)
|
let names = name.strip(chars={'/'}).split(",").filterIt(it.len > 0)
|
||||||
|
|
||||||
if names.len == 1:
|
if names.len == 1:
|
||||||
return await showSingleTimeline(names[0], after, agent, query)
|
return await showSingleTimeline(names[0], after, agent, query, prefs)
|
||||||
else:
|
else:
|
||||||
return await showMultiTimeline(names, after, agent, query)
|
return await showMultiTimeline(names, after, agent, query, prefs)
|
||||||
|
|
||||||
template respTimeline(timeline: typed) =
|
template respTimeline(timeline: typed) =
|
||||||
if timeline.len == 0:
|
if timeline.len == 0:
|
||||||
resp Http404, showError("User \"" & @"name" & "\" not found", cfg.title)
|
resp Http404, showError("User \"" & @"name" & "\" not found", cfg.title, prefs)
|
||||||
resp timeline
|
resp timeline
|
||||||
|
|
||||||
|
proc getCookiePrefs(request: Request): Prefs =
|
||||||
|
getPrefs(request.cookies.getOrDefault("preferences"))
|
||||||
|
|
||||||
setProfileCacheTime(cfg.profileCacheTime)
|
setProfileCacheTime(cfg.profileCacheTime)
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
|
@ -70,58 +79,76 @@ settings:
|
||||||
|
|
||||||
routes:
|
routes:
|
||||||
get "/":
|
get "/":
|
||||||
resp renderMain(renderSearch(), title=cfg.title)
|
let prefs = getCookiePrefs(request)
|
||||||
|
resp renderMain(renderSearch(), prefs, title=cfg.title)
|
||||||
|
|
||||||
post "/search":
|
post "/search":
|
||||||
if @"query".len == 0:
|
if @"query".len == 0:
|
||||||
resp Http404, showError("Please enter a username.", cfg.title)
|
resp Http404, showError("Please enter a username.", cfg.title,
|
||||||
|
getCookiePrefs(request))
|
||||||
redirect("/" & @"query")
|
redirect("/" & @"query")
|
||||||
|
|
||||||
|
post "/saveprefs":
|
||||||
|
var prefs = getCookiePrefs(request)
|
||||||
|
genUpdatePrefs()
|
||||||
|
setCookie("preferences", $prefs.id, daysForward(360))
|
||||||
|
redirect("/settings")
|
||||||
|
|
||||||
|
get "/settings":
|
||||||
|
let prefs = getCookiePrefs(request)
|
||||||
|
resp renderMain(renderPreferences(prefs), prefs, title=cfg.title, titleText="Preferences")
|
||||||
|
|
||||||
get "/@name/?":
|
get "/@name/?":
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
respTimeline(await showTimeline(@"name", @"after", none(Query)))
|
let prefs = getCookiePrefs(request)
|
||||||
|
respTimeline(await showTimeline(@"name", @"after", none(Query), prefs))
|
||||||
|
|
||||||
get "/@name/search":
|
get "/@name/search":
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
|
let prefs = getCookiePrefs(request)
|
||||||
let query = initQuery(@"filter", @"include", @"not", @"sep", @"name")
|
let query = initQuery(@"filter", @"include", @"not", @"sep", @"name")
|
||||||
respTimeline(await showTimeline(@"name", @"after", some(query)))
|
respTimeline(await showTimeline(@"name", @"after", some(query), prefs))
|
||||||
|
|
||||||
get "/@name/replies":
|
get "/@name/replies":
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
respTimeline(await showTimeline(@"name", @"after", some(getReplyQuery(@"name"))))
|
let prefs = getCookiePrefs(request)
|
||||||
|
respTimeline(await showTimeline(@"name", @"after", some(getReplyQuery(@"name")), prefs))
|
||||||
|
|
||||||
get "/@name/media":
|
get "/@name/media":
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
respTimeline(await showTimeline(@"name", @"after", some(getMediaQuery(@"name"))))
|
let prefs = getCookiePrefs(request)
|
||||||
|
respTimeline(await showTimeline(@"name", @"after", some(getMediaQuery(@"name")), prefs))
|
||||||
|
|
||||||
get "/@name/status/@id":
|
get "/@name/status/@id":
|
||||||
cond '.' notin @"name"
|
cond '.' notin @"name"
|
||||||
|
let prefs = getCookiePrefs(request)
|
||||||
|
|
||||||
let conversation = await getTweet(@"name", @"id", getAgent())
|
let conversation = await getTweet(@"name", @"id", getAgent())
|
||||||
if conversation == nil or conversation.tweet.id.len == 0:
|
if conversation == nil or conversation.tweet.id.len == 0:
|
||||||
resp Http404, showError("Tweet not found", cfg.title)
|
resp Http404, showError("Tweet not found", cfg.title, prefs)
|
||||||
|
|
||||||
let title = pageTitle(conversation.tweet.profile)
|
let title = pageTitle(conversation.tweet.profile)
|
||||||
let desc = conversation.tweet.text
|
let desc = conversation.tweet.text
|
||||||
let html = renderConversation(conversation)
|
let html = renderConversation(conversation, prefs)
|
||||||
|
|
||||||
if conversation.tweet.video.isSome():
|
if conversation.tweet.video.isSome():
|
||||||
let thumb = get(conversation.tweet.video).thumb
|
let thumb = get(conversation.tweet.video).thumb
|
||||||
let vidUrl = getVideoEmbed(conversation.tweet.id)
|
let vidUrl = getVideoEmbed(conversation.tweet.id)
|
||||||
resp renderMain(html, title=cfg.title, titleText=title, desc=desc,
|
resp renderMain(html, prefs, title=cfg.title, titleText=title, desc=desc,
|
||||||
images = @[thumb], `type`="video", video=vidUrl)
|
images = @[thumb], `type`="video", video=vidUrl)
|
||||||
elif conversation.tweet.gif.isSome():
|
elif conversation.tweet.gif.isSome():
|
||||||
let thumb = get(conversation.tweet.gif).thumb
|
let thumb = get(conversation.tweet.gif).thumb
|
||||||
let vidUrl = getVideoEmbed(conversation.tweet.id)
|
let vidUrl = getVideoEmbed(conversation.tweet.id)
|
||||||
resp renderMain(html, title=cfg.title, titleText=title, desc=desc,
|
resp renderMain(html, prefs, title=cfg.title, titleText=title, desc=desc,
|
||||||
images = @[thumb], `type`="video", video=vidUrl)
|
images = @[thumb], `type`="video", video=vidUrl)
|
||||||
else:
|
else:
|
||||||
resp renderMain(html, title=cfg.title, titleText=title,
|
resp renderMain(html, prefs, title=cfg.title, titleText=title,
|
||||||
desc=desc, images=conversation.tweet.photos)
|
desc=desc, images=conversation.tweet.photos)
|
||||||
|
|
||||||
get "/pic/@sig/@url":
|
get "/pic/@sig/@url":
|
||||||
cond "http" in @"url"
|
cond "http" in @"url"
|
||||||
cond "twimg" in @"url"
|
cond "twimg" in @"url"
|
||||||
|
let prefs = getCookiePrefs(request)
|
||||||
|
|
||||||
let
|
let
|
||||||
uri = parseUri(decodeUrl(@"url"))
|
uri = parseUri(decodeUrl(@"url"))
|
||||||
|
@ -129,7 +156,7 @@ routes:
|
||||||
filename = cfg.cacheDir / cleanFilename(path & uri.query)
|
filename = cfg.cacheDir / cleanFilename(path & uri.query)
|
||||||
|
|
||||||
if getHmac($uri) != @"sig":
|
if getHmac($uri) != @"sig":
|
||||||
resp showError("Failed to verify signature", cfg.title)
|
resp showError("Failed to verify signature", cfg.title, prefs)
|
||||||
|
|
||||||
if not existsDir(cfg.cacheDir):
|
if not existsDir(cfg.cacheDir):
|
||||||
createDir(cfg.cacheDir)
|
createDir(cfg.cacheDir)
|
||||||
|
@ -151,10 +178,11 @@ routes:
|
||||||
get "/video/@sig/@url":
|
get "/video/@sig/@url":
|
||||||
cond "http" in @"url"
|
cond "http" in @"url"
|
||||||
cond "video.twimg" in @"url"
|
cond "video.twimg" in @"url"
|
||||||
|
let prefs = getCookiePrefs(request)
|
||||||
let url = decodeUrl(@"url")
|
let url = decodeUrl(@"url")
|
||||||
|
|
||||||
if getHmac(url) != @"sig":
|
if getHmac(url) != @"sig":
|
||||||
resp showError("Failed to verify signature", cfg.title)
|
resp showError("Failed to verify signature", cfg.title, prefs)
|
||||||
|
|
||||||
let
|
let
|
||||||
client = newAsyncHttpClient()
|
client = newAsyncHttpClient()
|
||||||
|
|
|
@ -0,0 +1,95 @@
|
||||||
|
import asyncdispatch, times, macros, tables
|
||||||
|
import types
|
||||||
|
|
||||||
|
withCustomDb("prefs.db", "", "", ""):
|
||||||
|
try:
|
||||||
|
createTables()
|
||||||
|
except DbError:
|
||||||
|
discard
|
||||||
|
|
||||||
|
type
|
||||||
|
PrefKind* = enum
|
||||||
|
checkbox, select, input
|
||||||
|
|
||||||
|
Pref* = object
|
||||||
|
name*: string
|
||||||
|
label*: string
|
||||||
|
case kind*: PrefKind
|
||||||
|
of checkbox:
|
||||||
|
defaultState*: bool
|
||||||
|
of select:
|
||||||
|
defaultOption*: string
|
||||||
|
options*: seq[string]
|
||||||
|
of input:
|
||||||
|
defaultInput*: string
|
||||||
|
placeholder*: string
|
||||||
|
|
||||||
|
const prefList*: Table[string, seq[Pref]] = {
|
||||||
|
"Media": @[
|
||||||
|
Pref(kind: checkbox, name: "videoPlayback",
|
||||||
|
label: "Enable hls.js video playback (requires JavaScript)",
|
||||||
|
defaultState: false),
|
||||||
|
|
||||||
|
Pref(kind: checkbox, name: "autoplayGifs", label: "Autoplay gifs",
|
||||||
|
defaultState: true),
|
||||||
|
]
|
||||||
|
}.toTable
|
||||||
|
|
||||||
|
iterator allPrefs(): Pref =
|
||||||
|
for k, v in prefList:
|
||||||
|
for pref in v:
|
||||||
|
yield pref
|
||||||
|
|
||||||
|
macro genDefaultPrefs*(): untyped =
|
||||||
|
result = nnkObjConstr.newTree(ident("Prefs"))
|
||||||
|
|
||||||
|
for pref in allPrefs():
|
||||||
|
result.add nnkExprColonExpr.newTree(
|
||||||
|
ident(pref.name),
|
||||||
|
case pref.kind
|
||||||
|
of checkbox: newLit(pref.defaultState)
|
||||||
|
of select: newLit(pref.defaultOption)
|
||||||
|
of input: newLit(pref.defaultInput))
|
||||||
|
|
||||||
|
proc cache*(prefs: var Prefs) =
|
||||||
|
withCustomDb("prefs.db", "", "", ""):
|
||||||
|
try:
|
||||||
|
doAssert prefs.id != 0
|
||||||
|
discard Prefs.getOne("id = ?", prefs.id)
|
||||||
|
prefs.update()
|
||||||
|
except AssertionError, KeyError:
|
||||||
|
prefs.insert()
|
||||||
|
|
||||||
|
proc getPrefs*(id: string): Prefs =
|
||||||
|
if id.len == 0: return genDefaultPrefs()
|
||||||
|
|
||||||
|
withCustomDb("prefs.db", "", "", ""):
|
||||||
|
try:
|
||||||
|
result.getOne("id = ?", id)
|
||||||
|
except KeyError:
|
||||||
|
result = genDefaultPrefs()
|
||||||
|
cache(result)
|
||||||
|
|
||||||
|
macro genUpdatePrefs*(): untyped =
|
||||||
|
result = nnkStmtList.newTree()
|
||||||
|
|
||||||
|
for pref in allPrefs():
|
||||||
|
let ident = ident(pref.name)
|
||||||
|
let value = nnkPrefix.newTree(ident("@"), newLit(pref.name))
|
||||||
|
|
||||||
|
case pref.kind
|
||||||
|
of checkbox:
|
||||||
|
result.add quote do:
|
||||||
|
prefs.`ident` = `value` == "on"
|
||||||
|
of select:
|
||||||
|
let options = pref.options
|
||||||
|
let default = pref.defaultOption
|
||||||
|
result.add quote do:
|
||||||
|
if `value` in `options`: prefs.`ident` = `value`
|
||||||
|
else: prefs.`ident` = `default`
|
||||||
|
of input:
|
||||||
|
result.add quote do:
|
||||||
|
prefs.`ident` = `value`
|
||||||
|
|
||||||
|
result.add quote do:
|
||||||
|
cache(prefs)
|
|
@ -23,23 +23,17 @@ db("cache.db", "", "", ""):
|
||||||
likes*: string
|
likes*: string
|
||||||
media*: string
|
media*: string
|
||||||
verified* {.
|
verified* {.
|
||||||
dbType: "STRING",
|
dbType: "STRING", parseIt: parseBool(it.s), formatIt: $it.}: bool
|
||||||
parseIt: parseBool(it.s)
|
|
||||||
formatIt: $it
|
|
||||||
.}: bool
|
|
||||||
protected* {.
|
protected* {.
|
||||||
dbType: "STRING",
|
dbType: "STRING", parseIt: parseBool(it.s), formatIt: $it.}: bool
|
||||||
parseIt: parseBool(it.s)
|
|
||||||
formatIt: $it
|
|
||||||
.}: bool
|
|
||||||
joinDate* {.
|
joinDate* {.
|
||||||
dbType: "INTEGER",
|
dbType: "INTEGER"
|
||||||
parseIt: it.i.fromUnix(),
|
parseIt: it.i.fromUnix()
|
||||||
formatIt: it.toUnix()
|
formatIt: it.toUnix()
|
||||||
.}: Time
|
.}: Time
|
||||||
updated* {.
|
updated* {.
|
||||||
dbType: "INTEGER",
|
dbType: "INTEGER"
|
||||||
parseIt: it.i.fromUnix(),
|
parseIt: it.i.fromUnix()
|
||||||
formatIt: getTime().toUnix()
|
formatIt: getTime().toUnix()
|
||||||
.}: Time
|
.}: Time
|
||||||
|
|
||||||
|
@ -61,6 +55,12 @@ db("cache.db", "", "", ""):
|
||||||
formatIt: $it
|
formatIt: $it
|
||||||
.}: bool
|
.}: bool
|
||||||
|
|
||||||
|
Prefs* = object
|
||||||
|
videoPlayback* {.
|
||||||
|
dbType: "STRING", parseIt: parseBool(it.s), formatIt: $it.}: bool
|
||||||
|
autoplayGifs* {.
|
||||||
|
dbType: "STRING", parseIt: parseBool(it.s), formatIt: $it.}: bool
|
||||||
|
|
||||||
type
|
type
|
||||||
QueryKind* = enum
|
QueryKind* = enum
|
||||||
replies, media, multi, custom = "search"
|
replies, media, multi, custom = "search"
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import karax/[karaxdsl, vdom]
|
import karax/[karaxdsl, vdom]
|
||||||
|
|
||||||
import ../utils
|
import ../utils, ../types
|
||||||
|
|
||||||
const doctype = "<!DOCTYPE html>\n"
|
const doctype = "<!DOCTYPE html>\n"
|
||||||
|
|
||||||
|
@ -14,9 +14,9 @@ proc renderNavbar*(title: string): VNode =
|
||||||
|
|
||||||
tdiv(class="item right"):
|
tdiv(class="item right"):
|
||||||
a(class="site-about", href="/about"): text "🛈"
|
a(class="site-about", href="/about"): text "🛈"
|
||||||
a(class="site-settings", href="/settings"): text "⚙"
|
a(class="site-prefs", href="/settings"): text "⚙"
|
||||||
|
|
||||||
proc renderMain*(body: VNode; title="Nitter"; titleText=""; desc="";
|
proc renderMain*(body: VNode; prefs: Prefs; title="Nitter"; titleText=""; desc="";
|
||||||
`type`="article"; video=""; images: seq[string] = @[]): string =
|
`type`="article"; video=""; images: seq[string] = @[]): string =
|
||||||
let node = buildHtml(html(lang="en")):
|
let node = buildHtml(html(lang="en")):
|
||||||
head:
|
head:
|
||||||
|
@ -60,5 +60,5 @@ proc renderError*(error: string): VNode =
|
||||||
tdiv(class="error-panel"):
|
tdiv(class="error-panel"):
|
||||||
span: text error
|
span: text error
|
||||||
|
|
||||||
proc showError*(error: string; title: string): string =
|
proc showError*(error: string; title: string; prefs: Prefs): string =
|
||||||
renderMain(renderError(error), title=title, titleText="Error")
|
renderMain(renderError(error), prefs, title=title, titleText="Error")
|
||||||
|
|
|
@ -0,0 +1,69 @@
|
||||||
|
import tables, macros
|
||||||
|
import karax/[karaxdsl, vdom, vstyles]
|
||||||
|
|
||||||
|
import ../types, ../prefs
|
||||||
|
|
||||||
|
proc genCheckbox(pref: string; label: string; state: bool): VNode =
|
||||||
|
buildHtml(tdiv(class="pref-group")):
|
||||||
|
if state:
|
||||||
|
input(name=pref, `type`="checkbox", checked="")
|
||||||
|
else:
|
||||||
|
input(name=pref, `type`="checkbox")
|
||||||
|
label(`for`=pref): text label
|
||||||
|
|
||||||
|
proc genSelect(pref: string; label: string; options: seq[string]; state: string): VNode =
|
||||||
|
buildHtml(tdiv(class="pref-group")):
|
||||||
|
select(name=pref):
|
||||||
|
for opt in options:
|
||||||
|
if opt == state:
|
||||||
|
option(value=opt, selected=""): text opt
|
||||||
|
else:
|
||||||
|
option(value=opt): text opt
|
||||||
|
label(`for`=pref): text label
|
||||||
|
|
||||||
|
proc genInput(pref: string; label: string; placeholder, state: string): VNode =
|
||||||
|
buildHtml(tdiv(class="pref-group")):
|
||||||
|
input(name=pref, `type`="text", placeholder=placeholder, value=state)
|
||||||
|
label(`for`=pref): text label
|
||||||
|
|
||||||
|
macro renderPrefs*(): untyped =
|
||||||
|
result = nnkCall.newTree(
|
||||||
|
ident("buildHtml"), ident("tdiv"), nnkStmtList.newTree())
|
||||||
|
|
||||||
|
for header, options in prefList:
|
||||||
|
result[2].add nnkCall.newTree(
|
||||||
|
ident("legend"),
|
||||||
|
nnkStmtList.newTree(
|
||||||
|
nnkCommand.newTree(ident("text"), newLit(header))))
|
||||||
|
|
||||||
|
for pref in options:
|
||||||
|
let name = newLit(pref.name)
|
||||||
|
let label = newLit(pref.label)
|
||||||
|
let field = ident(pref.name)
|
||||||
|
case pref.kind
|
||||||
|
of checkbox:
|
||||||
|
result[2].add nnkStmtList.newTree(
|
||||||
|
nnkCall.newTree(
|
||||||
|
ident("genCheckbox"), name, label,
|
||||||
|
nnkDotExpr.newTree(ident("prefs"), field)))
|
||||||
|
of select:
|
||||||
|
let options = newLit(pref.options)
|
||||||
|
result[2].add nnkStmtList.newTree(
|
||||||
|
nnkCall.newTree(
|
||||||
|
ident("genSelect"), name, label, options,
|
||||||
|
nnkDotExpr.newTree(ident("prefs"), field)))
|
||||||
|
of input:
|
||||||
|
let placeholder = newLit(pref.placeholder)
|
||||||
|
result[2].add nnkStmtList.newTree(
|
||||||
|
nnkCall.newTree(
|
||||||
|
ident("genInput"), name, label, placeholder,
|
||||||
|
nnkDotExpr.newTree(ident("prefs"), field)))
|
||||||
|
|
||||||
|
proc renderPreferences*(prefs: Prefs): VNode =
|
||||||
|
buildHtml(tdiv(class="preferences-container")):
|
||||||
|
form(class="preferences", `method`="post", action="saveprefs"):
|
||||||
|
fieldset:
|
||||||
|
renderPrefs()
|
||||||
|
|
||||||
|
button(`type`="submit", class="pref-submit"):
|
||||||
|
text "Save preferences"
|
|
@ -68,7 +68,7 @@ proc renderBanner(profile: Profile): VNode =
|
||||||
genImg(profile.banner)
|
genImg(profile.banner)
|
||||||
|
|
||||||
proc renderProfile*(profile: Profile; timeline: Timeline;
|
proc renderProfile*(profile: Profile; timeline: Timeline;
|
||||||
photoRail: seq[GalleryPhoto]): VNode =
|
photoRail: seq[GalleryPhoto]; prefs: Prefs): VNode =
|
||||||
buildHtml(tdiv(class="profile-tabs")):
|
buildHtml(tdiv(class="profile-tabs")):
|
||||||
tdiv(class="profile-banner"):
|
tdiv(class="profile-banner"):
|
||||||
renderBanner(profile)
|
renderBanner(profile)
|
||||||
|
@ -79,9 +79,9 @@ proc renderProfile*(profile: Profile; timeline: Timeline;
|
||||||
renderPhotoRail(profile, photoRail)
|
renderPhotoRail(profile, photoRail)
|
||||||
|
|
||||||
tdiv(class="timeline-tab"):
|
tdiv(class="timeline-tab"):
|
||||||
renderTimeline(timeline, profile.username, profile.protected)
|
renderTimeline(timeline, profile.username, profile.protected, prefs)
|
||||||
|
|
||||||
proc renderMulti*(timeline: Timeline; usernames: string): VNode =
|
proc renderMulti*(timeline: Timeline; usernames: string; prefs: Prefs): VNode =
|
||||||
buildHtml(tdiv(class="multi-timeline")):
|
buildHtml(tdiv(class="multi-timeline")):
|
||||||
tdiv(class="timeline-tab"):
|
tdiv(class="timeline-tab"):
|
||||||
renderTimeline(timeline, usernames, false, multi=true)
|
renderTimeline(timeline, usernames, false, prefs, multi=true)
|
||||||
|
|
|
@ -4,11 +4,11 @@ import karax/[karaxdsl, vdom]
|
||||||
import ../types
|
import ../types
|
||||||
import tweet, renderutils
|
import tweet, renderutils
|
||||||
|
|
||||||
proc renderReplyThread(thread: Thread): VNode =
|
proc renderReplyThread(thread: Thread; prefs: Prefs): VNode =
|
||||||
buildHtml(tdiv(class="reply thread thread-line")):
|
buildHtml(tdiv(class="reply thread thread-line")):
|
||||||
for i, tweet in thread.tweets:
|
for i, tweet in thread.tweets:
|
||||||
let last = (i == thread.tweets.high and thread.more == 0)
|
let last = (i == thread.tweets.high and thread.more == 0)
|
||||||
renderTweet(tweet, index=i, last=last)
|
renderTweet(tweet, prefs, index=i, last=last)
|
||||||
|
|
||||||
if thread.more != 0:
|
if thread.more != 0:
|
||||||
let num = if thread.more != -1: $thread.more & " " else: ""
|
let num = if thread.more != -1: $thread.more & " " else: ""
|
||||||
|
@ -17,26 +17,26 @@ proc renderReplyThread(thread: Thread): VNode =
|
||||||
a(class="more-replies-text", title="Not implemented yet"):
|
a(class="more-replies-text", title="Not implemented yet"):
|
||||||
text $num & "more " & reply
|
text $num & "more " & reply
|
||||||
|
|
||||||
proc renderConversation*(conversation: Conversation): VNode =
|
proc renderConversation*(conversation: Conversation; prefs: Prefs): VNode =
|
||||||
let hasAfter = conversation.after != nil
|
let hasAfter = conversation.after != nil
|
||||||
buildHtml(tdiv(class="conversation", id="posts")):
|
buildHtml(tdiv(class="conversation", id="posts")):
|
||||||
tdiv(class="main-thread"):
|
tdiv(class="main-thread"):
|
||||||
if conversation.before != nil:
|
if conversation.before != nil:
|
||||||
tdiv(class="before-tweet thread-line"):
|
tdiv(class="before-tweet thread-line"):
|
||||||
for i, tweet in conversation.before.tweets:
|
for i, tweet in conversation.before.tweets:
|
||||||
renderTweet(tweet, index=i)
|
renderTweet(tweet, prefs, index=i)
|
||||||
|
|
||||||
tdiv(class="main-tweet"):
|
tdiv(class="main-tweet"):
|
||||||
let afterClass = if hasAfter: "thread thread-line" else: ""
|
let afterClass = if hasAfter: "thread thread-line" else: ""
|
||||||
renderTweet(conversation.tweet, class=afterClass)
|
renderTweet(conversation.tweet, prefs, class=afterClass)
|
||||||
|
|
||||||
if hasAfter:
|
if hasAfter:
|
||||||
tdiv(class="after-tweet thread-line"):
|
tdiv(class="after-tweet thread-line"):
|
||||||
let total = conversation.after.tweets.high
|
let total = conversation.after.tweets.high
|
||||||
for i, tweet in conversation.after.tweets:
|
for i, tweet in conversation.after.tweets:
|
||||||
renderTweet(tweet, index=i, total=total)
|
renderTweet(tweet, prefs, index=i, total=total)
|
||||||
|
|
||||||
if conversation.replies.len > 0:
|
if conversation.replies.len > 0:
|
||||||
tdiv(class="replies"):
|
tdiv(class="replies"):
|
||||||
for thread in conversation.replies:
|
for thread in conversation.replies:
|
||||||
renderReplyThread(thread)
|
renderReplyThread(thread, prefs)
|
||||||
|
|
|
@ -54,28 +54,28 @@ proc renderProtected(username: string): VNode =
|
||||||
h2: text "This account's tweets are protected."
|
h2: text "This account's tweets are protected."
|
||||||
p: text &"Only confirmed followers have access to @{username}'s tweets."
|
p: text &"Only confirmed followers have access to @{username}'s tweets."
|
||||||
|
|
||||||
proc renderThread(thread: seq[Tweet]): VNode =
|
proc renderThread(thread: seq[Tweet]; prefs: Prefs): VNode =
|
||||||
buildHtml(tdiv(class="timeline-tweet thread-line")):
|
buildHtml(tdiv(class="timeline-tweet thread-line")):
|
||||||
for i, threadTweet in thread.sortedByIt(it.time):
|
for i, threadTweet in thread.sortedByIt(it.time):
|
||||||
renderTweet(threadTweet, "thread", index=i, total=thread.high)
|
renderTweet(threadTweet, prefs, class="thread", index=i, total=thread.high)
|
||||||
|
|
||||||
proc threadFilter(it: Tweet; tweetThread: string): bool =
|
proc threadFilter(it: Tweet; tweetThread: string): bool =
|
||||||
it.retweet.isNone and it.reply.len == 0 and it.threadId == tweetThread
|
it.retweet.isNone and it.reply.len == 0 and it.threadId == tweetThread
|
||||||
|
|
||||||
proc renderTweets(timeline: Timeline): VNode =
|
proc renderTweets(timeline: Timeline; prefs: Prefs): VNode =
|
||||||
buildHtml(tdiv(id="posts")):
|
buildHtml(tdiv(id="posts")):
|
||||||
var threads: seq[string]
|
var threads: seq[string]
|
||||||
for tweet in timeline.tweets:
|
for tweet in timeline.tweets:
|
||||||
if tweet.threadId in threads: continue
|
if tweet.threadId in threads: continue
|
||||||
let thread = timeline.tweets.filterIt(threadFilter(it, tweet.threadId))
|
let thread = timeline.tweets.filterIt(threadFilter(it, tweet.threadId))
|
||||||
if thread.len < 2:
|
if thread.len < 2:
|
||||||
renderTweet(tweet, "timeline-tweet")
|
renderTweet(tweet, prefs, class="timeline-tweet")
|
||||||
else:
|
else:
|
||||||
renderThread(thread)
|
renderThread(thread, prefs)
|
||||||
threads &= tweet.threadId
|
threads &= tweet.threadId
|
||||||
|
|
||||||
proc renderTimeline*(timeline: Timeline; username: string;
|
proc renderTimeline*(timeline: Timeline; username: string; protected: bool;
|
||||||
protected: bool; multi=false): VNode =
|
prefs: Prefs; multi=false): VNode =
|
||||||
buildHtml(tdiv):
|
buildHtml(tdiv):
|
||||||
if multi:
|
if multi:
|
||||||
tdiv(class="multi-header"):
|
tdiv(class="multi-header"):
|
||||||
|
@ -91,7 +91,7 @@ proc renderTimeline*(timeline: Timeline; username: string;
|
||||||
elif timeline.tweets.len == 0:
|
elif timeline.tweets.len == 0:
|
||||||
renderNoneFound()
|
renderNoneFound()
|
||||||
else:
|
else:
|
||||||
renderTweets(timeline)
|
renderTweets(timeline, prefs)
|
||||||
if timeline.hasMore or timeline.query.isSome:
|
if timeline.hasMore or timeline.query.isSome:
|
||||||
renderOlder(timeline, username)
|
renderOlder(timeline, username)
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -44,26 +44,38 @@ proc renderAlbum(tweet: Tweet): VNode =
|
||||||
target="_blank", style={display: flex}):
|
target="_blank", style={display: flex}):
|
||||||
genImg(photo)
|
genImg(photo)
|
||||||
|
|
||||||
proc renderVideo(video: Video): VNode =
|
proc renderVideo(video: Video; prefs: Prefs): VNode =
|
||||||
buildHtml(tdiv(class="attachments")):
|
buildHtml(tdiv(class="attachments")):
|
||||||
tdiv(class="gallery-video"):
|
tdiv(class="gallery-video"):
|
||||||
tdiv(class="attachment video-container"):
|
tdiv(class="attachment video-container"):
|
||||||
|
let thumb = video.thumb.getSigUrl("pic")
|
||||||
case video.playbackType
|
case video.playbackType
|
||||||
of mp4:
|
of mp4:
|
||||||
video(poster=video.thumb.getSigUrl("pic"), controls=""):
|
video(poster=thumb, controls=""):
|
||||||
source(src=video.url.getSigUrl("video"), `type`="video/mp4")
|
source(src=video.url.getSigUrl("video"), `type`="video/mp4")
|
||||||
of m3u8, vmap:
|
of m3u8, vmap:
|
||||||
video(poster=video.thumb.getSigUrl("pic"))
|
if prefs.videoPlayback:
|
||||||
tdiv(class="video-overlay"):
|
video(poster=thumb)
|
||||||
p: text "Video playback not supported"
|
tdiv(class="video-overlay"):
|
||||||
|
p: text "Video playback not supported yet"
|
||||||
|
else:
|
||||||
|
img(src=thumb)
|
||||||
|
tdiv(class="video-overlay"):
|
||||||
|
p: text "Video playback disabled"
|
||||||
|
|
||||||
proc renderGif(gif: Gif): VNode =
|
proc renderGif(gif: Gif; prefs: Prefs): VNode =
|
||||||
buildHtml(tdiv(class="attachments media-gif")):
|
buildHtml(tdiv(class="attachments media-gif")):
|
||||||
tdiv(class="gallery-gif", style=style(maxHeight, "unset")):
|
tdiv(class="gallery-gif", style=style(maxHeight, "unset")):
|
||||||
tdiv(class="attachment"):
|
tdiv(class="attachment"):
|
||||||
video(class="gif", poster=gif.thumb.getSigUrl("pic"),
|
let thumb = gif.thumb.getSigUrl("pic")
|
||||||
autoplay="", muted="", loop=""):
|
let url = gif.url.getSigUrl("video")
|
||||||
source(src=gif.url.getSigUrl("video"), `type`="video/mp4")
|
if prefs.autoplayGifs:
|
||||||
|
|
||||||
|
video(class="gif", poster=thumb, autoplay="", muted="", loop=""):
|
||||||
|
source(src=url, `type`="video/mp4")
|
||||||
|
else:
|
||||||
|
video(class="gif", poster=thumb, controls="", muted="", loop=""):
|
||||||
|
source(src=url, `type`="video/mp4")
|
||||||
|
|
||||||
proc renderPoll(poll: Poll): VNode =
|
proc renderPoll(poll: Poll): VNode =
|
||||||
buildHtml(tdiv(class="poll")):
|
buildHtml(tdiv(class="poll")):
|
||||||
|
@ -86,7 +98,7 @@ proc renderCardImage(card: Card): VNode =
|
||||||
tdiv(class="card-overlay-circle"):
|
tdiv(class="card-overlay-circle"):
|
||||||
span(class="card-overlay-triangle")
|
span(class="card-overlay-triangle")
|
||||||
|
|
||||||
proc renderCard(card: Card): VNode =
|
proc renderCard(card: Card; prefs: Prefs): VNode =
|
||||||
const largeCards = {summaryLarge, liveEvent, promoWebsite, promoVideo}
|
const largeCards = {summaryLarge, liveEvent, promoWebsite, promoVideo}
|
||||||
let large = if card.kind in largeCards: " large" else: ""
|
let large = if card.kind in largeCards: " large" else: ""
|
||||||
|
|
||||||
|
@ -95,7 +107,7 @@ proc renderCard(card: Card): VNode =
|
||||||
if card.image.isSome:
|
if card.image.isSome:
|
||||||
renderCardImage(card)
|
renderCardImage(card)
|
||||||
elif card.video.isSome:
|
elif card.video.isSome:
|
||||||
renderVideo(get(card.video))
|
renderVideo(get(card.video), prefs)
|
||||||
|
|
||||||
tdiv(class="card-content-container"):
|
tdiv(class="card-content-container"):
|
||||||
tdiv(class="card-content"):
|
tdiv(class="card-content"):
|
||||||
|
@ -161,7 +173,8 @@ proc renderQuote(quote: Quote): VNode =
|
||||||
a(href=getLink(quote)):
|
a(href=getLink(quote)):
|
||||||
text "Show this thread"
|
text "Show this thread"
|
||||||
|
|
||||||
proc renderTweet*(tweet: Tweet; class=""; index=0; total=(-1); last=false): VNode =
|
proc renderTweet*(tweet: Tweet; prefs: Prefs; class="";
|
||||||
|
index=0; total=(-1); last=false): VNode =
|
||||||
var divClass = class
|
var divClass = class
|
||||||
if index == total or last:
|
if index == total or last:
|
||||||
divClass = "thread-last " & class
|
divClass = "thread-last " & class
|
||||||
|
@ -187,13 +200,13 @@ proc renderTweet*(tweet: Tweet; class=""; index=0; total=(-1); last=false): VNod
|
||||||
renderQuote(tweet.quote.get())
|
renderQuote(tweet.quote.get())
|
||||||
|
|
||||||
if tweet.card.isSome:
|
if tweet.card.isSome:
|
||||||
renderCard(tweet.card.get())
|
renderCard(tweet.card.get(), prefs)
|
||||||
elif tweet.photos.len > 0:
|
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(), prefs)
|
||||||
elif tweet.gif.isSome:
|
elif tweet.gif.isSome:
|
||||||
renderGif(tweet.gif.get())
|
renderGif(tweet.gif.get(), prefs)
|
||||||
elif tweet.poll.isSome:
|
elif tweet.poll.isSome:
|
||||||
renderPoll(tweet.poll.get())
|
renderPoll(tweet.poll.get())
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue