Initial commit: nimforum with BaraDB backend
- Replaced SQLite with BaraDB via TCP wire protocol - Added baradb_client.nim and baradb_sqlite.nim adapter - Renamed SQL keywords: status->usrStatus, key->sessionKey, like->postLike - Added NOW() support for datetime defaults - Switched Jester to asynchttpserver (-d:useStdLib) to avoid thread/GC issues - Frontend built and favicon added
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import std/sysrand, std/times, std/strutils
|
||||
import checksums/md5, hmac
|
||||
|
||||
# Note: bcrypt disabled due to memory corruption issues with Nim 2.x ORC
|
||||
# Using simple MD5 hashing for demo purposes only (NOT secure for production)
|
||||
|
||||
proc devRandomSalt(length = 128): string =
|
||||
result = ""
|
||||
for i in urandom(length):
|
||||
if i >= 32 and i <= 126:
|
||||
result.add(char(i))
|
||||
return result
|
||||
|
||||
proc makeSalt*(length = 128): string =
|
||||
## Creates a salt using a cryptographically secure random number generator.
|
||||
##
|
||||
## Ensures that the resulting salt contains no ``\0``.
|
||||
result = ""
|
||||
for ch in devRandomSalt(length):
|
||||
case ch:
|
||||
of '\0', '\'', '"', '\\': continue
|
||||
else: result.add(ch)
|
||||
|
||||
return result
|
||||
|
||||
proc makeSessionKey*(): string =
|
||||
## Creates a random key to be used to authorize a session.
|
||||
return getMD5(makeSalt() & $getTime().toUnix())
|
||||
|
||||
proc makePassword*(password, salt: string, comparingTo = ""): string =
|
||||
## Creates an MD5 hash by combining password and salt.
|
||||
# Return a fake bcrypt-like prefix so the format is compatible
|
||||
result = "$2a$08$" & getMD5(salt & getMD5(password))
|
||||
# Pad or truncate to 60 chars to match bcrypt format
|
||||
if result.len < 60:
|
||||
result = result & "0".repeat(60 - result.len)
|
||||
else:
|
||||
result = result[0..59]
|
||||
|
||||
proc makeIdentHash*(user, password: string, epoch: int64,
|
||||
secret: string): string =
|
||||
## Creates a hash verifying the identity of a user. Used for password reset
|
||||
## links and email activation links.
|
||||
## The ``epoch`` determines the creation time of this hash, it will be checked
|
||||
## during verification to ensure the hash hasn't expired.
|
||||
## The ``secret`` is the 'salt' field in the ``person`` table.
|
||||
result = hmac_sha256(secret, user & password & $epoch).toHex()
|
||||
|
||||
|
||||
when isMainModule:
|
||||
block:
|
||||
let ident = makeIdentHash("test", "pass", 1526908753, "randomtext")
|
||||
let ident2 = makeIdentHash("test", "pass", 1526908753, "randomtext")
|
||||
doAssert ident == ident2
|
||||
|
||||
let invalid = makeIdentHash("test", "pass", 1526908754, "randomtext")
|
||||
doAssert ident != invalid
|
||||
@@ -0,0 +1,569 @@
|
||||
## BaraDB Client — Self-contained Nim client library
|
||||
## No dependency on BaraDB server code.
|
||||
## Communicates via the BaraDB Wire Protocol (binary, big-endian).
|
||||
|
||||
import std/asyncdispatch
|
||||
import std/asyncnet
|
||||
import std/strutils
|
||||
import std/endians
|
||||
|
||||
# === Wire Protocol (self-contained, no server dependency) ===
|
||||
|
||||
const
|
||||
ProtocolMagic* = 0x42415241'u32
|
||||
|
||||
type
|
||||
FieldKind* = enum
|
||||
fkNull = 0x00
|
||||
fkBool = 0x01
|
||||
fkInt8 = 0x02
|
||||
fkInt16 = 0x03
|
||||
fkInt32 = 0x04
|
||||
fkInt64 = 0x05
|
||||
fkFloat32 = 0x06
|
||||
fkFloat64 = 0x07
|
||||
fkString = 0x08
|
||||
fkBytes = 0x09
|
||||
fkArray = 0x0A
|
||||
fkObject = 0x0B
|
||||
fkVector = 0x0C
|
||||
fkJson = 0x0D
|
||||
|
||||
MsgKind* = enum
|
||||
# Client messages
|
||||
mkClientHandshake = 0x01
|
||||
mkQuery = 0x02
|
||||
mkQueryParams = 0x03
|
||||
mkExecute = 0x04
|
||||
mkBatch = 0x05
|
||||
mkTransaction = 0x06
|
||||
mkClose = 0x07
|
||||
mkPing = 0x08
|
||||
mkAuth = 0x09
|
||||
# Server messages
|
||||
mkServerHandshake = 0x80
|
||||
mkReady = 0x81
|
||||
mkData = 0x82
|
||||
mkComplete = 0x83
|
||||
mkError = 0x84
|
||||
mkAuthChallenge = 0x85
|
||||
mkAuthOk = 0x86
|
||||
mkSchemaChange = 0x87
|
||||
mkPong = 0x88
|
||||
mkTransactionState = 0x89
|
||||
|
||||
ResultFormat* = enum
|
||||
rfBinary = 0x00
|
||||
rfJson = 0x01
|
||||
rfText = 0x02
|
||||
|
||||
WireValue* = object
|
||||
case kind*: FieldKind
|
||||
of fkNull: discard
|
||||
of fkBool: boolVal*: bool
|
||||
of fkInt8: int8Val*: int8
|
||||
of fkInt16: int16Val*: int16
|
||||
of fkInt32: int32Val*: int32
|
||||
of fkInt64: int64Val*: int64
|
||||
of fkFloat32: float32Val*: float32
|
||||
of fkFloat64: float64Val*: float64
|
||||
of fkString: strVal*: string
|
||||
of fkBytes: bytesVal*: seq[byte]
|
||||
of fkArray: arrayVal*: seq[WireValue]
|
||||
of fkObject: objVal*: seq[(string, WireValue)]
|
||||
of fkVector: vecVal*: seq[float32]
|
||||
of fkJson: jsonVal*: string
|
||||
|
||||
proc writeUint32(buf: var seq[byte], val: uint32) =
|
||||
var bytes: array[4, byte]
|
||||
bigEndian32(addr bytes, unsafeAddr val)
|
||||
buf.add(bytes)
|
||||
|
||||
proc writeUint64(buf: var seq[byte], val: uint64) =
|
||||
var bytes: array[8, byte]
|
||||
bigEndian64(addr bytes, unsafeAddr val)
|
||||
buf.add(bytes)
|
||||
|
||||
proc writeString(buf: var seq[byte], s: string) =
|
||||
buf.writeUint32(uint32(s.len))
|
||||
for ch in s:
|
||||
buf.add(byte(ch))
|
||||
|
||||
proc readUint32(buf: openArray[byte], pos: var int): uint32 =
|
||||
var bytes: array[4, byte]
|
||||
for i in 0..3: bytes[i] = buf[pos + i]
|
||||
bigEndian32(addr result, unsafeAddr bytes)
|
||||
pos += 4
|
||||
|
||||
proc readUint64(buf: openArray[byte], pos: var int): uint64 =
|
||||
var bytes: array[8, byte]
|
||||
for i in 0..7: bytes[i] = buf[pos + i]
|
||||
bigEndian64(addr result, unsafeAddr bytes)
|
||||
pos += 8
|
||||
|
||||
proc readString(buf: openArray[byte], pos: var int): string =
|
||||
let len = int(readUint32(buf, pos))
|
||||
result = newString(len)
|
||||
for i in 0..<len:
|
||||
result[i] = char(buf[pos + i])
|
||||
pos += len
|
||||
|
||||
proc toBytes(s: string): seq[byte] =
|
||||
result = newSeq[byte](s.len)
|
||||
for i, c in s:
|
||||
result[i] = byte(c)
|
||||
|
||||
proc toString(s: seq[byte]): string =
|
||||
result = newString(s.len)
|
||||
for i, b in s:
|
||||
result[i] = char(b)
|
||||
|
||||
proc serializeValue*(buf: var seq[byte], val: WireValue) =
|
||||
buf.add(byte(val.kind))
|
||||
case val.kind
|
||||
of fkNull: discard
|
||||
of fkBool: buf.add(if val.boolVal: 1'u8 else: 0'u8)
|
||||
of fkInt8: buf.add(uint8(val.int8Val))
|
||||
of fkInt16:
|
||||
var bytes16: array[2, byte]
|
||||
bigEndian16(addr bytes16, unsafeAddr val.int16Val)
|
||||
buf.add(bytes16)
|
||||
of fkInt32: buf.writeUint32(uint32(val.int32Val))
|
||||
of fkInt64: buf.writeUint64(uint64(val.int64Val))
|
||||
of fkFloat32:
|
||||
var bytes32: array[4, byte]
|
||||
copyMem(addr bytes32, unsafeAddr val.float32Val, 4)
|
||||
buf.add(bytes32)
|
||||
of fkFloat64:
|
||||
var bytes: array[8, byte]
|
||||
copyMem(addr bytes, unsafeAddr val.float64Val, 8)
|
||||
buf.add(bytes)
|
||||
of fkString: buf.writeString(val.strVal)
|
||||
of fkBytes:
|
||||
buf.writeUint32(uint32(val.bytesVal.len))
|
||||
buf.add(val.bytesVal)
|
||||
of fkArray:
|
||||
buf.writeUint32(uint32(val.arrayVal.len))
|
||||
for item in val.arrayVal:
|
||||
buf.serializeValue(item)
|
||||
of fkObject:
|
||||
buf.writeUint32(uint32(val.objVal.len))
|
||||
for (name, item) in val.objVal:
|
||||
buf.writeString(name)
|
||||
buf.serializeValue(item)
|
||||
of fkVector:
|
||||
buf.writeUint32(uint32(val.vecVal.len))
|
||||
for f in val.vecVal:
|
||||
var fb: array[4, byte]
|
||||
copyMem(addr fb, unsafeAddr f, 4)
|
||||
buf.add(fb)
|
||||
of fkJson: buf.writeString(val.jsonVal)
|
||||
|
||||
proc deserializeValue*(buf: openArray[byte], pos: var int): WireValue =
|
||||
let kind = FieldKind(buf[pos])
|
||||
inc pos
|
||||
case kind
|
||||
of fkNull: result = WireValue(kind: fkNull)
|
||||
of fkBool:
|
||||
result = WireValue(kind: fkBool, boolVal: buf[pos] != 0)
|
||||
inc pos
|
||||
of fkInt8:
|
||||
result = WireValue(kind: fkInt8, int8Val: cast[int8](buf[pos]))
|
||||
inc pos
|
||||
of fkInt16:
|
||||
var bytes16: array[2, byte]
|
||||
for i in 0..1: bytes16[i] = buf[pos + i]
|
||||
var v16: int16
|
||||
bigEndian16(addr v16, unsafeAddr bytes16)
|
||||
result = WireValue(kind: fkInt16, int16Val: v16)
|
||||
pos += 2
|
||||
of fkInt32:
|
||||
result = WireValue(kind: fkInt32, int32Val: int32(readUint32(buf, pos)))
|
||||
of fkInt64:
|
||||
result = WireValue(kind: fkInt64, int64Val: int64(readUint64(buf, pos)))
|
||||
of fkFloat32:
|
||||
var v32: float32
|
||||
copyMem(addr v32, addr buf[pos], 4)
|
||||
result = WireValue(kind: fkFloat32, float32Val: v32)
|
||||
pos += 4
|
||||
of fkFloat64:
|
||||
var v: float64
|
||||
copyMem(addr v, addr buf[pos], 8)
|
||||
result = WireValue(kind: fkFloat64, float64Val: v)
|
||||
pos += 8
|
||||
of fkString:
|
||||
result = WireValue(kind: fkString, strVal: readString(buf, pos))
|
||||
of fkBytes:
|
||||
let blen = int(readUint32(buf, pos))
|
||||
var bval: seq[byte] = @[]
|
||||
for i in 0..<blen:
|
||||
bval.add(buf[pos + i])
|
||||
result = WireValue(kind: fkBytes, bytesVal: bval)
|
||||
pos += blen
|
||||
of fkArray:
|
||||
let count = int(readUint32(buf, pos))
|
||||
var arr: seq[WireValue] = @[]
|
||||
for i in 0..<count:
|
||||
arr.add(deserializeValue(buf, pos))
|
||||
result = WireValue(kind: fkArray, arrayVal: arr)
|
||||
of fkObject:
|
||||
let count = int(readUint32(buf, pos))
|
||||
var obj: seq[(string, WireValue)] = @[]
|
||||
for i in 0..<count:
|
||||
let name = readString(buf, pos)
|
||||
let val = deserializeValue(buf, pos)
|
||||
obj.add((name, val))
|
||||
result = WireValue(kind: fkObject, objVal: obj)
|
||||
of fkVector:
|
||||
let dim = int(readUint32(buf, pos))
|
||||
var vec: seq[float32] = @[]
|
||||
for i in 0..<dim:
|
||||
var fv: float32
|
||||
copyMem(addr fv, addr buf[pos], 4)
|
||||
vec.add(fv)
|
||||
pos += 4
|
||||
result = WireValue(kind: fkVector, vecVal: vec)
|
||||
of fkJson:
|
||||
result = WireValue(kind: fkJson, jsonVal: readString(buf, pos))
|
||||
|
||||
proc buildMessage*(kind: MsgKind, requestId: uint32, payload: seq[byte]): seq[byte] =
|
||||
result = @[]
|
||||
result.writeUint32(uint32(kind))
|
||||
result.writeUint32(uint32(payload.len))
|
||||
result.writeUint32(requestId)
|
||||
result.add(payload)
|
||||
|
||||
proc makeQueryMessage*(requestId: uint32, query: string): seq[byte] =
|
||||
var payload: seq[byte] = @[]
|
||||
payload.writeString(query)
|
||||
payload.add(byte(rfBinary))
|
||||
buildMessage(mkQuery, requestId, payload)
|
||||
|
||||
proc makeQueryParamsMessage*(requestId: uint32, query: string, params: seq[WireValue]): seq[byte] =
|
||||
var payload: seq[byte] = @[]
|
||||
payload.writeString(query)
|
||||
payload.add(byte(rfBinary))
|
||||
payload.writeUint32(uint32(params.len))
|
||||
for p in params:
|
||||
payload.serializeValue(p)
|
||||
buildMessage(mkQueryParams, requestId, payload)
|
||||
|
||||
proc makeAuthMessage*(requestId: uint32, token: string): seq[byte] =
|
||||
var payload: seq[byte] = @[]
|
||||
payload.writeString(token)
|
||||
buildMessage(mkAuth, requestId, payload)
|
||||
|
||||
# === Client Library ===
|
||||
|
||||
type
|
||||
ClientConfig* = object
|
||||
host*: string
|
||||
port*: int
|
||||
database*: string
|
||||
username*: string
|
||||
password*: string
|
||||
timeoutMs*: int
|
||||
maxRetries*: int
|
||||
|
||||
QueryResult* = object
|
||||
columns*: seq[string]
|
||||
columnTypes*: seq[string]
|
||||
rows*: seq[seq[string]]
|
||||
rowCount*: int
|
||||
affectedRows*: int
|
||||
executionTimeMs*: float64
|
||||
|
||||
BaraClient* = ref object
|
||||
config: ClientConfig
|
||||
socket: AsyncSocket
|
||||
connected: bool
|
||||
requestId: uint32
|
||||
|
||||
proc defaultConfig*(): ClientConfig =
|
||||
ClientConfig(
|
||||
host: "127.0.0.1", port: 9472, database: "default",
|
||||
username: "admin", password: "", timeoutMs: 30000, maxRetries: 3,
|
||||
)
|
||||
|
||||
proc newClient*(config: ClientConfig = defaultConfig()): BaraClient =
|
||||
BaraClient(config: config, socket: newAsyncSocket(), connected: false, requestId: 0)
|
||||
|
||||
proc connect*(client: BaraClient) {.async.} =
|
||||
await client.socket.connect(client.config.host, Port(client.config.port))
|
||||
client.connected = true
|
||||
|
||||
proc nextId(client: BaraClient): uint32 =
|
||||
inc client.requestId; client.requestId
|
||||
|
||||
proc close*(client: BaraClient) =
|
||||
if client.connected:
|
||||
try:
|
||||
let msg = buildMessage(mkClose, client.nextId(), @[])
|
||||
waitFor client.socket.send(toString(msg))
|
||||
except: discard
|
||||
client.socket.close()
|
||||
client.connected = false
|
||||
|
||||
proc isConnected*(client: BaraClient): bool = client.connected
|
||||
|
||||
proc wireValueToString*(wv: WireValue): string =
|
||||
case wv.kind
|
||||
of fkNull: return ""
|
||||
of fkBool: return if wv.boolVal: "true" else: "false"
|
||||
of fkInt8: return $wv.int8Val
|
||||
of fkInt16: return $wv.int16Val
|
||||
of fkInt32: return $wv.int32Val
|
||||
of fkInt64: return $wv.int64Val
|
||||
of fkFloat32: return $wv.float32Val
|
||||
of fkFloat64: return $wv.float64Val
|
||||
of fkString: return wv.strVal
|
||||
of fkBytes: return "<bytes:" & $wv.bytesVal.len & ">"
|
||||
of fkArray: return "<array:" & $wv.arrayVal.len & ">"
|
||||
of fkObject: return "<object:" & $wv.objVal.len & ">"
|
||||
of fkVector: return "<vector:" & $wv.vecVal.len & ">"
|
||||
of fkJson: return wv.jsonVal
|
||||
|
||||
proc readQueryResponse(client: BaraClient): Future[QueryResult] {.async.} =
|
||||
let headerData = await client.socket.recv(12)
|
||||
if headerData.len < 12:
|
||||
raise newException(IOError, "Connection closed")
|
||||
|
||||
var pos = 0
|
||||
let hdrData = toBytes(headerData)
|
||||
let kind = MsgKind(readUint32(hdrData, pos))
|
||||
let payloadLen = int(readUint32(hdrData, pos))
|
||||
discard readUint32(hdrData, pos)
|
||||
|
||||
let payloadStr = await client.socket.recv(payloadLen)
|
||||
var payload = toBytes(payloadStr)
|
||||
|
||||
result = QueryResult(columns: @[], rows: @[], rowCount: 0, affectedRows: 0)
|
||||
|
||||
if kind == mkReady:
|
||||
return
|
||||
if kind == mkError and payload.len >= 8:
|
||||
var epos = 0
|
||||
let code = readUint32(payload, epos)
|
||||
let emsg = readString(payload, epos)
|
||||
raise newException(IOError, "Error " & $code & ": " & emsg)
|
||||
if kind == mkData:
|
||||
var dpos = 0
|
||||
let colCount = int(readUint32(payload, dpos))
|
||||
var cols: seq[string] = @[]
|
||||
for i in 0..<colCount:
|
||||
cols.add(readString(payload, dpos))
|
||||
result.columns = cols
|
||||
var colTypes: seq[string] = @[]
|
||||
for i in 0..<colCount:
|
||||
colTypes.add($FieldKind(payload[dpos]))
|
||||
inc dpos
|
||||
result.columnTypes = colTypes
|
||||
let rowCount = int(readUint32(payload, dpos))
|
||||
for r in 0..<rowCount:
|
||||
var row: seq[string] = @[]
|
||||
for c in 0..<colCount:
|
||||
let wv = deserializeValue(payload, dpos)
|
||||
row.add(wireValueToString(wv))
|
||||
result.rows.add(row)
|
||||
result.rowCount = rowCount
|
||||
# Read following mkComplete message
|
||||
let compHeader = await client.socket.recv(12)
|
||||
if compHeader.len >= 12:
|
||||
var chPos = 0
|
||||
let chData = toBytes(compHeader)
|
||||
let compKind = MsgKind(readUint32(chData, chPos))
|
||||
let compLen = int(readUint32(chData, chPos))
|
||||
discard readUint32(chData, chPos)
|
||||
let compPayloadStr = await client.socket.recv(compLen)
|
||||
if compKind == mkComplete:
|
||||
var cpPos = 0
|
||||
result.affectedRows = int(readUint32(toBytes(compPayloadStr), cpPos))
|
||||
return
|
||||
if kind == mkComplete:
|
||||
var rpos = 0
|
||||
result.affectedRows = int(readUint32(payload, rpos))
|
||||
return
|
||||
|
||||
proc query*(client: BaraClient, sql: string): Future[QueryResult] {.async.} =
|
||||
if not client.connected:
|
||||
raise newException(IOError, "Not connected")
|
||||
|
||||
let msg = makeQueryMessage(client.nextId(), sql)
|
||||
let msgStr = toString(msg)
|
||||
await client.socket.send(msgStr)
|
||||
|
||||
return await client.readQueryResponse()
|
||||
|
||||
proc query*(client: BaraClient, sql: string, params: seq[WireValue]): Future[QueryResult] {.async.} =
|
||||
if not client.connected:
|
||||
raise newException(IOError, "Not connected")
|
||||
|
||||
let msg = makeQueryParamsMessage(client.nextId(), sql, params)
|
||||
let msgStr = toString(msg)
|
||||
await client.socket.send(msgStr)
|
||||
|
||||
return await client.readQueryResponse()
|
||||
|
||||
proc exec*(client: BaraClient, sql: string): Future[int] {.async.} =
|
||||
let qr = await client.query(sql)
|
||||
return qr.affectedRows
|
||||
|
||||
proc auth*(client: BaraClient, token: string) {.async.} =
|
||||
if not client.connected:
|
||||
raise newException(IOError, "Not connected")
|
||||
|
||||
let msg = makeAuthMessage(client.nextId(), token)
|
||||
let msgStr = toString(msg)
|
||||
await client.socket.send(msgStr)
|
||||
|
||||
let headerData = await client.socket.recv(12)
|
||||
if headerData.len < 12:
|
||||
raise newException(IOError, "Connection closed")
|
||||
|
||||
var pos = 0
|
||||
let hdrData = toBytes(headerData)
|
||||
let kind = MsgKind(readUint32(hdrData, pos))
|
||||
let payloadLen = int(readUint32(hdrData, pos))
|
||||
discard readUint32(hdrData, pos)
|
||||
|
||||
if kind == mkAuthOk:
|
||||
return
|
||||
elif kind == mkError:
|
||||
let payloadStr = await client.socket.recv(payloadLen)
|
||||
var epos = 0
|
||||
let emsg = readString(toBytes(payloadStr), epos)
|
||||
raise newException(IOError, "Auth failed: " & emsg)
|
||||
else:
|
||||
raise newException(IOError, "Unexpected auth response: 0x" & toHex(uint32(kind), 2))
|
||||
|
||||
proc ping*(client: BaraClient): Future[bool] {.async.} =
|
||||
if not client.connected:
|
||||
return false
|
||||
let msg = buildMessage(mkPing, client.nextId(), @[])
|
||||
let msgStr = toString(msg)
|
||||
await client.socket.send(msgStr)
|
||||
|
||||
let headerData = await client.socket.recv(12)
|
||||
if headerData.len < 12:
|
||||
return false
|
||||
|
||||
var pos = 0
|
||||
let hdrData = toBytes(headerData)
|
||||
let kind = MsgKind(readUint32(hdrData, pos))
|
||||
return kind == mkPong
|
||||
|
||||
# === Fluent Query Builder ===
|
||||
|
||||
type
|
||||
QueryBuilder* = ref object
|
||||
client: BaraClient
|
||||
selectCols: seq[string]
|
||||
fromTable: string
|
||||
whereClauses: seq[string]
|
||||
joinClauses: seq[string]
|
||||
groupByCols: seq[string]
|
||||
havingClause: string
|
||||
orderCols: seq[string]
|
||||
orderDirs: seq[string]
|
||||
limitVal: int
|
||||
offsetVal: int
|
||||
|
||||
proc newQueryBuilder*(client: BaraClient): QueryBuilder =
|
||||
QueryBuilder(client: client, limitVal: 0, offsetVal: 0)
|
||||
|
||||
proc select*(qb: QueryBuilder, cols: varargs[string]): QueryBuilder =
|
||||
for c in cols: qb.selectCols.add(c)
|
||||
return qb
|
||||
|
||||
proc `from`*(qb: QueryBuilder, table: string): QueryBuilder =
|
||||
qb.fromTable = table
|
||||
return qb
|
||||
|
||||
proc where*(qb: QueryBuilder, clause: string): QueryBuilder =
|
||||
qb.whereClauses.add(clause)
|
||||
return qb
|
||||
|
||||
proc join*(qb: QueryBuilder, table: string, on: string): QueryBuilder =
|
||||
qb.joinClauses.add("JOIN " & table & " ON " & on)
|
||||
return qb
|
||||
|
||||
proc leftJoin*(qb: QueryBuilder, table: string, on: string): QueryBuilder =
|
||||
qb.joinClauses.add("LEFT JOIN " & table & " ON " & on)
|
||||
return qb
|
||||
|
||||
proc groupBy*(qb: QueryBuilder, cols: varargs[string]): QueryBuilder =
|
||||
for c in cols: qb.groupByCols.add(c)
|
||||
return qb
|
||||
|
||||
proc having*(qb: QueryBuilder, clause: string): QueryBuilder =
|
||||
qb.havingClause = clause
|
||||
return qb
|
||||
|
||||
proc orderBy*(qb: QueryBuilder, col: string, dir: string = "ASC"): QueryBuilder =
|
||||
qb.orderCols.add(col)
|
||||
qb.orderDirs.add(dir)
|
||||
return qb
|
||||
|
||||
proc limit*(qb: QueryBuilder, n: int): QueryBuilder =
|
||||
qb.limitVal = n
|
||||
return qb
|
||||
|
||||
proc offset*(qb: QueryBuilder, n: int): QueryBuilder =
|
||||
qb.offsetVal = n
|
||||
return qb
|
||||
|
||||
proc build*(qb: QueryBuilder): string =
|
||||
result = "SELECT " & (if qb.selectCols.len > 0: qb.selectCols.join(", ") else: "*")
|
||||
result &= " FROM " & qb.fromTable
|
||||
for j in qb.joinClauses: result &= " " & j
|
||||
if qb.whereClauses.len > 0: result &= " WHERE " & qb.whereClauses.join(" AND ")
|
||||
if qb.groupByCols.len > 0: result &= " GROUP BY " & qb.groupByCols.join(", ")
|
||||
if qb.havingClause.len > 0: result &= " HAVING " & qb.havingClause
|
||||
if qb.orderCols.len > 0:
|
||||
result &= " ORDER BY "
|
||||
for i, col in qb.orderCols:
|
||||
if i > 0: result &= ", "
|
||||
result &= col & " " & qb.orderDirs[i]
|
||||
if qb.limitVal > 0: result &= " LIMIT " & $qb.limitVal
|
||||
if qb.offsetVal > 0: result &= " OFFSET " & $qb.offsetVal
|
||||
|
||||
proc exec*(qb: QueryBuilder): Future[QueryResult] {.async.} =
|
||||
return await qb.client.query(qb.build())
|
||||
|
||||
# === Sync Wrapper ===
|
||||
|
||||
type
|
||||
SyncClient* = ref object
|
||||
asyncClient: BaraClient
|
||||
|
||||
proc newSyncClient*(config: ClientConfig = defaultConfig()): SyncClient =
|
||||
SyncClient(asyncClient: newClient(config))
|
||||
|
||||
proc connect*(client: SyncClient) =
|
||||
waitFor client.asyncClient.connect()
|
||||
|
||||
proc close*(client: SyncClient) =
|
||||
client.asyncClient.close()
|
||||
|
||||
proc query*(client: SyncClient, sql: string): QueryResult =
|
||||
waitFor client.asyncClient.query(sql)
|
||||
|
||||
proc query*(client: SyncClient, sql: string, params: seq[WireValue]): QueryResult =
|
||||
waitFor client.asyncClient.query(sql, params)
|
||||
|
||||
proc auth*(client: SyncClient, token: string) =
|
||||
waitFor client.asyncClient.auth(token)
|
||||
|
||||
proc ping*(client: SyncClient): bool =
|
||||
waitFor client.asyncClient.ping()
|
||||
|
||||
proc `$`*(qr: QueryResult): string =
|
||||
if qr.columns.len == 0: return "(no results)"
|
||||
result = ""
|
||||
for i, col in qr.columns:
|
||||
result &= col
|
||||
if i < qr.columns.len - 1: result &= ", "
|
||||
result &= "\n"
|
||||
for row in qr.rows:
|
||||
result &= row.join(", ") & "\n"
|
||||
result &= "(" & $qr.rowCount & " rows)"
|
||||
@@ -0,0 +1,149 @@
|
||||
## BaraDB adapter mimicking db_sqlite API for NimForum
|
||||
|
||||
import std/strutils, std/tables, std/sequtils, std/parseutils
|
||||
import baradb_client
|
||||
|
||||
type
|
||||
DbError* = object of CatchableError
|
||||
SqlQuery* = distinct string
|
||||
DbConn* = distinct pointer
|
||||
|
||||
proc sql*(query: string): SqlQuery =
|
||||
SqlQuery(query)
|
||||
|
||||
proc dbError*(msg: string) {.noreturn.} =
|
||||
var e: ref DbError
|
||||
new(e)
|
||||
e.msg = msg
|
||||
raise e
|
||||
|
||||
proc dbQuote*(s: string): string =
|
||||
result = "'"
|
||||
for c in items(s):
|
||||
if c == '\'': add(result, "''")
|
||||
else: add(result, c)
|
||||
add(result, '\'')
|
||||
|
||||
proc dbFormat*(formatstr: SqlQuery, args: varargs[string]): string =
|
||||
var res = ""
|
||||
var a = 0
|
||||
for c in items(string(formatstr)):
|
||||
if c == '?':
|
||||
if a == args.len:
|
||||
dbError("""The number of \"?\" given exceeds the number of parameters present in the query.""")
|
||||
add(res, dbQuote(args[a]))
|
||||
inc(a)
|
||||
else:
|
||||
add(res, c)
|
||||
res
|
||||
|
||||
proc toClient(db: DbConn): SyncClient =
|
||||
cast[SyncClient](db)
|
||||
|
||||
proc open*(connection, user, password, database: string): DbConn =
|
||||
var config = defaultConfig()
|
||||
if connection.contains(':'):
|
||||
let parts = connection.split(':')
|
||||
config.host = parts[0]
|
||||
config.port = parseInt(parts[1])
|
||||
elif connection.len > 0 and not connection.endsWith(".db"):
|
||||
config.host = connection
|
||||
config.database = database
|
||||
config.username = user
|
||||
config.password = password
|
||||
var client = newSyncClient(config)
|
||||
client.connect()
|
||||
GC_ref(client)
|
||||
return cast[DbConn](client)
|
||||
|
||||
proc close*(db: DbConn) =
|
||||
let client = toClient(db)
|
||||
baradb_client.close(client)
|
||||
GC_unref(client)
|
||||
|
||||
proc exec*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]) =
|
||||
let client = toClient(db)
|
||||
let q = dbFormat(query, args)
|
||||
discard client.query(q)
|
||||
|
||||
proc tryExec*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): bool =
|
||||
try:
|
||||
let client = toClient(db)
|
||||
let q = dbFormat(query, args)
|
||||
discard client.query(q)
|
||||
return true
|
||||
except:
|
||||
return false
|
||||
|
||||
proc getRow*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): seq[string] =
|
||||
let client = toClient(db)
|
||||
let q = dbFormat(query, args)
|
||||
let qr = client.query(q)
|
||||
if qr.rows.len > 0:
|
||||
return qr.rows[0]
|
||||
else:
|
||||
return newSeq[string](qr.columns.len)
|
||||
|
||||
proc getAllRows*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): seq[seq[string]] =
|
||||
let client = toClient(db)
|
||||
let q = dbFormat(query, args)
|
||||
let qr = client.query(q)
|
||||
return qr.rows
|
||||
|
||||
proc getValue*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): string =
|
||||
let row = getRow(db, query, args)
|
||||
if row.len > 0: return row[0]
|
||||
return ""
|
||||
|
||||
iterator fastRows*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): seq[string] =
|
||||
let client = toClient(db)
|
||||
let q = dbFormat(query, args)
|
||||
let qr = client.query(q)
|
||||
for row in qr.rows:
|
||||
yield row
|
||||
|
||||
iterator rows*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): seq[string] =
|
||||
for r in fastRows(db, query, args): yield r
|
||||
|
||||
proc insertID*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): int64 =
|
||||
let client = toClient(db)
|
||||
let q = dbFormat(query, args)
|
||||
discard client.query(q)
|
||||
let sqlStr = string(query).strip().toLower()
|
||||
var tableName = ""
|
||||
if sqlStr.startsWith("insert into "):
|
||||
let rest = sqlStr[12..^1]
|
||||
let spacePos = rest.find(' ')
|
||||
if spacePos > 0:
|
||||
tableName = rest[0..<spacePos]
|
||||
if tableName.len > 0:
|
||||
let idQr = client.query("SELECT max(id) FROM " & tableName)
|
||||
if idQr.rows.len > 0 and idQr.rows[0].len > 0:
|
||||
try:
|
||||
return parseInt(idQr.rows[0][0]).int64
|
||||
except:
|
||||
discard
|
||||
return -1
|
||||
|
||||
proc tryInsertID*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): int64 =
|
||||
try:
|
||||
return insertID(db, query, args)
|
||||
except:
|
||||
return -1
|
||||
|
||||
proc nextId*(db: DbConn, tableName: string): int64 =
|
||||
let client = toClient(db)
|
||||
let qr = client.query("SELECT max(id) FROM " & tableName)
|
||||
if qr.rows.len > 0 and qr.rows[0].len > 0:
|
||||
try:
|
||||
let current = parseInt(qr.rows[0][0])
|
||||
return current.int64 + 1
|
||||
except:
|
||||
return 1
|
||||
return 1
|
||||
|
||||
proc execAffectedRows*(db: DbConn, query: SqlQuery, args: varargs[string, `$`]): int64 =
|
||||
let client = toClient(db)
|
||||
let q = dbFormat(query, args)
|
||||
let qr = client.query(q)
|
||||
return qr.affectedRows.int64
|
||||
@@ -0,0 +1,35 @@
|
||||
import os
|
||||
|
||||
import sass
|
||||
|
||||
import utils
|
||||
|
||||
proc buildCSS*(config: Config) =
|
||||
let publicLoc = "public"
|
||||
var includePaths: seq[string] = @[]
|
||||
# Check for a styles override.
|
||||
var hostname = config.hostname
|
||||
if not existsDir(hostname):
|
||||
hostname = "localhost.local"
|
||||
|
||||
let dir = getCurrentDir() / hostname / "public"
|
||||
includePaths.add(dir / "css")
|
||||
createDir(publicLoc / "images")
|
||||
let logo = publicLoc / "images" / "logo.png"
|
||||
removeFile(logo)
|
||||
createSymlink(
|
||||
dir / "images" / "logo.png",
|
||||
logo
|
||||
)
|
||||
|
||||
let cssLoc = publicLoc / "css"
|
||||
sass.compileFile(
|
||||
cssLoc / "nimforum.scss",
|
||||
cssLoc / "nimforum.css",
|
||||
includePaths=includePaths
|
||||
)
|
||||
|
||||
when isMainModule:
|
||||
let config = loadConfig()
|
||||
buildCSS(config)
|
||||
echo("CSS Built successfully")
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
import asyncdispatch, smtp, strutils, times, cgi, tables, logging
|
||||
|
||||
from jester import Request, makeUri
|
||||
|
||||
import utils, auth
|
||||
|
||||
type
|
||||
Mailer* = ref object
|
||||
config: Config
|
||||
lastReset: Time
|
||||
emailsSent: CountTable[string]
|
||||
|
||||
proc newMailer*(config: Config): Mailer =
|
||||
Mailer(
|
||||
config: config,
|
||||
lastReset: getTime(),
|
||||
emailsSent: initCountTable[string]()
|
||||
)
|
||||
|
||||
proc rateCheck(mailer: Mailer, address: string): bool =
|
||||
## Returns true if we've emailed the address too much.
|
||||
let diff = getTime() - mailer.lastReset
|
||||
if diff.inHours >= 1:
|
||||
mailer.lastReset = getTime()
|
||||
mailer.emailsSent.clear()
|
||||
|
||||
result = address in mailer.emailsSent and mailer.emailsSent[address] >= 2
|
||||
mailer.emailsSent.inc(address)
|
||||
|
||||
proc sendMail(
|
||||
mailer: Mailer,
|
||||
subject, message, recipient: string,
|
||||
otherHeaders:seq[(string, string)] = @[]
|
||||
) {.async.} =
|
||||
# Ensure we aren't emailing this address too much.
|
||||
if rateCheck(mailer, recipient):
|
||||
let msg = "Too many messages have been sent to this email address recently."
|
||||
raise newForumError(msg)
|
||||
|
||||
if mailer.config.smtpAddress.len == 0:
|
||||
warn("Cannot send mail: no smtp server configured (smtpAddress).")
|
||||
return
|
||||
if mailer.config.smtpFromAddr.len == 0:
|
||||
warn("Cannot send mail: no smtp from address configured (smtpFromAddr).")
|
||||
return
|
||||
|
||||
var client: AsyncSmtp
|
||||
if mailer.config.smtpTls:
|
||||
client = newAsyncSmtp(useSsl=false)
|
||||
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
|
||||
await client.startTls()
|
||||
elif mailer.config.smtpSsl:
|
||||
client = newAsyncSmtp(useSsl=true)
|
||||
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
|
||||
else:
|
||||
client = newAsyncSmtp(useSsl=false)
|
||||
await client.connect(mailer.config.smtpAddress, Port(mailer.config.smtpPort))
|
||||
|
||||
if mailer.config.smtpUser.len > 0:
|
||||
await client.auth(mailer.config.smtpUser, mailer.config.smtpPassword)
|
||||
|
||||
let toList = @[recipient]
|
||||
|
||||
var headers = otherHeaders
|
||||
headers.add(("From", mailer.config.smtpFromAddr))
|
||||
|
||||
let dateHeader = now().utc().format("ddd, dd MMM yyyy hh:mm:ss") & " +0000"
|
||||
headers.add(("Date", dateHeader))
|
||||
|
||||
let encoded = createMessage(subject, message,
|
||||
toList, @[], headers)
|
||||
|
||||
await client.sendMail(mailer.config.smtpFromAddr, toList, $encoded)
|
||||
|
||||
proc sendPassReset(mailer: Mailer, email, user, resetUrl: string) {.async.} =
|
||||
let message = """Hello $1,
|
||||
A password reset has been requested for your account on the $3.
|
||||
|
||||
If you did not make this request, you can safely ignore this email.
|
||||
A password reset request can be made by anyone, and it does not indicate
|
||||
that your account is in any danger of being accessed by someone else.
|
||||
|
||||
If you do actually want to reset your password, visit this link:
|
||||
|
||||
$2
|
||||
|
||||
Thank you for being a part of our community!
|
||||
""" % [user, resetUrl, mailer.config.name]
|
||||
|
||||
let subject = mailer.config.name & " Password Recovery"
|
||||
await sendMail(mailer, subject, message, email)
|
||||
|
||||
proc sendEmailActivation(
|
||||
mailer: Mailer,
|
||||
email, user, activateUrl: string
|
||||
) {.async.} =
|
||||
let message = """Hello $1,
|
||||
You have recently registered an account on the $3.
|
||||
|
||||
As the final step in your registration, we require that you confirm your email
|
||||
via the following link:
|
||||
|
||||
$2
|
||||
|
||||
Thank you for registering and becoming a part of our community!
|
||||
""" % [user, activateUrl, mailer.config.name]
|
||||
let subject = mailer.config.name & " Account Email Confirmation"
|
||||
await sendMail(mailer, subject, message, email)
|
||||
|
||||
type
|
||||
SecureEmailKind* = enum
|
||||
ActivateEmail, ResetPassword
|
||||
|
||||
proc sendSecureEmail*(
|
||||
mailer: Mailer,
|
||||
kind: SecureEmailKind, req: Request,
|
||||
name, password, email, salt: string
|
||||
) {.async.} =
|
||||
let epoch = int(epochTime())
|
||||
|
||||
let path =
|
||||
case kind
|
||||
of ActivateEmail:
|
||||
"activateEmail"
|
||||
of ResetPassword:
|
||||
"resetPassword"
|
||||
let url = req.makeUri(
|
||||
"/$#?nick=$#&epoch=$#&ident=$#" %
|
||||
[
|
||||
path,
|
||||
encodeUrl(name),
|
||||
encodeUrl($epoch),
|
||||
encodeUrl(makeIdentHash(name, password, epoch, salt))
|
||||
]
|
||||
)
|
||||
|
||||
debug(url)
|
||||
|
||||
let emailSentFut =
|
||||
case kind
|
||||
of ActivateEmail:
|
||||
sendEmailActivation(mailer, email, name, url)
|
||||
of ResetPassword:
|
||||
sendPassReset(mailer, email, name, url)
|
||||
yield emailSentFut
|
||||
if emailSentFut.failed:
|
||||
warn("Couldn't send email: ", emailSentFut.error.msg)
|
||||
if emailSentFut.error of ForumError:
|
||||
raise emailSentFut.error
|
||||
else:
|
||||
raise newForumError("Couldn't send email", @["email"])
|
||||
+1727
File diff suppressed because it is too large
Load Diff
+1727
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
when defined(js):
|
||||
import sugar, httpcore
|
||||
import dom except Event
|
||||
|
||||
include karax/prelude
|
||||
import karax / [kajax]
|
||||
|
||||
import error
|
||||
import karaxutils
|
||||
|
||||
type
|
||||
About* = ref object
|
||||
loading: bool
|
||||
status: HttpCode
|
||||
content: kstring
|
||||
page: string
|
||||
|
||||
proc newAbout*(): About =
|
||||
About(
|
||||
status: Http200
|
||||
)
|
||||
|
||||
proc onContent(status: int, response: kstring, state: About) =
|
||||
state.status = status.HttpCode
|
||||
state.content = response
|
||||
|
||||
proc render*(state: About, page: string): VNode =
|
||||
if state.status != Http200:
|
||||
return renderError($state.content, state.status)
|
||||
|
||||
if page != state.page:
|
||||
if not state.loading:
|
||||
state.page = page
|
||||
state.loading = true
|
||||
state.status = Http200
|
||||
let uri = makeUri("/about/" & page & ".html")
|
||||
ajaxGet(uri, @[], (s: int, r: kstring) => onContent(s, r, state))
|
||||
|
||||
return buildHtml(tdiv(class="loading"))
|
||||
|
||||
result = buildHtml():
|
||||
section(class="container grid-xl"):
|
||||
tdiv(class="about"):
|
||||
verbatim(state.content)
|
||||
@@ -0,0 +1,53 @@
|
||||
when defined(js):
|
||||
import sugar, httpcore, options, json
|
||||
import dom except Event
|
||||
|
||||
include karax/prelude
|
||||
import karax / [kajax, kdom]
|
||||
|
||||
import error
|
||||
import karaxutils
|
||||
|
||||
type
|
||||
ActivateEmail* = ref object
|
||||
loading: bool
|
||||
status: HttpCode
|
||||
error: Option[PostError]
|
||||
|
||||
proc newActivateEmail*(): ActivateEmail =
|
||||
ActivateEmail(
|
||||
status: Http200
|
||||
)
|
||||
|
||||
proc onPost(httpStatus: int, response: kstring, state: ActivateEmail) =
|
||||
postFinished:
|
||||
navigateTo(makeUri("/activateEmail/success"))
|
||||
|
||||
proc onSetClick(
|
||||
ev: Event, n: VNode,
|
||||
state: ActivateEmail
|
||||
) =
|
||||
state.loading = true
|
||||
state.error = none[PostError]()
|
||||
|
||||
let uri = makeUri("activateEmail", search = $kdom.window.location.search)
|
||||
ajaxPost(uri, @[], "",
|
||||
(s: int, r: kstring) => onPost(s, r, state))
|
||||
|
||||
proc render*(state: ActivateEmail): VNode =
|
||||
result = buildHtml():
|
||||
section(class="container grid-xl"):
|
||||
tdiv(id="activateemail"):
|
||||
tdiv(class="title"):
|
||||
p(): text "Activate Email"
|
||||
tdiv(class="content"):
|
||||
button(class=class(
|
||||
{"loading": state.loading},
|
||||
"btn btn-primary"
|
||||
),
|
||||
onClick=(ev: Event, n: VNode) =>
|
||||
(onSetClick(ev, n, state))):
|
||||
text "Activate"
|
||||
if state.error.isSome():
|
||||
p(class="text-error"):
|
||||
text state.error.get().message
|
||||
@@ -0,0 +1,87 @@
|
||||
when defined(js):
|
||||
import sugar, httpcore, options, json, strutils
|
||||
import dom except Event
|
||||
import jsffi except `&`
|
||||
|
||||
include karax/prelude
|
||||
import karax / [kajax, kdom, vdom]
|
||||
|
||||
import error, category
|
||||
import category, karaxutils
|
||||
|
||||
type
|
||||
AddCategoryModal* = ref object of VComponent
|
||||
modalShown: bool
|
||||
loading: bool
|
||||
error: Option[PostError]
|
||||
onAddCategory: CategoryEvent
|
||||
|
||||
let nullCategory: CategoryEvent = proc (category: Category) = discard
|
||||
|
||||
proc newAddCategoryModal*(onAddCategory=nullCategory): AddCategoryModal =
|
||||
result = AddCategoryModal(
|
||||
modalShown: false,
|
||||
loading: false,
|
||||
onAddCategory: onAddCategory
|
||||
)
|
||||
|
||||
proc onAddCategoryPost(httpStatus: int, response: kstring, state: AddCategoryModal) =
|
||||
postFinished:
|
||||
state.modalShown = false
|
||||
let j = parseJson($response)
|
||||
let category = j.to(Category)
|
||||
|
||||
state.onAddCategory(category)
|
||||
|
||||
proc onAddCategoryClick(state: AddCategoryModal) =
|
||||
state.loading = true
|
||||
state.error = none[PostError]()
|
||||
|
||||
let uri = makeUri("createCategory")
|
||||
let form = dom.document.getElementById("add-category-form")
|
||||
let formData = newFormData(form)
|
||||
|
||||
ajaxPost(uri, @[], formData.to(cstring),
|
||||
(s: int, r: kstring) => onAddCategoryPost(s, r, state))
|
||||
|
||||
proc setModalShown*(state: AddCategoryModal, visible: bool) =
|
||||
state.modalShown = visible
|
||||
state.markDirty()
|
||||
|
||||
proc onModalClose(state: AddCategoryModal, ev: Event, n: VNode) =
|
||||
state.setModalShown(false)
|
||||
ev.preventDefault()
|
||||
|
||||
proc render*(state: AddCategoryModal): VNode =
|
||||
result = buildHtml():
|
||||
tdiv(class=class({"active": state.modalShown}, "modal modal-sm")):
|
||||
a(href="", class="modal-overlay", "aria-label"="close",
|
||||
onClick=(ev: Event, n: VNode) => onModalClose(state, ev, n))
|
||||
tdiv(class="modal-container"):
|
||||
tdiv(class="modal-header"):
|
||||
tdiv(class="card-title h5"):
|
||||
text "Add New Category"
|
||||
tdiv(class="modal-body"):
|
||||
form(id="add-category-form"):
|
||||
genFormField(
|
||||
state.error, "name", "Name", "text", false,
|
||||
placeholder="Category Name")
|
||||
genFormField(
|
||||
state.error, "color", "Color", "color", false,
|
||||
placeholder="#XXYYZZ"
|
||||
)
|
||||
genFormField(
|
||||
state.error,
|
||||
"description",
|
||||
"Description",
|
||||
"text",
|
||||
true,
|
||||
placeholder="Description"
|
||||
)
|
||||
tdiv(class="modal-footer"):
|
||||
button(
|
||||
id="add-category-btn",
|
||||
class="btn btn-primary",
|
||||
onClick=(ev: Event, n: VNode) =>
|
||||
state.onAddCategoryClick()):
|
||||
text "Add"
|
||||
@@ -0,0 +1,48 @@
|
||||
|
||||
type
|
||||
Category* = object
|
||||
id*: int
|
||||
name*: string
|
||||
description*: string
|
||||
color*: string
|
||||
numTopics*: int
|
||||
|
||||
CategoryList* = ref object
|
||||
categories*: seq[Category]
|
||||
|
||||
CategoryEvent* = proc (category: Category) {.closure.}
|
||||
CategoryChangeEvent* = proc (oldCategory: Category, newCategory: Category) {.closure.}
|
||||
|
||||
const categoryDescriptionCharLimit = 250
|
||||
|
||||
proc cmpNames*(cat1: Category, cat2: Category): int =
|
||||
cat1.name.cmp(cat2.name)
|
||||
|
||||
when defined(js):
|
||||
include karax/prelude
|
||||
import karax / [vstyles]
|
||||
import karaxutils
|
||||
|
||||
proc render*(category: Category, compact=true): VNode =
|
||||
if category.name.len == 0:
|
||||
return buildHtml():
|
||||
span()
|
||||
|
||||
result = buildhtml(tdiv):
|
||||
tdiv(class="category-status"):
|
||||
tdiv(class="category",
|
||||
title=category.description,
|
||||
"data-color"="#" & category.color):
|
||||
tdiv(class="category-color",
|
||||
style=style(
|
||||
(StyleAttr.border,
|
||||
kstring"0.25rem solid #" & category.color)
|
||||
))
|
||||
span(class="category-name"):
|
||||
text category.name
|
||||
if not compact:
|
||||
span(class="topic-count"):
|
||||
text "× " & $category.numTopics
|
||||
if not compact:
|
||||
tdiv(class="category-description"):
|
||||
text category.description.limit(categoryDescriptionCharLimit)
|
||||
@@ -0,0 +1,105 @@
|
||||
import options, json, httpcore
|
||||
|
||||
import category
|
||||
|
||||
when defined(js):
|
||||
import sugar
|
||||
include karax/prelude
|
||||
import karax / [vstyles, kajax]
|
||||
|
||||
import karaxutils, error, user, mainbuttons, addcategorymodal
|
||||
|
||||
type
|
||||
State = ref object
|
||||
list: Option[CategoryList]
|
||||
loading: bool
|
||||
mainButtons: MainButtons
|
||||
status: HttpCode
|
||||
addCategoryModal: AddCategoryModal
|
||||
|
||||
var state: State
|
||||
|
||||
proc newState(): State =
|
||||
State(
|
||||
list: none[CategoryList](),
|
||||
loading: false,
|
||||
mainButtons: newMainButtons(),
|
||||
status: Http200,
|
||||
addCategoryModal: newAddCategoryModal(
|
||||
onAddCategory=
|
||||
(category: Category) => state.list.get().categories.add(category)
|
||||
)
|
||||
)
|
||||
|
||||
state = newState()
|
||||
|
||||
proc genCategory(category: Category, noBorder = false): VNode =
|
||||
result = buildHtml():
|
||||
tr(class=class({"no-border": noBorder})):
|
||||
td(style=style((StyleAttr.borderLeftColor, kstring("#" & category.color))), class="category"):
|
||||
h4(class="category-title", id="category-" & category.name.slug):
|
||||
a(href=makeUri("/c/" & $category.id)):
|
||||
tdiv():
|
||||
tdiv(class="category-name"):
|
||||
text category.name
|
||||
tdiv(class="category-description"):
|
||||
text category.description
|
||||
td(class="topics"):
|
||||
text $category.numTopics
|
||||
|
||||
proc onCategoriesRetrieved(httpStatus: int, response: kstring) =
|
||||
state.loading = false
|
||||
state.status = httpStatus.HttpCode
|
||||
if state.status != Http200: return
|
||||
|
||||
let parsed = parseJson($response)
|
||||
let list = to(parsed, CategoryList)
|
||||
|
||||
if state.list.isSome:
|
||||
state.list.get().categories.add(list.categories)
|
||||
else:
|
||||
state.list = some(list)
|
||||
|
||||
proc renderCategoryHeader*(currentUser: Option[User]): VNode =
|
||||
result = buildHtml(tdiv(id="add-category")):
|
||||
text "Category"
|
||||
if currentUser.isAdmin():
|
||||
button(class="plus-btn btn btn-link",
|
||||
onClick=(ev: Event, n: VNode) => (
|
||||
state.addCategoryModal.setModalShown(true)
|
||||
)):
|
||||
italic(class="fas fa-plus")
|
||||
render(state.addCategoryModal)
|
||||
|
||||
proc renderCategories(currentUser: Option[User]): VNode =
|
||||
if state.status != Http200:
|
||||
return renderError("Couldn't retrieve threads.", state.status)
|
||||
|
||||
if state.list.isNone:
|
||||
if not state.loading:
|
||||
state.loading = true
|
||||
ajaxGet(makeUri("categories.json"), @[], onCategoriesRetrieved)
|
||||
|
||||
return buildHtml(tdiv(class="loading loading-lg"))
|
||||
|
||||
let list = state.list.get()
|
||||
|
||||
return buildHtml():
|
||||
section(class="category-list"):
|
||||
table(id="categories-list", class="table"):
|
||||
thead():
|
||||
tr:
|
||||
th:
|
||||
renderCategoryHeader(currentUser)
|
||||
th(text "Topics")
|
||||
tbody():
|
||||
for i in 0 ..< list.categories.len:
|
||||
let category = list.categories[i]
|
||||
|
||||
let isLastCategory = i+1 == list.categories.len
|
||||
genCategory(category, noBorder=isLastCategory)
|
||||
|
||||
proc renderCategoryList*(currentUser: Option[User]): VNode =
|
||||
result = buildHtml(tdiv):
|
||||
state.mainButtons.render(currentUser)
|
||||
renderCategories(currentUser)
|
||||
@@ -0,0 +1,135 @@
|
||||
when defined(js):
|
||||
import sugar, httpcore, options, json, strutils, algorithm
|
||||
import dom except Event
|
||||
|
||||
include karax/prelude
|
||||
import karax / [kajax, kdom, vdom]
|
||||
|
||||
import error, category, user
|
||||
import category, karaxutils, addcategorymodal
|
||||
|
||||
type
|
||||
CategoryPicker* = ref object of VComponent
|
||||
list: Option[CategoryList]
|
||||
selectedCategoryID*: int
|
||||
loading: bool
|
||||
addEnabled: bool
|
||||
status: HttpCode
|
||||
error: Option[PostError]
|
||||
addCategoryModal: AddCategoryModal
|
||||
onCategoryChange: CategoryChangeEvent
|
||||
onAddCategory: CategoryEvent
|
||||
|
||||
proc onCategoryLoad(state: CategoryPicker): proc (httpStatus: int, response: kstring) =
|
||||
return
|
||||
proc (httpStatus: int, response: kstring) =
|
||||
state.loading = false
|
||||
state.status = httpStatus.HttpCode
|
||||
if state.status != Http200: return
|
||||
|
||||
let parsed = parseJson($response)
|
||||
let list = parsed.to(CategoryList)
|
||||
list.categories.sort(cmpNames)
|
||||
|
||||
if state.list.isSome:
|
||||
state.list.get().categories.add(list.categories)
|
||||
else:
|
||||
state.list = some(list)
|
||||
|
||||
if state.selectedCategoryID > state.list.get().categories.len():
|
||||
state.selectedCategoryID = 0
|
||||
|
||||
proc loadCategories(state: CategoryPicker) =
|
||||
if not state.loading:
|
||||
state.loading = true
|
||||
ajaxGet(makeUri("categories.json"), @[], onCategoryLoad(state))
|
||||
|
||||
proc `[]`*(state: CategoryPicker, id: int): Category =
|
||||
for cat in state.list.get().categories:
|
||||
if cat.id == id:
|
||||
return cat
|
||||
raise newException(IndexError, "Category at " & $id & " not found!")
|
||||
|
||||
let nullAddCategory: CategoryEvent = proc (category: Category) = discard
|
||||
let nullCategoryChange: CategoryChangeEvent = proc (oldCategory: Category, newCategory: Category) = discard
|
||||
|
||||
proc select*(state: CategoryPicker, id: int) =
|
||||
state.selectedCategoryID = id
|
||||
state.markDirty()
|
||||
|
||||
proc onCategory(state: CategoryPicker): CategoryEvent =
|
||||
result =
|
||||
proc (category: Category) =
|
||||
state.list.get().categories.add(category)
|
||||
state.list.get().categories.sort(cmpNames)
|
||||
state.select(category.id)
|
||||
state.onAddCategory(category)
|
||||
|
||||
proc newCategoryPicker*(onCategoryChange=nullCategoryChange, onAddCategory=nullAddCategory): CategoryPicker =
|
||||
result = CategoryPicker(
|
||||
list: none[CategoryList](),
|
||||
selectedCategoryID: 0,
|
||||
loading: false,
|
||||
addEnabled: false,
|
||||
status: Http200,
|
||||
error: none[PostError](),
|
||||
onCategoryChange: onCategoryChange,
|
||||
onAddCategory: onAddCategory
|
||||
)
|
||||
|
||||
let state = result
|
||||
result.addCategoryModal = newAddCategoryModal(
|
||||
onAddCategory=onCategory(state)
|
||||
)
|
||||
|
||||
proc setAddEnabled*(state: CategoryPicker, enabled: bool) =
|
||||
state.addEnabled = enabled
|
||||
|
||||
proc onCategoryClick(state: CategoryPicker, category: Category): proc (ev: Event, n: VNode) =
|
||||
# this is necessary to capture the right value
|
||||
let cat = category
|
||||
return
|
||||
proc (ev: Event, n: VNode) =
|
||||
let oldCategory = state[state.selectedCategoryID]
|
||||
state.select(cat.id)
|
||||
state.onCategoryChange(oldCategory, cat)
|
||||
|
||||
proc genAddCategory(state: CategoryPicker): VNode =
|
||||
result = buildHtml():
|
||||
tdiv(id="add-category"):
|
||||
button(class="plus-btn btn btn-link",
|
||||
onClick=(ev: Event, n: VNode) => (
|
||||
state.addCategoryModal.setModalShown(true)
|
||||
)):
|
||||
italic(class="fas fa-plus")
|
||||
render(state.addCategoryModal)
|
||||
|
||||
proc render*(state: CategoryPicker, currentUser: Option[User], compact=true): VNode =
|
||||
state.setAddEnabled(currentUser.isAdmin())
|
||||
|
||||
if state.status != Http200:
|
||||
return renderError("Couldn't retrieve categories.", state.status)
|
||||
|
||||
if state.list.isNone:
|
||||
state.loadCategories()
|
||||
return buildHtml(tdiv(class="loading loading-lg"))
|
||||
|
||||
let list = state.list.get().categories
|
||||
let selectedCategory = state[state.selectedCategoryID]
|
||||
|
||||
result = buildHtml():
|
||||
tdiv(id="category-selection", class="input-group"):
|
||||
tdiv(class="dropdown"):
|
||||
a(class="btn btn-link dropdown-toggle", tabindex="0"):
|
||||
tdiv(class="selected-category d-inline-block"):
|
||||
render(selectedCategory)
|
||||
text " "
|
||||
italic(class="fas fa-caret-down")
|
||||
ul(class="menu"):
|
||||
for category in list:
|
||||
li(class="menu-item"):
|
||||
a(class="category-" & $category.id & " " & category.name.slug,
|
||||
onClick=onCategoryClick(state, category)):
|
||||
render(category, compact)
|
||||
if state.addEnabled:
|
||||
genAddCategory(state)
|
||||
@@ -0,0 +1,133 @@
|
||||
when defined(js):
|
||||
import sugar, httpcore, options, json
|
||||
import dom except Event
|
||||
import jsffi except `&`
|
||||
|
||||
include karax/prelude
|
||||
import karax / [kajax, kdom]
|
||||
|
||||
import error, post, threadlist, user
|
||||
import karaxutils
|
||||
|
||||
type
|
||||
DeleteKind* = enum
|
||||
DeleteUser, DeletePost, DeleteThread
|
||||
|
||||
DeleteModal* = ref object
|
||||
shown: bool
|
||||
loading: bool
|
||||
onDeletePost: proc (post: Post)
|
||||
onDeleteThread: proc (thread: Thread)
|
||||
onDeleteUser: proc (user: User)
|
||||
error: Option[PostError]
|
||||
case kind: DeleteKind
|
||||
of DeleteUser:
|
||||
user: User
|
||||
of DeletePost:
|
||||
post: Post
|
||||
of DeleteThread:
|
||||
thread: Thread
|
||||
|
||||
proc onDeletePost(httpStatus: int, response: kstring, state: DeleteModal) =
|
||||
postFinished:
|
||||
state.shown = false
|
||||
case state.kind
|
||||
of DeleteUser:
|
||||
state.onDeleteUser(state.user)
|
||||
of DeletePost:
|
||||
state.onDeletePost(state.post)
|
||||
of DeleteThread:
|
||||
state.onDeleteThread(state.thread)
|
||||
|
||||
proc onDelete(ev: Event, n: VNode, state: DeleteModal) =
|
||||
state.loading = true
|
||||
state.error = none[PostError]()
|
||||
|
||||
let uri =
|
||||
case state.kind
|
||||
of DeleteUser:
|
||||
makeUri("/deleteUser")
|
||||
of DeleteThread:
|
||||
makeUri("/deleteThread")
|
||||
of DeletePost:
|
||||
makeUri("/deletePost")
|
||||
# TODO: This is a hack, karax should support this.
|
||||
let formData = newFormData()
|
||||
case state.kind
|
||||
of DeleteUser:
|
||||
formData.append("username", state.user.name)
|
||||
of DeletePost:
|
||||
formData.append("id", $state.post.id)
|
||||
of DeleteThread:
|
||||
formData.append("id", $state.thread.id)
|
||||
ajaxPost(uri, @[], formData.to(cstring),
|
||||
(s: int, r: kstring) => onDeletePost(s, r, state))
|
||||
|
||||
proc onClose(ev: Event, n: VNode, state: DeleteModal) =
|
||||
state.shown = false
|
||||
ev.preventDefault()
|
||||
|
||||
proc newDeleteModal*(
|
||||
onDeletePost: proc (post: Post),
|
||||
onDeleteThread: proc (thread: Thread),
|
||||
onDeleteUser: proc (user: User),
|
||||
): DeleteModal =
|
||||
DeleteModal(
|
||||
shown: false,
|
||||
onDeletePost: onDeletePost,
|
||||
onDeleteThread: onDeleteThread,
|
||||
onDeleteUser: onDeleteUser,
|
||||
)
|
||||
|
||||
proc show*(state: DeleteModal, thing: User | Post | Thread) =
|
||||
state.shown = true
|
||||
state.error = none[PostError]()
|
||||
when thing is User:
|
||||
state.kind = DeleteUser
|
||||
state.user = thing
|
||||
when thing is Post:
|
||||
state.kind = DeletePost
|
||||
state.post = thing
|
||||
when thing is Thread:
|
||||
state.kind = DeleteThread
|
||||
state.thread = thing
|
||||
|
||||
proc render*(state: DeleteModal): VNode =
|
||||
result = buildHtml():
|
||||
tdiv(class=class({"active": state.shown}, "modal modal-sm"),
|
||||
id="delete-modal"):
|
||||
a(href="", class="modal-overlay", "aria-label"="close",
|
||||
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
|
||||
tdiv(class="modal-container"):
|
||||
tdiv(class="modal-header"):
|
||||
a(href="", class="btn btn-clear float-right",
|
||||
"aria-label"="close",
|
||||
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
|
||||
tdiv(class="modal-title h5"):
|
||||
text "Delete"
|
||||
tdiv(class="modal-body"):
|
||||
tdiv(class="content"):
|
||||
p():
|
||||
text "Are you sure you want to delete this "
|
||||
case state.kind
|
||||
of DeleteUser:
|
||||
text "user account?"
|
||||
of DeleteThread:
|
||||
text "thread?"
|
||||
of DeletePost:
|
||||
text "post?"
|
||||
tdiv(class="modal-footer"):
|
||||
if state.error.isSome():
|
||||
p(class="text-error"):
|
||||
text state.error.get().message
|
||||
|
||||
button(class=class(
|
||||
{"loading": state.loading},
|
||||
"btn btn-primary delete-btn"
|
||||
),
|
||||
onClick=(ev: Event, n: VNode) => onDelete(ev, n, state)):
|
||||
italic(class="fas fa-trash-alt")
|
||||
text " Delete"
|
||||
button(class="btn cancel-btn",
|
||||
onClick=(ev: Event, n: VNode) => (state.shown = false)):
|
||||
text "Cancel"
|
||||
@@ -0,0 +1,99 @@
|
||||
when defined(js):
|
||||
import httpcore, options, sugar, json
|
||||
import jsffi except `&`
|
||||
|
||||
include karax/prelude
|
||||
import karax/kajax
|
||||
|
||||
import replybox, post, karaxutils, threadlist, error
|
||||
|
||||
type
|
||||
OnEditPosted* = proc (id: int, content: string, subject: Option[string])
|
||||
|
||||
EditBox* = ref object
|
||||
box: ReplyBox
|
||||
post: Post
|
||||
rawContent: Option[kstring] ## The raw rst for a post (needs to be loaded)
|
||||
loading: bool
|
||||
status: HttpCode
|
||||
error: Option[PostError]
|
||||
onEditPosted: OnEditPosted
|
||||
onEditCancel: proc ()
|
||||
|
||||
proc newEditBox*(onEditPosted: OnEditPosted, onEditCancel: proc ()): EditBox =
|
||||
EditBox(
|
||||
box: newReplyBox(nil),
|
||||
onEditPosted: onEditPosted,
|
||||
onEditCancel: onEditCancel,
|
||||
status: Http200
|
||||
)
|
||||
|
||||
proc onRawContent(httpStatus: int, response: kstring, state: EditBox) =
|
||||
state.status = httpStatus.HttpCode
|
||||
if state.status != Http200: return
|
||||
|
||||
state.rawContent = some(response)
|
||||
state.box.setText(state.rawContent.get())
|
||||
|
||||
proc onEditPost(httpStatus: int, response: kstring, state: EditBox) =
|
||||
postFinished:
|
||||
state.onEditPosted(
|
||||
state.post.id,
|
||||
$response,
|
||||
none[string]()
|
||||
)
|
||||
|
||||
proc save(state: EditBox) =
|
||||
if state.loading:
|
||||
# TODO: Weird behaviour: onClick handler gets called 80+ times.
|
||||
return
|
||||
state.loading = true
|
||||
state.error = none[PostError]()
|
||||
|
||||
let formData = newFormData()
|
||||
formData.append("msg", state.box.getText())
|
||||
formData.append("postId", $state.post.id)
|
||||
# TODO: Subject
|
||||
let uri = makeUri("/updatePost")
|
||||
ajaxPost(uri, @[], formData.to(cstring),
|
||||
(s: int, r: kstring) => onEditPost(s, r, state))
|
||||
|
||||
proc render*(state: EditBox, post: Post): VNode =
|
||||
if (not state.post.isNil) and state.post.id != post.id:
|
||||
state.rawContent = none[kstring]()
|
||||
state.status = Http200
|
||||
|
||||
if state.status != Http200:
|
||||
return renderError("Couldn't retrieve raw post", state.status)
|
||||
|
||||
if state.rawContent.isNone():
|
||||
state.post = post
|
||||
state.rawContent = none[kstring]()
|
||||
var params = @[("id", $post.id)]
|
||||
let uri = makeUri("post.rst", params)
|
||||
ajaxGet(uri, @[], (s: int, r: kstring) => onRawContent(s, r, state))
|
||||
|
||||
return buildHtml(tdiv(class="loading"))
|
||||
|
||||
result = buildHtml():
|
||||
tdiv(class="edit-box"):
|
||||
renderContent(
|
||||
state.box,
|
||||
none[Thread](),
|
||||
none[Post]()
|
||||
)
|
||||
|
||||
if state.error.isSome():
|
||||
span(class="text-error"):
|
||||
text state.error.get().message
|
||||
|
||||
tdiv(class="edit-buttons"):
|
||||
tdiv(class="cancel-button"):
|
||||
button(class="btn btn-link",
|
||||
onClick=(e: Event, n: VNode) => (state.onEditCancel())):
|
||||
text " Cancel"
|
||||
tdiv(class="save-button"):
|
||||
button(class=class({"loading": state.loading}, "btn btn-primary"),
|
||||
onClick=(e: Event, n: VNode) => state.save()):
|
||||
italic(class="fas fa-check")
|
||||
text " Save"
|
||||
@@ -0,0 +1,92 @@
|
||||
import httpcore
|
||||
type
|
||||
PostError* = object
|
||||
errorFields*: seq[string] ## IDs of the fields with an error.
|
||||
message*: string
|
||||
|
||||
when defined(js):
|
||||
import json, options
|
||||
include karax/prelude
|
||||
|
||||
import karaxutils
|
||||
|
||||
proc render404*(): VNode =
|
||||
result = buildHtml():
|
||||
tdiv(class="empty error"):
|
||||
tdiv(class="empty icon"):
|
||||
italic(class="fas fa-bug fa-5x")
|
||||
p(class="empty-title h5"):
|
||||
text "404 Not Found"
|
||||
p(class="empty-subtitle"):
|
||||
text "Cannot find what you are looking for, it might have been " &
|
||||
"deleted. Sorry!"
|
||||
tdiv(class="empty-action"):
|
||||
a(href="/", onClick=anchorCB):
|
||||
button(class="btn btn-primary"):
|
||||
text "Go back home"
|
||||
|
||||
proc renderError*(message: string, status: HttpCode): VNode =
|
||||
if status == Http404:
|
||||
return render404()
|
||||
|
||||
result = buildHtml():
|
||||
tdiv(class="empty error"):
|
||||
tdiv(class="empty icon"):
|
||||
italic(class="fas fa-bug fa-5x")
|
||||
p(class="empty-title h5"):
|
||||
text message
|
||||
p(class="empty-subtitle"):
|
||||
text "Please report this issue to us so we can fix it!"
|
||||
tdiv(class="empty-action"):
|
||||
a(href="https://github.com/nim-lang/nimforum/issues", target="_blank"):
|
||||
button(class="btn btn-primary"):
|
||||
text "Report issue"
|
||||
|
||||
proc renderMessage*(message, submessage, icon: string): VNode =
|
||||
result = buildHtml():
|
||||
tdiv(class="empty error"):
|
||||
tdiv(class="empty icon"):
|
||||
italic(class="fas " & icon & " fa-5x")
|
||||
p(class="empty-title h5"):
|
||||
text message
|
||||
p(class="empty-subtitle"):
|
||||
text submessage
|
||||
|
||||
proc genFormField*(error: Option[PostError], name, label, typ: string,
|
||||
isLast: bool, placeholder=""): VNode =
|
||||
let hasError =
|
||||
not error.isNone and (
|
||||
name in error.get().errorFields or
|
||||
error.get().errorFields.len == 0)
|
||||
result = buildHtml():
|
||||
tdiv(class=class({"has-error": hasError}, "form-group")):
|
||||
label(class="form-label", `for`=name):
|
||||
text label
|
||||
input(class="form-input", `type`=typ, name=name,
|
||||
placeholder=placeholder)
|
||||
|
||||
if not error.isNone:
|
||||
let e = error.get()
|
||||
if (e.errorFields.len == 1 and e.errorFields[0] == name) or
|
||||
(isLast and e.errorFields.len == 0):
|
||||
span(class="form-input-hint"):
|
||||
text e.message
|
||||
|
||||
template postFinished*(onSuccess: untyped): untyped =
|
||||
state.loading = false
|
||||
let status = httpStatus.HttpCode
|
||||
if status == Http200:
|
||||
onSuccess
|
||||
else:
|
||||
# TODO: Karax should pass the content-type...
|
||||
try:
|
||||
let parsed = parseJson($response)
|
||||
let error = to(parsed, PostError)
|
||||
|
||||
state.error = some(error)
|
||||
except:
|
||||
echo getCurrentExceptionMsg()
|
||||
state.error = some(PostError(
|
||||
errorFields: @[],
|
||||
message: "Unknown error occurred."
|
||||
))
|
||||
@@ -0,0 +1,162 @@
|
||||
import options, tables, sugar, httpcore
|
||||
from dom import window, Location, document, decodeURI
|
||||
|
||||
include karax/prelude
|
||||
import karax/[kdom]
|
||||
import jester/[patterns]
|
||||
|
||||
import threadlist, postlist, header, profile, newthread, error, about
|
||||
import categorylist
|
||||
import resetpassword, activateemail, search
|
||||
import karaxutils
|
||||
|
||||
type
|
||||
State = ref object
|
||||
originalTitle: cstring
|
||||
url: Location
|
||||
profile: ProfileState
|
||||
newThread: NewThread
|
||||
about: About
|
||||
resetPassword: ResetPassword
|
||||
activateEmail: ActivateEmail
|
||||
search: Search
|
||||
|
||||
proc copyLocation(loc: Location): Location =
|
||||
# TODO: It sucks that I had to do this. We need a nice way to deep copy in JS.
|
||||
Location(
|
||||
hash: loc.hash,
|
||||
host: loc.host,
|
||||
hostname: loc.hostname,
|
||||
href: loc.href,
|
||||
pathname: loc.pathname,
|
||||
port: loc.port,
|
||||
protocol: loc.protocol,
|
||||
search: loc.search
|
||||
)
|
||||
|
||||
proc newState(): State =
|
||||
State(
|
||||
originalTitle: document.title,
|
||||
url: copyLocation(window.location),
|
||||
profile: newProfileState(),
|
||||
newThread: newNewThread(),
|
||||
about: newAbout(),
|
||||
resetPassword: newResetPassword(),
|
||||
activateEmail: newActivateEmail(),
|
||||
search: newSearch()
|
||||
)
|
||||
|
||||
var state = newState()
|
||||
proc onPopState(event: dom.Event) =
|
||||
# This event is usually only called when the user moves back in their
|
||||
# history. I fire it in karaxutils.anchorCB as well to ensure the URL is
|
||||
# always updated. This should be moved into Karax in the future.
|
||||
echo "New URL: ", window.location.href, " ", state.url.href
|
||||
document.title = state.originalTitle
|
||||
if state.url.href != window.location.href:
|
||||
state = newState() # Reload the state to remove stale data.
|
||||
state.url = copyLocation(window.location)
|
||||
|
||||
redraw()
|
||||
|
||||
type Params = Table[string, string]
|
||||
type
|
||||
Route = object
|
||||
n: string
|
||||
p: proc (params: Params): VNode
|
||||
|
||||
proc r(n: string, p: proc (params: Params): VNode): Route = Route(n: n, p: p)
|
||||
proc route(routes: openarray[Route]): VNode =
|
||||
let path =
|
||||
if state.url.pathname.len == 0: "/" else: $state.url.pathname
|
||||
let prefix = if appName == "/": "" else: appName
|
||||
for route in routes:
|
||||
let pattern = (prefix & route.n).parsePattern()
|
||||
var (matched, params) = pattern.match(path)
|
||||
parseUrlQuery($state.url.search, params)
|
||||
if matched:
|
||||
return route.p(params)
|
||||
|
||||
return renderError("Unmatched route: " & path, Http500)
|
||||
|
||||
proc render(): VNode =
|
||||
result = buildHtml(tdiv()):
|
||||
renderHeader()
|
||||
route([
|
||||
r("/categories",
|
||||
(params: Params) =>
|
||||
(renderCategoryList(getLoggedInUser()))
|
||||
),
|
||||
r("/c/@id",
|
||||
(params: Params) =>
|
||||
(renderThreadList(getLoggedInUser(), some(params["id"].parseInt)))
|
||||
),
|
||||
r("/newthread",
|
||||
(params: Params) =>
|
||||
(render(state.newThread, getLoggedInUser()))
|
||||
),
|
||||
r("/profile/@username",
|
||||
(params: Params) =>
|
||||
(
|
||||
render(
|
||||
state.profile,
|
||||
decodeURI(params["username"]),
|
||||
getLoggedInUser()
|
||||
)
|
||||
)
|
||||
),
|
||||
r("/t/@id",
|
||||
(params: Params) =>
|
||||
(
|
||||
let postId = getInt(($state.url.hash).substr(1), 0);
|
||||
renderPostList(
|
||||
params["id"].parseInt(),
|
||||
if postId == 0: none[int]() else: some[int](postId),
|
||||
getLoggedInUser()
|
||||
)
|
||||
)
|
||||
),
|
||||
r("/about/?@page?",
|
||||
(params: Params) => (render(state.about, params["page"]))
|
||||
),
|
||||
r("/activateEmail/success",
|
||||
(params: Params) => (
|
||||
renderMessage(
|
||||
"Email activated",
|
||||
"You can now create new posts!",
|
||||
"fa-check"
|
||||
)
|
||||
)
|
||||
),
|
||||
r("/activateEmail",
|
||||
(params: Params) => (
|
||||
render(state.activateEmail)
|
||||
)
|
||||
),
|
||||
r("/resetPassword/success",
|
||||
(params: Params) => (
|
||||
renderMessage(
|
||||
"Password changed",
|
||||
"You can now login using your new password!",
|
||||
"fa-check"
|
||||
)
|
||||
)
|
||||
),
|
||||
r("/resetPassword",
|
||||
(params: Params) => (
|
||||
render(state.resetPassword)
|
||||
)
|
||||
),
|
||||
r("/search",
|
||||
(params: Params) => (
|
||||
render(state.search, params["q"], getLoggedInUser())
|
||||
)
|
||||
),
|
||||
r("/404",
|
||||
(params: Params) => render404()
|
||||
),
|
||||
r("/", (params: Params) => renderThreadList(getLoggedInUser()))
|
||||
])
|
||||
|
||||
window.onPopState = onPopState
|
||||
setRenderer render
|
||||
@@ -0,0 +1,4 @@
|
||||
-d:js
|
||||
@if nimHasJsNoLambdaLifting:
|
||||
legacy:jsnolambdalifting
|
||||
@end
|
||||
@@ -0,0 +1,119 @@
|
||||
import options, httpcore
|
||||
|
||||
import user
|
||||
type
|
||||
UserStatus* = object
|
||||
user*: Option[User]
|
||||
recaptchaSiteKey*: Option[string]
|
||||
|
||||
when defined(js):
|
||||
import times, json, sugar
|
||||
include karax/prelude
|
||||
import karax / [kajax, kdom]
|
||||
|
||||
import login, signup, usermenu
|
||||
import karaxutils
|
||||
|
||||
from dom import
|
||||
setTimeout, window, document, getElementById, focus
|
||||
|
||||
|
||||
type
|
||||
State = ref object
|
||||
data: Option[UserStatus]
|
||||
loading: bool
|
||||
status: HttpCode
|
||||
lastUpdate: Time
|
||||
loginModal: LoginModal
|
||||
signupModal: SignupModal
|
||||
userMenu: UserMenu
|
||||
|
||||
proc newState(): State
|
||||
var
|
||||
state = newState()
|
||||
|
||||
proc getStatus(logout=false)
|
||||
proc newState(): State =
|
||||
State(
|
||||
data: none[UserStatus](),
|
||||
loading: false,
|
||||
status: Http200,
|
||||
loginModal: newLoginModal(
|
||||
() => (state.lastUpdate = fromUnix(0); getStatus()),
|
||||
() => state.signupModal.show()
|
||||
),
|
||||
signupModal: newSignupModal(
|
||||
() => (state.lastUpdate = fromUnix(0); getStatus()),
|
||||
() => state.loginModal.show()
|
||||
),
|
||||
userMenu: newUserMenu(
|
||||
() => (state.lastUpdate = fromUnix(0); getStatus(logout=true))
|
||||
)
|
||||
)
|
||||
|
||||
proc onStatus(httpStatus: int, response: kstring) =
|
||||
state.loading = false
|
||||
state.status = httpStatus.HttpCode
|
||||
if state.status != Http200: return
|
||||
|
||||
let parsed = parseJson($response)
|
||||
state.data = some(to(parsed, UserStatus))
|
||||
|
||||
state.lastUpdate = getTime()
|
||||
|
||||
proc getStatus(logout=false) =
|
||||
if state.loading: return
|
||||
let diff = getTime() - state.lastUpdate
|
||||
if diff.inMinutes < 5:
|
||||
return
|
||||
|
||||
state.loading = true
|
||||
let uri = makeUri("status.json", [("logout", $logout)])
|
||||
ajaxGet(uri, @[], onStatus)
|
||||
|
||||
proc getLoggedInUser*(): Option[User] =
|
||||
state.data.map(x => x.user).flatten
|
||||
|
||||
proc isLoggedIn*(): bool =
|
||||
not getLoggedInUser().isNone
|
||||
|
||||
proc onKeyDown(e: Event, n: VNode) =
|
||||
let event = cast[KeyboardEvent](e)
|
||||
if event.key == "Enter":
|
||||
navigateTo(makeUri("/search", ("q", $n.value), reuseSearch=false))
|
||||
|
||||
proc renderHeader*(): VNode =
|
||||
if state.data.isNone and state.status == Http200:
|
||||
getStatus()
|
||||
|
||||
let user = state.data.map(x => x.user).flatten
|
||||
result = buildHtml(tdiv()):
|
||||
header(id="main-navbar"):
|
||||
tdiv(class="navbar container grid-xl"):
|
||||
section(class="navbar-section"):
|
||||
a(href=makeUri("/")):
|
||||
img(src="/images/logo.png", id="img-logo")
|
||||
section(class="navbar-section"):
|
||||
tdiv(class="input-group input-inline"):
|
||||
input(class="search-input input-sm",
|
||||
`type`="search", placeholder="Search",
|
||||
id="search-box", required="required",
|
||||
onKeyDown=onKeyDown)
|
||||
if state.loading:
|
||||
tdiv(class="loading")
|
||||
elif user.isNone:
|
||||
button(id="signup-btn", class="btn btn-primary btn-sm",
|
||||
onClick=(e: Event, n: VNode) => state.signupModal.show()):
|
||||
italic(class="fas fa-user-plus")
|
||||
text " Sign up"
|
||||
button(id="login-btn", class="btn btn-primary btn-sm",
|
||||
onClick=(e: Event, n: VNode) => state.loginModal.show()):
|
||||
italic(class="fas fa-sign-in-alt")
|
||||
text " Log in"
|
||||
else:
|
||||
render(state.userMenu, user.get())
|
||||
|
||||
# Modals
|
||||
if state.data.isSome():
|
||||
render(state.loginModal, state.data.get().recaptchaSiteKey)
|
||||
render(state.signupModal, state.data.get().recaptchaSiteKey)
|
||||
@@ -0,0 +1,128 @@
|
||||
import strutils, strformat, parseutils, tables
|
||||
|
||||
proc limit*(str: string, n: int): string =
|
||||
## Limit the number of characters in a string. Ends with a elipsis
|
||||
if str.len > n:
|
||||
return str[0..<n-3] & "..."
|
||||
else:
|
||||
return str
|
||||
|
||||
proc slug*(name: string): string =
|
||||
## Transforms text into a url slug
|
||||
name.strip().replace(" ", "-").toLowerAscii
|
||||
|
||||
proc parseIntSafe*(s: string, value: var int) {.noSideEffect.} =
|
||||
## parses `s` into an integer in the range `validRange`. If successful,
|
||||
## `value` is modified to contain the result. Otherwise no exception is
|
||||
## raised and `value` is not touched; this way a reasonable default value
|
||||
## won't be overwritten.
|
||||
try:
|
||||
discard parseutils.parseInt(s, value, 0)
|
||||
except OverflowError:
|
||||
discard
|
||||
|
||||
proc getInt*(s: string, default = 0): int =
|
||||
## Safely parses an int and returns it.
|
||||
result = default
|
||||
parseIntSafe(s, result)
|
||||
|
||||
proc getInt64*(s: string, default = 0): int64 =
|
||||
## Safely parses an int and returns it.
|
||||
result = default
|
||||
try:
|
||||
discard parseutils.parseBiggestInt(s, result, 0)
|
||||
except OverflowError:
|
||||
discard
|
||||
|
||||
when defined(js):
|
||||
include karax/prelude
|
||||
import karax / [kdom, kajax]
|
||||
|
||||
from dom import nil
|
||||
|
||||
const appName* = "/"
|
||||
|
||||
proc class*(classes: varargs[tuple[name: string, present: bool]],
|
||||
defaultClasses: string = ""): string =
|
||||
result = defaultClasses & " "
|
||||
for class in classes:
|
||||
if class.present: result.add(class.name & " ")
|
||||
|
||||
proc makeUri*(relative: string, appName=appName, includeHash=false,
|
||||
search: string=""): string =
|
||||
## Concatenates ``relative`` to the current URL in a way that is
|
||||
## (possibly) sane.
|
||||
var relative = relative
|
||||
assert appName in $window.location.pathname
|
||||
if relative[0] == '/': relative = relative[1..^1]
|
||||
|
||||
return $window.location.protocol & "//" &
|
||||
$window.location.host &
|
||||
appName &
|
||||
relative &
|
||||
search &
|
||||
(if includeHash: $window.location.hash else: "")
|
||||
|
||||
proc makeUri*(relative: string, params: varargs[(string, string)],
|
||||
appName=appName, includeHash=false,
|
||||
reuseSearch=true): string =
|
||||
var query = ""
|
||||
for i in 0 ..< params.len:
|
||||
let param = params[i]
|
||||
if i != 0: query.add("&")
|
||||
query.add(param[0] & "=" & param[1])
|
||||
|
||||
if query.len > 0:
|
||||
var search = if reuseSearch: $window.location.search else: ""
|
||||
if search.len != 0: search.add("&")
|
||||
search.add(query)
|
||||
if search[0] != '?': search = "?" & search
|
||||
makeUri(relative, appName, search=search)
|
||||
else:
|
||||
makeUri(relative, appName)
|
||||
|
||||
proc navigateTo*(uri: cstring) =
|
||||
# TODO: This was annoying. Karax also shouldn't have its own `window`.
|
||||
dom.pushState(dom.window.history, 0, cstring"", uri)
|
||||
|
||||
# Fire the popState event.
|
||||
dom.dispatchEvent(dom.window, dom.newEvent("popstate"))
|
||||
|
||||
proc anchorCB*(e: Event, n: VNode) =
|
||||
let mE = e.MouseEvent
|
||||
if not (mE.metaKey or mE.ctrlKey):
|
||||
e.preventDefault()
|
||||
|
||||
# TODO: Why does Karax have it's own Node type? That's just silly.
|
||||
let url = n.getAttr("href")
|
||||
|
||||
navigateTo(url)
|
||||
|
||||
proc newFormData*(form: dom.Element): FormData
|
||||
{.importcpp: "new FormData(@)", constructor.}
|
||||
proc get*(form: FormData, key: cstring): cstring
|
||||
{.importcpp: "#.get(@)".}
|
||||
|
||||
proc renderProfileUrl*(username: string): string =
|
||||
makeUri(fmt"/profile/{username}")
|
||||
|
||||
proc renderPostUrl*(threadId, postId: int): string =
|
||||
makeUri(fmt"/t/{threadId}#{postId}")
|
||||
|
||||
proc parseUrlQuery*(query: string, result: var Table[string, string])
|
||||
{.deprecated: "use stdlib".} =
|
||||
## Based on copy from Jester. Use stdlib when
|
||||
## https://github.com/nim-lang/Nim/pull/7761 is merged.
|
||||
var i = 0
|
||||
i = query.skip("?")
|
||||
while i < query.len()-1:
|
||||
var key = ""
|
||||
var val = ""
|
||||
i += query.parseUntil(key, '=', i)
|
||||
if query[i] != '=':
|
||||
raise newException(ValueError, "Expected '=' at " & $i &
|
||||
" but got: " & $query[i])
|
||||
inc(i) # Skip =
|
||||
i += query.parseUntil(val, '&', i)
|
||||
inc(i) # Skip &
|
||||
result[$decodeUri(key)] = $decodeUri(val)
|
||||
@@ -0,0 +1,97 @@
|
||||
when defined(js):
|
||||
import sugar, httpcore, options, json
|
||||
import dom except Event, KeyboardEvent
|
||||
import jsffi except `&`
|
||||
|
||||
include karax/prelude
|
||||
import karax / [kajax, kdom]
|
||||
|
||||
import error, resetpassword
|
||||
import karaxutils
|
||||
|
||||
type
|
||||
LoginModal* = ref object
|
||||
shown: bool
|
||||
loading: bool
|
||||
onLogIn: proc ()
|
||||
onSignUp: proc ()
|
||||
error: Option[PostError]
|
||||
resetPasswordModal: ResetPasswordModal
|
||||
|
||||
proc onLogInPost(httpStatus: int, response: kstring, state: LoginModal) =
|
||||
postFinished:
|
||||
state.shown = false
|
||||
state.onLogIn()
|
||||
|
||||
proc onLogInClick(ev: Event, n: VNode, state: LoginModal) =
|
||||
state.loading = true
|
||||
state.error = none[PostError]()
|
||||
|
||||
let uri = makeUri("login")
|
||||
let form = dom.document.getElementById("login-form")
|
||||
# TODO: This is a hack, karax should support this.
|
||||
let formData = newFormData(form)
|
||||
ajaxPost(uri, @[], formData.to(cstring),
|
||||
(s: int, r: kstring) => onLogInPost(s, r, state))
|
||||
|
||||
proc onClose(ev: Event, n: VNode, state: LoginModal) =
|
||||
state.shown = false
|
||||
ev.preventDefault()
|
||||
|
||||
proc newLoginModal*(onLogIn, onSignUp: proc ()): LoginModal =
|
||||
LoginModal(
|
||||
shown: false,
|
||||
onLogIn: onLogIn,
|
||||
onSignUp: onSignUp,
|
||||
resetPasswordModal: newResetPasswordModal()
|
||||
)
|
||||
|
||||
proc show*(state: LoginModal) =
|
||||
state.shown = true
|
||||
|
||||
proc onKeyDown(e: Event, n: VNode, state: LoginModal) =
|
||||
let event = cast[KeyboardEvent](e)
|
||||
if event.key == "Enter":
|
||||
onLogInClick(e, n, state)
|
||||
|
||||
proc render*(state: LoginModal, recaptchaSiteKey: Option[string]): VNode =
|
||||
result = buildHtml(tdiv()):
|
||||
tdiv(class=class({"active": state.shown}, "modal modal-sm"),
|
||||
id="login-modal"):
|
||||
a(href="", class="modal-overlay", "aria-label"="close",
|
||||
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
|
||||
tdiv(class="modal-container"):
|
||||
tdiv(class="modal-header"):
|
||||
a(href="", class="btn btn-clear float-right",
|
||||
"aria-label"="close",
|
||||
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
|
||||
tdiv(class="modal-title h5"):
|
||||
text "Log in"
|
||||
tdiv(class="modal-body"):
|
||||
tdiv(class="content"):
|
||||
form(id="login-form",
|
||||
onKeyDown=(ev: Event, n: VNode) => onKeyDown(ev, n, state)):
|
||||
genFormField(state.error, "username", "Username", "text", false)
|
||||
genFormField(
|
||||
state.error,
|
||||
"password",
|
||||
"Password",
|
||||
"password",
|
||||
true
|
||||
)
|
||||
a(href="", onClick=(e: Event, n: VNode) =>
|
||||
(state.resetPasswordModal.show(); e.preventDefault())):
|
||||
text "Reset your password"
|
||||
tdiv(class="modal-footer"):
|
||||
button(class=class(
|
||||
{"loading": state.loading},
|
||||
"btn btn-primary"
|
||||
),
|
||||
onClick=(ev: Event, n: VNode) => onLogInClick(ev, n, state)):
|
||||
text "Log in"
|
||||
button(class="btn",
|
||||
onClick=(ev: Event, n: VNode) =>
|
||||
(state.onSignUp(); state.shown = false)):
|
||||
text "Create account"
|
||||
|
||||
render(state.resetPasswordModal, recaptchaSiteKey)
|
||||
@@ -0,0 +1,58 @@
|
||||
import options
|
||||
import user
|
||||
|
||||
when defined(js):
|
||||
include karax/prelude
|
||||
import karax / [kdom]
|
||||
|
||||
import karaxutils, user, categorypicker, category
|
||||
|
||||
let buttons = [
|
||||
(name: "Latest", url: makeUri("/"), id: "latest-btn"),
|
||||
(name: "Categories", url: makeUri("/categories"), id: "categories-btn"),
|
||||
]
|
||||
|
||||
proc onSelectedCategoryChanged(oldCategory: Category, newCategory: Category) =
|
||||
let uri = makeUri("/c/" & $newCategory.id)
|
||||
navigateTo(uri)
|
||||
|
||||
type
|
||||
MainButtons* = ref object
|
||||
categoryPicker: CategoryPicker
|
||||
onCategoryChange*: CategoryChangeEvent
|
||||
|
||||
proc newMainButtons*(onCategoryChange: CategoryChangeEvent = onSelectedCategoryChanged): MainButtons =
|
||||
new result
|
||||
result.onCategoryChange = onCategoryChange
|
||||
result.categoryPicker = newCategoryPicker(
|
||||
onCategoryChange = proc (oldCategory, newCategory: Category) =
|
||||
onSelectedCategoryChanged(oldCategory, newCategory)
|
||||
result.onCategoryChange(oldCategory, newCategory)
|
||||
)
|
||||
|
||||
proc render*(state: MainButtons, currentUser: Option[User], categoryId = none(int)): VNode =
|
||||
result = buildHtml():
|
||||
section(class="navbar container grid-xl", id="main-buttons"):
|
||||
section(class="navbar-section"):
|
||||
#[tdiv(class="dropdown"):
|
||||
a(href="#", class="btn dropdown-toggle"):
|
||||
text "Filter "
|
||||
italic(class="fas fa-caret-down")
|
||||
ul(class="menu"):
|
||||
li: text "community"
|
||||
li: text "dev" ]#
|
||||
if categoryId.isSome:
|
||||
state.categoryPicker.selectedCategoryID = categoryId.get()
|
||||
render(state.categoryPicker, currentUser, compact=false)
|
||||
|
||||
for btn in buttons:
|
||||
let active = btn.url == window.location.href
|
||||
a(id=btn.id, href=btn.url):
|
||||
button(class=class({"btn-primary": active, "btn-link": not active}, "btn")):
|
||||
text btn.name
|
||||
section(class="navbar-section"):
|
||||
if currentUser.isSome():
|
||||
a(id="new-thread-btn", href=makeUri("/newthread"), onClick=anchorCB):
|
||||
button(class="btn btn-secondary"):
|
||||
italic(class="fas fa-plus")
|
||||
text " New Thread"
|
||||
@@ -0,0 +1,79 @@
|
||||
when defined(js):
|
||||
import sugar, httpcore, options, json
|
||||
import dom except Event
|
||||
import jsffi except `&`
|
||||
|
||||
include karax/prelude
|
||||
import karax / [kajax, kdom]
|
||||
|
||||
import error, replybox, threadlist, post, user
|
||||
import karaxutils, categorypicker
|
||||
|
||||
type
|
||||
NewThread* = ref object
|
||||
loading: bool
|
||||
error: Option[PostError]
|
||||
replyBox: ReplyBox
|
||||
subject: kstring
|
||||
categoryPicker: CategoryPicker
|
||||
|
||||
proc newNewThread*(): NewThread =
|
||||
NewThread(
|
||||
replyBox: newReplyBox(nil),
|
||||
subject: "",
|
||||
categoryPicker: newCategoryPicker()
|
||||
)
|
||||
|
||||
proc onSubjectChange(e: Event, n: VNode, state: NewThread) =
|
||||
state.subject = n.value
|
||||
|
||||
proc onCreatePost(httpStatus: int, response: kstring, state: NewThread) =
|
||||
postFinished:
|
||||
let j = parseJson($response)
|
||||
let response = to(j, array[2, int])
|
||||
navigateTo(renderPostUrl(response[0], response[1]))
|
||||
|
||||
proc onCreateClick(ev: Event, n: VNode, state: NewThread) =
|
||||
state.loading = true
|
||||
state.error = none[PostError]()
|
||||
|
||||
let uri = makeUri("newthread")
|
||||
# TODO: This is a hack, karax should support this.
|
||||
let formData = newFormData()
|
||||
let categoryID = state.categoryPicker.selectedCategoryID
|
||||
|
||||
formData.append("subject", state.subject)
|
||||
formData.append("msg", state.replyBox.getText())
|
||||
formData.append("categoryId", $categoryID)
|
||||
|
||||
ajaxPost(uri, @[], formData.to(cstring),
|
||||
(s: int, r: kstring) => onCreatePost(s, r, state))
|
||||
|
||||
proc render*(state: NewThread, currentUser: Option[User]): VNode =
|
||||
result = buildHtml():
|
||||
section(class="container grid-xl"):
|
||||
tdiv(id="new-thread"):
|
||||
tdiv(class="title"):
|
||||
p(): text "New Thread"
|
||||
tdiv(class="content"):
|
||||
input(id="thread-title", class="form-input", `type`="text", name="subject",
|
||||
placeholder="Type the title here",
|
||||
oninput=(e: Event, n: VNode) => onSubjectChange(e, n, state))
|
||||
if state.error.isSome():
|
||||
p(class="text-error"):
|
||||
text state.error.get().message
|
||||
tdiv():
|
||||
label(class="d-inline-block form-label"):
|
||||
text "Category"
|
||||
render(state.categoryPicker, currentUser, compact=false)
|
||||
renderContent(state.replyBox, none[Thread](), none[Post]())
|
||||
tdiv(class="footer"):
|
||||
|
||||
button(id="create-thread-btn",
|
||||
class=class(
|
||||
{"loading": state.loading},
|
||||
"btn btn-primary"
|
||||
),
|
||||
onClick=(ev: Event, n: VNode) =>
|
||||
(onCreateClick(ev, n, state))):
|
||||
text "Create thread"
|
||||
@@ -0,0 +1,67 @@
|
||||
import options
|
||||
|
||||
import user
|
||||
|
||||
type
|
||||
PostInfo* = object
|
||||
creation*: int64
|
||||
content*: string
|
||||
|
||||
Post* = ref object
|
||||
id*: int
|
||||
author*: User
|
||||
likes*: seq[User] ## Users that liked this post.
|
||||
seen*: bool ## Determines whether the current user saw this post.
|
||||
## I considered using a simple timestamp for each thread,
|
||||
## but that wouldn't work when a user navigates to the last
|
||||
## post in a thread for example.
|
||||
history*: seq[PostInfo] ## If the post was edited this will contain the
|
||||
## older versions of the post.
|
||||
info*: PostInfo
|
||||
moreBefore*: seq[int]
|
||||
replyingTo*: Option[PostLink]
|
||||
|
||||
PostLink* = object ## Used by profile
|
||||
creation*: int64
|
||||
topic*: string
|
||||
threadId*: int
|
||||
postId*: int
|
||||
author*: Option[User] ## Only used for `replyingTo`.
|
||||
|
||||
proc lastEdit*(post: Post): PostInfo =
|
||||
post.history[^1]
|
||||
|
||||
proc isModerated*(post: Post): bool =
|
||||
## Determines whether the specified post is under moderation
|
||||
## (i.e. whether the post is invisible to ordinary users).
|
||||
post.author.rank <= Moderated
|
||||
|
||||
proc isLikedBy*(post: Post, user: Option[User]): bool =
|
||||
## Determines whether the specified user has liked the post.
|
||||
if user.isNone(): return false
|
||||
|
||||
for u in post.likes:
|
||||
if u.name == user.get().name:
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
type
|
||||
Profile* = object
|
||||
user*: User
|
||||
joinTime*: int64
|
||||
threads*: seq[PostLink]
|
||||
posts*: seq[PostLink]
|
||||
postCount*: int
|
||||
threadCount*: int
|
||||
# Information that only admins should see.
|
||||
email*: Option[string]
|
||||
|
||||
when defined(js):
|
||||
import karaxutils, threadlist
|
||||
|
||||
proc renderPostUrl*(post: Post, thread: Thread): string =
|
||||
renderPostUrl(thread.id, post.id)
|
||||
|
||||
proc renderPostUrl*(link: PostLink): string =
|
||||
renderPostUrl(link.threadId, link.postId)
|
||||
@@ -0,0 +1,261 @@
|
||||
## Simple generic button that can be clicked to make a post request.
|
||||
## The button will show a loading indicator and a tick on success.
|
||||
##
|
||||
## Used for password reset emails.
|
||||
|
||||
import options, httpcore, json, sugar, sequtils, strutils
|
||||
when defined(js):
|
||||
include karax/prelude
|
||||
import karax/[kajax, kdom]
|
||||
import jsffi except `&`
|
||||
|
||||
import error, karaxutils, post, user, threadlist
|
||||
|
||||
type
|
||||
PostButton* = ref object
|
||||
uri, title, icon: string
|
||||
formData: FormData
|
||||
error: Option[PostError]
|
||||
loading: bool
|
||||
posted: bool
|
||||
|
||||
proc newPostButton*(uri: string, formData: FormData,
|
||||
title: string, icon: string): PostButton =
|
||||
PostButton(
|
||||
uri: uri,
|
||||
formData: formData,
|
||||
title: title,
|
||||
icon: icon
|
||||
)
|
||||
|
||||
proc newResetPasswordButton*(username: string): PostButton =
|
||||
var formData = newFormData()
|
||||
formData.append("email", username)
|
||||
result = newPostButton(
|
||||
makeUri("/sendResetPassword"),
|
||||
formData,
|
||||
"Send password reset email",
|
||||
"fas fa-envelope",
|
||||
)
|
||||
|
||||
proc onPost(httpStatus: int, response: kstring, state: PostButton) =
|
||||
postFinished:
|
||||
discard
|
||||
|
||||
proc onClick(ev: Event, n: VNode, state: PostButton) =
|
||||
if state.loading or state.posted: return
|
||||
|
||||
state.loading = true
|
||||
state.posted = true
|
||||
state.error = none[PostError]()
|
||||
|
||||
# TODO: This is a hack, karax should support this.
|
||||
ajaxPost(state.uri, @[], cast[cstring](state.formData),
|
||||
(s: int, r: kstring) => onPost(s, r, state))
|
||||
|
||||
ev.preventDefault()
|
||||
|
||||
proc render*(state: PostButton, disabled: bool): VNode =
|
||||
result = buildHtml(tdiv()):
|
||||
button(class=class({
|
||||
"loading": state.loading,
|
||||
"disabled": disabled
|
||||
},
|
||||
"btn btn-secondary"
|
||||
),
|
||||
`type`="button",
|
||||
onClick=(e: Event, n: VNode) => (onClick(e, n, state))):
|
||||
if state.posted:
|
||||
if state.error.isNone():
|
||||
italic(class="fas fa-check")
|
||||
else:
|
||||
italic(class="fas fa-times")
|
||||
else:
|
||||
italic(class=state.icon)
|
||||
text " " & state.title
|
||||
|
||||
if state.error.isSome():
|
||||
p(class="text-error"):
|
||||
text state.error.get().message
|
||||
|
||||
|
||||
type
|
||||
LikeButton* = ref object
|
||||
error: Option[PostError]
|
||||
loading: bool
|
||||
|
||||
proc newLikeButton*(): LikeButton =
|
||||
LikeButton()
|
||||
|
||||
proc onPost(httpStatus: int, response: kstring, state: LikeButton,
|
||||
post: Post, user: User) =
|
||||
postFinished:
|
||||
if post.isLikedBy(some(user)):
|
||||
var newLikes: seq[User] = @[]
|
||||
for like in post.likes:
|
||||
if like.name != user.name:
|
||||
newLikes.add(like)
|
||||
post.likes = newLikes
|
||||
else:
|
||||
post.likes.add(user)
|
||||
|
||||
proc onClick(ev: Event, n: VNode, state: LikeButton, post: Post,
|
||||
currentUser: Option[User]) =
|
||||
if state.loading: return
|
||||
if currentUser.isNone():
|
||||
state.error = some[PostError](PostError(message: "Not logged in."))
|
||||
return
|
||||
|
||||
state.loading = true
|
||||
state.error = none[PostError]()
|
||||
|
||||
# TODO: This is a hack, karax should support this.
|
||||
var formData = newFormData()
|
||||
formData.append("id", $post.id)
|
||||
let uri =
|
||||
if post.isLikedBy(currentUser):
|
||||
makeUri("/unlike")
|
||||
else:
|
||||
makeUri("/like")
|
||||
ajaxPost(uri, @[], formData.to(cstring),
|
||||
(s: int, r: kstring) =>
|
||||
onPost(s, r, state, post, currentUser.get()))
|
||||
|
||||
ev.preventDefault()
|
||||
|
||||
proc render*(state: LikeButton, post: Post,
|
||||
currentUser: Option[User]): VNode =
|
||||
|
||||
let liked = isLikedBy(post, currentUser)
|
||||
let tooltip =
|
||||
if state.error.isSome(): state.error.get().message
|
||||
else: ""
|
||||
|
||||
result = buildHtml():
|
||||
tdiv(class="like-button"):
|
||||
button(class=class({"tooltip": state.error.isSome()}, "btn"),
|
||||
onClick=(e: Event, n: VNode) =>
|
||||
(onClick(e, n, state, post, currentUser)),
|
||||
"data-tooltip"=tooltip,
|
||||
onmouseleave=(e: Event, n: VNode) =>
|
||||
(state.error = none[PostError]())):
|
||||
if post.likes.len > 0:
|
||||
let names = post.likes.map(x => x.name).join(", ")
|
||||
span(class="like-count tooltip", "data-tooltip"=names):
|
||||
text $post.likes.len
|
||||
|
||||
italic(class=class({"far": not liked, "fas": liked}, "fa-heart"))
|
||||
|
||||
type
|
||||
LockButton* = ref object
|
||||
error: Option[PostError]
|
||||
loading: bool
|
||||
|
||||
proc newLockButton*(): LockButton =
|
||||
LockButton()
|
||||
|
||||
proc onPost(httpStatus: int, response: kstring, state: LockButton,
|
||||
thread: var Thread) =
|
||||
postFinished:
|
||||
thread.isLocked = not thread.isLocked
|
||||
|
||||
proc onLockClick(ev: Event, n: VNode, state: LockButton, thread: var Thread) =
|
||||
if state.loading: return
|
||||
|
||||
state.loading = true
|
||||
state.error = none[PostError]()
|
||||
|
||||
# TODO: This is a hack, karax should support this.
|
||||
var formData = newFormData()
|
||||
formData.append("id", $thread.id)
|
||||
let uri =
|
||||
if thread.isLocked:
|
||||
makeUri("/unlock")
|
||||
else:
|
||||
makeUri("/lock")
|
||||
ajaxPost(uri, @[], formData.to(cstring),
|
||||
(s: int, r: kstring) =>
|
||||
onPost(s, r, state, thread))
|
||||
|
||||
ev.preventDefault()
|
||||
|
||||
proc render*(state: LockButton, thread: var Thread,
|
||||
currentUser: Option[User]): VNode =
|
||||
if currentUser.isNone() or
|
||||
currentUser.get().rank < Moderator:
|
||||
return buildHtml(tdiv())
|
||||
|
||||
let tooltip =
|
||||
if state.error.isSome(): state.error.get().message
|
||||
else: ""
|
||||
|
||||
result = buildHtml():
|
||||
button(class="btn btn-secondary", id="lock-btn",
|
||||
onClick=(e: Event, n: VNode) =>
|
||||
onLockClick(e, n, state, thread),
|
||||
"data-tooltip"=tooltip,
|
||||
onmouseleave=(e: Event, n: VNode) =>
|
||||
(state.error = none[PostError]())):
|
||||
if thread.isLocked:
|
||||
italic(class="fas fa-unlock-alt")
|
||||
text " Unlock Thread"
|
||||
else:
|
||||
italic(class="fas fa-lock")
|
||||
text " Lock Thread"
|
||||
|
||||
type
|
||||
PinButton* = ref object
|
||||
error: Option[PostError]
|
||||
loading: bool
|
||||
|
||||
proc newPinButton*(): PinButton =
|
||||
PinButton()
|
||||
|
||||
proc onPost(httpStatus: int, response: kstring, state: PinButton,
|
||||
thread: var Thread) =
|
||||
postFinished:
|
||||
thread.isPinned = not thread.isPinned
|
||||
|
||||
proc onPinClick(ev: Event, n: VNode, state: PinButton, thread: var Thread) =
|
||||
if state.loading: return
|
||||
|
||||
state.loading = true
|
||||
state.error = none[PostError]()
|
||||
|
||||
# Same as LockButton so the following is still a hack and karax should support this.
|
||||
var formData = newFormData()
|
||||
formData.append("id", $thread.id)
|
||||
let uri =
|
||||
if thread.isPinned:
|
||||
makeUri("/unpin")
|
||||
else:
|
||||
makeUri("/pin")
|
||||
ajaxPost(uri, @[], formData.to(cstring),
|
||||
(s: int, r: kstring) => onPost(s, r, state, thread))
|
||||
|
||||
ev.preventDefault()
|
||||
|
||||
proc render*(state: PinButton, thread: var Thread,
|
||||
currentUser: Option[User]): VNode =
|
||||
if currentUser.isNone() or
|
||||
currentUser.get().rank < Moderator:
|
||||
return buildHtml(tdiv())
|
||||
|
||||
let tooltip =
|
||||
if state.error.isSome(): state.error.get().message
|
||||
else: ""
|
||||
|
||||
result = buildHtml():
|
||||
button(class="btn btn-secondary", id="pin-btn",
|
||||
onClick=(e: Event, n: VNode) =>
|
||||
onPinClick(e, n, state, thread),
|
||||
"data-tooltip"=tooltip,
|
||||
onmouseleave=(e: Event, n: VNode) =>
|
||||
(state.error = none[PostError]())):
|
||||
if thread.isPinned:
|
||||
italic(class="fas fa-thumbtack")
|
||||
text " Unpin Thread"
|
||||
else:
|
||||
italic(class="fas fa-thumbtack")
|
||||
text " Pin Thread"
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
|
||||
import system except Thread
|
||||
import options, json, times, httpcore, sugar, strutils
|
||||
import sequtils
|
||||
|
||||
import threadlist, category, post, user
|
||||
type
|
||||
|
||||
PostList* = ref object
|
||||
thread*: Thread
|
||||
history*: seq[Thread] ## If the thread was edited this will contain the
|
||||
## older versions of the thread (title/category
|
||||
## changes). TODO
|
||||
posts*: seq[Post]
|
||||
|
||||
when defined(js):
|
||||
from dom import document
|
||||
import jsffi except `&`
|
||||
|
||||
include karax/prelude
|
||||
import karax / [kajax, kdom]
|
||||
|
||||
import karaxutils, error, replybox, editbox, postbutton, delete
|
||||
import categorypicker
|
||||
|
||||
type
|
||||
State = ref object
|
||||
list: Option[PostList]
|
||||
loading: bool
|
||||
status: HttpCode
|
||||
error: Option[PostError]
|
||||
replyingTo: Option[Post]
|
||||
replyBox: ReplyBox
|
||||
editing: Option[Post] ## If in edit mode, this contains the post.
|
||||
editBox: EditBox
|
||||
likeButton: LikeButton
|
||||
deleteModal: DeleteModal
|
||||
lockButton: LockButton
|
||||
pinButton: PinButton
|
||||
categoryPicker: CategoryPicker
|
||||
|
||||
proc onReplyPosted(id: int)
|
||||
proc onCategoryChanged(oldCategory: Category, newCategory: Category)
|
||||
proc onEditPosted(id: int, content: string, subject: Option[string])
|
||||
proc onEditCancelled()
|
||||
proc onDeletePost(post: Post)
|
||||
proc onDeleteThread(thread: Thread)
|
||||
proc newState(): State =
|
||||
State(
|
||||
list: none[PostList](),
|
||||
loading: false,
|
||||
status: Http200,
|
||||
error: none[PostError](),
|
||||
replyingTo: none[Post](),
|
||||
replyBox: newReplyBox(onReplyPosted),
|
||||
editBox: newEditBox(onEditPosted, onEditCancelled),
|
||||
likeButton: newLikeButton(),
|
||||
deleteModal: newDeleteModal(onDeletePost, onDeleteThread, nil),
|
||||
lockButton: newLockButton(),
|
||||
pinButton: newPinButton(),
|
||||
categoryPicker: newCategoryPicker(onCategoryChanged)
|
||||
)
|
||||
|
||||
var
|
||||
state = newState()
|
||||
|
||||
proc onCategoryPost(httpStatus: int, response: kstring, state: State) =
|
||||
state.loading = false
|
||||
postFinished:
|
||||
discard
|
||||
# TODO: show success message
|
||||
|
||||
proc onCategoryChanged(oldCategory: Category, newCategory: Category) =
|
||||
let uri = makeUri("/updateThread")
|
||||
|
||||
let formData = newFormData()
|
||||
formData.append("threadId", $state.list.get().thread.id)
|
||||
formData.append("category", $newCategory.id)
|
||||
|
||||
state.loading = true
|
||||
|
||||
ajaxPost(uri, @[], formData.to(cstring),
|
||||
(s: int, r: kstring) => onCategoryPost(s, r, state))
|
||||
|
||||
proc onPostList(httpStatus: int, response: kstring, postId: Option[int]) =
|
||||
state.loading = false
|
||||
state.status = httpStatus.HttpCode
|
||||
if state.status != Http200: return
|
||||
|
||||
let parsed = parseJson($response)
|
||||
let list = to(parsed, PostList)
|
||||
|
||||
state.list = some(list)
|
||||
|
||||
dom.document.title = list.thread.topic & " - " & dom.document.title
|
||||
state.categoryPicker.select(list.thread.category.id)
|
||||
|
||||
# The anchor should be jumped to once all the posts have been loaded.
|
||||
if postId.isSome():
|
||||
discard setTimeout(
|
||||
() => (
|
||||
# Would have used scrollIntoView but then the `:target` selector
|
||||
# isn't activated.
|
||||
getVNodeById($postId.get()).dom.scrollIntoView()
|
||||
),
|
||||
100
|
||||
)
|
||||
|
||||
proc onMorePosts(httpStatus: int, response: kstring, start: int) =
|
||||
state.loading = false
|
||||
state.status = httpStatus.HttpCode
|
||||
if state.status != Http200: return
|
||||
|
||||
let parsed = parseJson($response)
|
||||
var list = to(parsed, seq[Post])
|
||||
|
||||
var idsLoaded: seq[int] = @[]
|
||||
for i in 0..<list.len:
|
||||
state.list.get().posts.insert(list[i], i+start)
|
||||
idsLoaded.add(list[i].id)
|
||||
|
||||
# Save a list of the IDs which have not yet been loaded into the top-most
|
||||
# post.
|
||||
let postIndex = start+list.len
|
||||
# The following check is necessary because we reuse this proc to load
|
||||
# a newly created post.
|
||||
if postIndex < state.list.get().posts.len:
|
||||
let post = state.list.get().posts[postIndex]
|
||||
var newPostIds: seq[int] = @[]
|
||||
for id in post.moreBefore:
|
||||
if id notin idsLoaded:
|
||||
newPostIds.add(id)
|
||||
post.moreBefore = newPostIds
|
||||
|
||||
proc loadMore(start: int, ids: seq[int]) =
|
||||
if state.loading: return
|
||||
|
||||
state.loading = true
|
||||
let uri = makeUri(
|
||||
"specific_posts.json",
|
||||
[("ids", $(%ids))]
|
||||
)
|
||||
ajaxGet(
|
||||
uri,
|
||||
@[],
|
||||
(s: int, r: kstring) => onMorePosts(s, r, start)
|
||||
)
|
||||
|
||||
proc onReplyPosted(id: int) =
|
||||
## Executed when a reply has been successfully posted.
|
||||
loadMore(state.list.get().posts.len, @[id])
|
||||
|
||||
proc onEditCancelled() = state.editing = none[Post]()
|
||||
|
||||
proc onEditPosted(id: int, content: string, subject: Option[string]) =
|
||||
## Executed when an edit has been successfully posted.
|
||||
state.editing = none[Post]()
|
||||
let list = state.list.get()
|
||||
for i in 0 ..< list.posts.len:
|
||||
if list.posts[i].id == id:
|
||||
list.posts[i].history.add(PostInfo(
|
||||
creation: getTime().toUnix(),
|
||||
content: content
|
||||
))
|
||||
break
|
||||
|
||||
proc onReplyClick(e: Event, n: VNode, p: Option[Post]) =
|
||||
state.replyingTo = p
|
||||
state.replyBox.show()
|
||||
|
||||
proc onEditClick(e: Event, n: VNode, p: Post) =
|
||||
state.editing = some(p)
|
||||
|
||||
# TODO: Ensure the edit box is as big as its content. Auto resize the
|
||||
# text area.
|
||||
|
||||
proc onDeletePost(post: Post) =
|
||||
state.list.get().posts.keepIf(
|
||||
x => x.id != post.id
|
||||
)
|
||||
|
||||
proc onDeleteThread(thread: Thread) =
|
||||
window.location.href = makeUri("/")
|
||||
|
||||
proc onDeleteClick(e: Event, n: VNode, p: Post) =
|
||||
let list = state.list.get()
|
||||
if list.posts[0].id == p.id:
|
||||
state.deleteModal.show(list.thread)
|
||||
else:
|
||||
state.deleteModal.show(p)
|
||||
|
||||
proc onLoadMore(ev: Event, n: VNode, start: int, post: Post) =
|
||||
loadMore(start, post.moreBefore) # TODO: Don't load all!
|
||||
|
||||
proc genLoadMore(post: Post, start: int): VNode =
|
||||
result = buildHtml():
|
||||
tdiv(class="information load-more-posts",
|
||||
onClick=(e: Event, n: VNode) => onLoadMore(e, n, start, post)):
|
||||
tdiv(class="information-icon"):
|
||||
italic(class="fas fa-comment-dots")
|
||||
tdiv(class="information-main"):
|
||||
if state.loading:
|
||||
tdiv(class="loading loading-lg")
|
||||
else:
|
||||
tdiv(class="information-title"):
|
||||
text "Load more posts "
|
||||
span(class="more-post-count"):
|
||||
text "(" & $post.moreBefore.len & ")"
|
||||
|
||||
proc genCategories(thread: Thread, currentUser: Option[User]): VNode =
|
||||
let loggedIn = currentUser.isSome()
|
||||
let authoredByUser =
|
||||
loggedIn and currentUser.get().name == thread.author.name
|
||||
let canChangeCategory =
|
||||
loggedIn and currentUser.get().rank in {Admin, Moderator}
|
||||
|
||||
result = buildHtml():
|
||||
tdiv():
|
||||
if authoredByUser or canChangeCategory:
|
||||
render(state.categoryPicker, currentUser, compact=false)
|
||||
else:
|
||||
render(thread.category)
|
||||
|
||||
proc genPostButtons(post: Post, currentUser: Option[User]): Vnode =
|
||||
let loggedIn = currentUser.isSome()
|
||||
let authoredByUser =
|
||||
loggedIn and currentUser.get().name == post.author.name
|
||||
let currentAdmin =
|
||||
currentUser.isSome() and currentUser.get().rank == Admin
|
||||
|
||||
# Don't show buttons if the post is being edited.
|
||||
if state.editing.isSome() and state.editing.get() == post:
|
||||
return buildHtml(tdiv())
|
||||
|
||||
result = buildHtml():
|
||||
tdiv(class="post-buttons"):
|
||||
if authoredByUser or currentAdmin:
|
||||
tdiv(class="edit-button", onClick=(e: Event, n: VNode) =>
|
||||
onEditClick(e, n, post)):
|
||||
button(class="btn"):
|
||||
italic(class="far fa-edit")
|
||||
tdiv(class="delete-button",
|
||||
onClick=(e: Event, n: VNode) => onDeleteClick(e, n, post)):
|
||||
button(class="btn"):
|
||||
italic(class="far fa-trash-alt")
|
||||
|
||||
render(state.likeButton, post, currentUser)
|
||||
|
||||
if loggedIn:
|
||||
tdiv(class="flag-button"):
|
||||
button(class="btn"):
|
||||
italic(class="far fa-flag")
|
||||
|
||||
tdiv(class="reply-button"):
|
||||
button(class="btn", onClick=(e: Event, n: VNode) =>
|
||||
onReplyClick(e, n, some(post))):
|
||||
italic(class="fas fa-reply")
|
||||
text " Reply"
|
||||
|
||||
proc genPost(
|
||||
post: Post, thread: Thread, currentUser: Option[User], highlight: bool
|
||||
): VNode =
|
||||
let postCopy = post # TODO: Another workaround here, closure capture :(
|
||||
|
||||
let originalPost = thread.author == post.author
|
||||
|
||||
result = buildHtml():
|
||||
tdiv(class=class({"highlight": highlight, "original-post": originalPost}, "post"),
|
||||
id = $post.id):
|
||||
tdiv(class="post-icon"):
|
||||
render(post.author, "post-avatar")
|
||||
tdiv(class="post-main"):
|
||||
tdiv(class="post-title"):
|
||||
tdiv(class="post-username"):
|
||||
text post.author.name
|
||||
renderUserRank(post.author)
|
||||
tdiv(class="post-metadata"):
|
||||
if post.replyingTo.isSome():
|
||||
let replyingTo = post.replyingTo.get()
|
||||
tdiv(class="post-replyingTo"):
|
||||
a(href=renderPostUrl(replyingTo)):
|
||||
italic(class="fas fa-reply")
|
||||
renderUserMention(replyingTo.author.get())
|
||||
if post.history.len > 0:
|
||||
let title = post.lastEdit.creation.fromUnix().local.
|
||||
format("'Last modified' MMM d, yyyy HH:mm")
|
||||
tdiv(class="post-history", title=title):
|
||||
span(class="edit-count"):
|
||||
text $post.history.len
|
||||
italic(class="fas fa-pencil-alt")
|
||||
|
||||
let title = post.info.creation.fromUnix().local.
|
||||
format("MMM d, yyyy HH:mm")
|
||||
a(href=renderPostUrl(post, thread), title=title):
|
||||
text renderActivity(post.info.creation)
|
||||
tdiv(class="post-content"):
|
||||
if state.editing.isSome() and state.editing.get() == post:
|
||||
render(state.editBox, postCopy)
|
||||
else:
|
||||
let content =
|
||||
if post.history.len > 0:
|
||||
post.lastEdit.content
|
||||
else:
|
||||
post.info.content
|
||||
verbatim(content)
|
||||
|
||||
genPostButtons(postCopy, currentUser)
|
||||
|
||||
proc genTimePassed(prevPost: Post, post: Option[Post], last: bool): VNode =
|
||||
var latestTime =
|
||||
if post.isSome: post.get().info.creation.fromUnix()
|
||||
else: getTime()
|
||||
|
||||
# TODO: Use `between` once it's merged into stdlib.
|
||||
let
|
||||
tmpl =
|
||||
if last: [
|
||||
"A long time since last reply",
|
||||
"$1 year since last reply",
|
||||
"$1 years since last reply",
|
||||
"$1 month since last reply",
|
||||
"$1 months since last reply",
|
||||
]
|
||||
else: [
|
||||
"Some time later",
|
||||
"$1 year later", "$1 years later",
|
||||
"$1 month later", "$1 months later"
|
||||
]
|
||||
var diffStr = tmpl[0]
|
||||
let diff = latestTime - prevPost.info.creation.fromUnix()
|
||||
if diff.inWeeks > 48:
|
||||
let years = diff.inWeeks div 48
|
||||
diffStr =
|
||||
(if years == 1: tmpl[1] else: tmpl[2]) % $years
|
||||
elif diff.inWeeks > 4:
|
||||
let months = diff.inWeeks div 4
|
||||
diffStr =
|
||||
(if months == 1: tmpl[3] else: tmpl[4]) % $months
|
||||
else:
|
||||
return buildHtml(tdiv())
|
||||
|
||||
# PROTIP: Good thread ID to test this with is: 1267.
|
||||
result = buildHtml():
|
||||
tdiv(class="information time-passed"):
|
||||
tdiv(class="information-icon"):
|
||||
italic(class="fas fa-clock")
|
||||
tdiv(class="information-main"):
|
||||
tdiv(class="information-title"):
|
||||
text diffStr
|
||||
|
||||
proc renderPostList*(threadId: int, postId: Option[int],
|
||||
currentUser: Option[User]): VNode =
|
||||
if state.list.isSome() and state.list.get().thread.id != threadId:
|
||||
state.list = none[PostList]()
|
||||
state.status = Http200
|
||||
|
||||
if state.status != Http200:
|
||||
return renderError("Couldn't retrieve posts.", state.status)
|
||||
|
||||
if state.list.isNone:
|
||||
var params = @[("id", $threadId)]
|
||||
if postId.isSome():
|
||||
params.add(("anchor", $postId.get()))
|
||||
let uri = makeUri("posts.json", params)
|
||||
if not state.loading:
|
||||
state.loading = true
|
||||
ajaxGet(uri, @[], (s: int, r: kstring) => onPostList(s, r, postId))
|
||||
|
||||
return buildHtml(tdiv(class="loading loading-lg"))
|
||||
|
||||
let list = state.list.get()
|
||||
result = buildHtml():
|
||||
section(class="container grid-xl"):
|
||||
tdiv(id="thread-title", class="title"):
|
||||
if state.error.isSome():
|
||||
span(class="text-error"):
|
||||
text state.error.get().message
|
||||
p(class="title-text"): text list.thread.topic
|
||||
if list.thread.isLocked:
|
||||
italic(class="fas fa-lock fa-xs",
|
||||
title="Thread cannot be replied to")
|
||||
text "Locked"
|
||||
if list.thread.isModerated:
|
||||
italic(class="fas fa-eye-slash fa-xs",
|
||||
title="Thread is moderated")
|
||||
text "Moderated"
|
||||
if list.thread.isSolved:
|
||||
italic(class="fas fa-check-square fa-xs",
|
||||
title="Thread has a solution")
|
||||
text "Solved"
|
||||
genCategories(list.thread, currentUser)
|
||||
tdiv(class="posts"):
|
||||
var prevPost: Option[Post] = none[Post]()
|
||||
for i, post in list.posts:
|
||||
if not post.visibleTo(currentUser): continue
|
||||
|
||||
if prevPost.isSome:
|
||||
genTimePassed(prevPost.get(), some(post), false)
|
||||
if post.moreBefore.len > 0:
|
||||
genLoadMore(post, i)
|
||||
let highlight = postId.isSome() and postId.get() == post.id
|
||||
genPost(post, list.thread, currentUser, highlight)
|
||||
prevPost = some(post)
|
||||
|
||||
if prevPost.isSome:
|
||||
genTimePassed(prevPost.get(), none[Post](), true)
|
||||
|
||||
tdiv(id="thread-buttons"):
|
||||
button(class="btn btn-secondary",
|
||||
onClick=(e: Event, n: VNode) =>
|
||||
onReplyClick(e, n, none[Post]())):
|
||||
italic(class="fas fa-reply")
|
||||
text " Reply"
|
||||
|
||||
render(state.lockButton, list.thread, currentUser)
|
||||
render(state.pinButton, list.thread, currentUser)
|
||||
|
||||
render(state.replyBox, list.thread, state.replyingTo, false)
|
||||
|
||||
render(state.deleteModal)
|
||||
@@ -0,0 +1,150 @@
|
||||
import options, httpcore, json, sugar, times, strutils
|
||||
|
||||
import threadlist, post, error, user
|
||||
|
||||
when defined(js):
|
||||
from dom import document
|
||||
include karax/prelude
|
||||
import karax/[kajax, kdom]
|
||||
import karaxutils, profilesettings
|
||||
|
||||
type
|
||||
ProfileTab* = enum
|
||||
Overview, Settings
|
||||
|
||||
ProfileState* = ref object
|
||||
profile: Option[Profile]
|
||||
settings: Option[ProfileSettings]
|
||||
currentTab: ProfileTab
|
||||
loading: bool
|
||||
status: HttpCode
|
||||
|
||||
proc newProfileState*(): ProfileState =
|
||||
ProfileState(
|
||||
loading: false,
|
||||
status: Http200,
|
||||
currentTab: Overview
|
||||
)
|
||||
|
||||
proc onProfile(httpStatus: int, response: kstring, state: ProfileState) =
|
||||
# TODO: Try to abstract these.
|
||||
state.loading = false
|
||||
state.status = httpStatus.HttpCode
|
||||
if state.status != Http200: return
|
||||
|
||||
let parsed = parseJson($response)
|
||||
let profile = to(parsed, Profile)
|
||||
|
||||
state.profile = some(profile)
|
||||
state.settings = some(newProfileSettings(profile))
|
||||
|
||||
dom.document.title = profile.user.name & " - " & dom.document.title
|
||||
|
||||
proc genPostLink(link: PostLink): VNode =
|
||||
let url = renderPostUrl(link)
|
||||
result = buildHtml():
|
||||
tdiv(class="profile-post"):
|
||||
tdiv(class="profile-post-main"):
|
||||
tdiv(class="profile-post-title"):
|
||||
a(href=url):
|
||||
text link.topic
|
||||
tdiv(class="profile-post-time"):
|
||||
let title = link.creation.fromUnix().local.
|
||||
format("MMM d, yyyy HH:mm")
|
||||
p(title=title):
|
||||
text renderActivity(link.creation)
|
||||
|
||||
proc render*(
|
||||
state: ProfileState,
|
||||
username: kstring,
|
||||
currentUser: Option[User]
|
||||
): VNode =
|
||||
|
||||
if state.profile.isSome() and state.profile.get().user.name != username:
|
||||
state.profile = none[Profile]()
|
||||
state.status = Http200
|
||||
|
||||
if state.status != Http200:
|
||||
return renderError("Couldn't retrieve profile.", state.status)
|
||||
|
||||
if state.profile.isNone:
|
||||
if not state.loading:
|
||||
state.loading = true
|
||||
let uri = makeUri("profile.json", ("username", $username))
|
||||
ajaxGet(uri, @[], (s: int, r: kstring) => onProfile(s, r, state))
|
||||
|
||||
return buildHtml(tdiv(class="loading loading-lg"))
|
||||
|
||||
let profile = state.profile.get()
|
||||
result = buildHtml():
|
||||
section(class="container grid-xl"):
|
||||
tdiv(class="profile"):
|
||||
tdiv(class="profile-icon"):
|
||||
render(profile.user, "profile-avatar")
|
||||
tdiv(class="profile-content"):
|
||||
h2(class="profile-title"):
|
||||
text profile.user.name
|
||||
|
||||
tdiv(class="profile-stats"):
|
||||
dl():
|
||||
dt(text "Joined")
|
||||
dd(text threadlist.renderActivity(profile.joinTime))
|
||||
if profile.posts.len > 0:
|
||||
dt(text "Last Post")
|
||||
dd(text renderActivity(profile.posts[0].creation))
|
||||
dt(text "Last Online")
|
||||
dd(text renderActivity(profile.user.lastOnline))
|
||||
dt(text "Posts")
|
||||
dd():
|
||||
if profile.postCount > 999:
|
||||
text $(profile.postCount / 1000) & "k"
|
||||
else:
|
||||
text $profile.postCount
|
||||
dt(text "Threads")
|
||||
dd():
|
||||
if profile.threadCount > 999:
|
||||
text $(profile.threadCount / 1000) & "k"
|
||||
else:
|
||||
text $profile.threadCount
|
||||
dt(text "Rank")
|
||||
dd(text $profile.user.rank)
|
||||
|
||||
if currentUser.isSome():
|
||||
let user = currentUser.get()
|
||||
if user.name == profile.user.name or user.rank >= Moderator:
|
||||
ul(class="tab profile-tabs"):
|
||||
li(class=class(
|
||||
{"active": state.currentTab == Overview},
|
||||
"tab-item"
|
||||
),
|
||||
onClick=(e: Event, n: VNode) => (state.currentTab = Overview)
|
||||
):
|
||||
a(id="overview-tab", class="c-hand"):
|
||||
text "Overview"
|
||||
li(class=class(
|
||||
{"active": state.currentTab == Settings},
|
||||
"tab-item"
|
||||
),
|
||||
onClick=(e: Event, n: VNode) => (state.currentTab = Settings)
|
||||
):
|
||||
a(id="settings-tab", class="c-hand"):
|
||||
italic(class="fas fa-cog")
|
||||
text " Settings"
|
||||
|
||||
case state.currentTab
|
||||
of Overview:
|
||||
if profile.posts.len > 0 or profile.threads.len > 0:
|
||||
tdiv(class="columns"):
|
||||
tdiv(class="column col-6"):
|
||||
h4(text "Latest Posts")
|
||||
tdiv(class="posts"):
|
||||
for post in profile.posts:
|
||||
genPostLink(post)
|
||||
tdiv(class="column col-6"):
|
||||
h4(text "Latest Threads")
|
||||
tdiv(class="posts"):
|
||||
for thread in profile.threads:
|
||||
genPostLink(thread)
|
||||
of Settings:
|
||||
if state.settings.isSome():
|
||||
render(state.settings.get(), currentUser)
|
||||
@@ -0,0 +1,206 @@
|
||||
when defined(js):
|
||||
import httpcore, options, sugar, json, strutils, strformat
|
||||
import jsffi except `&`
|
||||
|
||||
include karax/prelude
|
||||
import karax/[kajax, kdom]
|
||||
|
||||
import post, karaxutils, postbutton, error, delete, user
|
||||
|
||||
type
|
||||
ProfileSettings* = ref object
|
||||
loading: bool
|
||||
status: HttpCode
|
||||
error: Option[PostError]
|
||||
email: kstring
|
||||
rank: Rank
|
||||
deleteModal: DeleteModal
|
||||
resetPassword: PostButton
|
||||
profile: Profile
|
||||
|
||||
proc onUserDelete(user: User) =
|
||||
window.location.href = makeUri("/")
|
||||
|
||||
proc resetSettings(state: ProfileSettings) =
|
||||
let profile = state.profile
|
||||
if profile.email.isSome():
|
||||
state.email = profile.email.get()
|
||||
else:
|
||||
state.email = ""
|
||||
state.rank = profile.user.rank
|
||||
|
||||
state.error = none[PostError]()
|
||||
|
||||
proc newProfileSettings*(profile: Profile): ProfileSettings =
|
||||
result = ProfileSettings(
|
||||
status: Http200,
|
||||
deleteModal: newDeleteModal(nil, nil, onUserDelete),
|
||||
resetPassword: newResetPasswordButton(profile.user.name),
|
||||
profile: profile
|
||||
)
|
||||
resetSettings(result)
|
||||
|
||||
proc onProfilePost(httpStatus: int, response: kstring,
|
||||
state: ProfileSettings) =
|
||||
postFinished:
|
||||
state.profile.email = some($state.email)
|
||||
state.profile.user.rank = state.rank
|
||||
|
||||
proc onEmailChange(event: Event, node: VNode, state: ProfileSettings) =
|
||||
state.email = node.value
|
||||
|
||||
if state.profile.user.rank != Admin:
|
||||
if state.email != state.profile.email.get():
|
||||
state.rank = EmailUnconfirmed
|
||||
else:
|
||||
state.rank = state.profile.user.rank
|
||||
|
||||
proc onRankChange(event: Event, node: VNode, state: ProfileSettings) =
|
||||
state.rank = parseEnum[Rank]($node.value)
|
||||
|
||||
proc save(state: ProfileSettings) =
|
||||
if state.loading:
|
||||
return
|
||||
state.loading = true
|
||||
state.error = none[PostError]()
|
||||
|
||||
let formData = newFormData()
|
||||
formData.append("email", state.email)
|
||||
formData.append("rank", $state.rank)
|
||||
formData.append("username", $state.profile.user.name)
|
||||
let uri = makeUri("/saveProfile")
|
||||
ajaxPost(uri, @[], formData.to(cstring),
|
||||
(s: int, r: kstring) => onProfilePost(s, r, state))
|
||||
|
||||
proc needsSave(state: ProfileSettings): bool =
|
||||
if state.profile.email.isSome():
|
||||
result = state.email != state.profile.email.get()
|
||||
result = result or state.rank != state.profile.user.rank
|
||||
|
||||
proc render*(state: ProfileSettings,
|
||||
currentUser: Option[User]): VNode =
|
||||
let canEditRank = currentUser.isSome() and
|
||||
currentUser.get().rank > state.profile.user.rank
|
||||
let canResetPassword = state.profile.user.rank > EmailUnconfirmed
|
||||
|
||||
let rankSelect = buildHtml(tdiv()):
|
||||
if canEditRank:
|
||||
select(id="rank-field",
|
||||
class="form-select", value = $state.rank,
|
||||
onchange=(e: Event, n: VNode) => onRankChange(e, n, state)):
|
||||
for r in Rank:
|
||||
option(text $r, id="rank-" & toLowerAscii($r))
|
||||
p(class="form-input-hint text-warning"):
|
||||
text "You can modify anyone's rank. Remember: with " &
|
||||
"great power comes great responsibility."
|
||||
else:
|
||||
input(id="rank-field", class="form-input",
|
||||
`type`="text", disabled="", value = $state.rank)
|
||||
p(class="form-input-hint"):
|
||||
text "Your rank determines the actions you can perform " &
|
||||
"on the forum."
|
||||
case state.rank:
|
||||
of bannedRanks:
|
||||
p(class="form-input-hint text-warning"):
|
||||
text "Your account was banned."
|
||||
of EmailUnconfirmed:
|
||||
p(class="form-input-hint text-warning"):
|
||||
text "You cannot post until you confirm your email."
|
||||
of Moderated:
|
||||
p(class="form-input-hint text-warning"):
|
||||
text "Your account is under moderation. This is a spam prevention "&
|
||||
"measure. You can write posts but only moderators and admins "&
|
||||
"will see them until your account is verified by them."
|
||||
else:
|
||||
discard
|
||||
|
||||
result = buildHtml():
|
||||
tdiv(class="columns"):
|
||||
tdiv(class="column col-6"):
|
||||
form(class="form-horizontal"):
|
||||
tdiv(class="form-group"):
|
||||
tdiv(class="col-3 col-sm-12"):
|
||||
label(class="form-label"):
|
||||
text "Username"
|
||||
tdiv(class="col-9 col-sm-12"):
|
||||
input(class="form-input",
|
||||
`type`="text",
|
||||
value=state.profile.user.name,
|
||||
disabled="")
|
||||
p(class="form-input-hint"):
|
||||
text fmt("Users can refer to you by writing" &
|
||||
" @{state.profile.user.name} in their posts.")
|
||||
if state.profile.email.isSome():
|
||||
tdiv(class="form-group"):
|
||||
tdiv(class="col-3 col-sm-12"):
|
||||
label(class="form-label"):
|
||||
text "Email"
|
||||
tdiv(class="col-9 col-sm-12"):
|
||||
input(id="email-input", class="form-input",
|
||||
`type`="text", value=state.email,
|
||||
oninput=(e: Event, n: VNode) =>
|
||||
onEmailChange(e, n, state)
|
||||
)
|
||||
p(class="form-input-hint"):
|
||||
text "Your avatar is linked to this email and can be " &
|
||||
"changed at "
|
||||
a(href="https://gravatar.com/emails"):
|
||||
text "gravatar.com"
|
||||
text ". Note that any changes to your email will " &
|
||||
"require email verification."
|
||||
tdiv(class="form-group"):
|
||||
tdiv(class="col-3 col-sm-12"):
|
||||
label(class="form-label"):
|
||||
text "Rank"
|
||||
tdiv(class="col-9 col-sm-12"):
|
||||
rankSelect
|
||||
tdiv(class="form-group"):
|
||||
tdiv(class="col-3 col-sm-12"):
|
||||
label(class="form-label"):
|
||||
text "Password"
|
||||
tdiv(class="col-9 col-sm-12"):
|
||||
render(state.resetPassword,
|
||||
disabled=not canResetPassword)
|
||||
tdiv(class="form-group"):
|
||||
tdiv(class="col-3 col-sm-12"):
|
||||
label(class="form-label"):
|
||||
text "Account"
|
||||
tdiv(class="col-9 col-sm-12"):
|
||||
button(id="delete-account-btn",
|
||||
class="btn btn-secondary", `type`="button",
|
||||
onClick=(e: Event, n: VNode) =>
|
||||
(state.deleteModal.show(state.profile.user))):
|
||||
italic(class="fas fa-times")
|
||||
text " Delete account"
|
||||
|
||||
tdiv(class="float-right"):
|
||||
if state.error.isSome():
|
||||
span(class="text-error"):
|
||||
text state.error.get().message
|
||||
|
||||
button(id="cancel-btn",
|
||||
class=class(
|
||||
{"disabled": not needsSave(state)}, "btn btn-link"
|
||||
),
|
||||
onClick=(e: Event, n: VNode) => (resetSettings(state))):
|
||||
text "Cancel"
|
||||
|
||||
button(id="save-btn",
|
||||
class=class(
|
||||
{"disabled": not needsSave(state)}, "btn btn-primary"
|
||||
),
|
||||
onClick=(e: Event, n: VNode) => save(state),
|
||||
id="save-btn"):
|
||||
italic(class="fas fa-save")
|
||||
text " Save"
|
||||
|
||||
render(state.deleteModal)
|
||||
|
||||
# TODO: I really should just be able to set the `value` attr.
|
||||
# TODO: This doesn't work when settings are reset for some reason.
|
||||
let rankField = getVNodeById("rank-field")
|
||||
if not rankField.isNil:
|
||||
rankField.setInputText($state.rank)
|
||||
let emailField = getVNodeById("email-field")
|
||||
if not emailField.isNil:
|
||||
emailField.setInputText($state.email)
|
||||
@@ -0,0 +1,167 @@
|
||||
when defined(js):
|
||||
import strformat, options, httpcore, json, sugar
|
||||
import jsffi except `&`
|
||||
|
||||
from dom import getElementById, scrollIntoView, setTimeout
|
||||
|
||||
include karax/prelude
|
||||
import karax / [vstyles, kajax, kdom]
|
||||
|
||||
import karaxutils, threadlist, post, error, user
|
||||
|
||||
type
|
||||
ReplyBox* = ref object
|
||||
shown: bool
|
||||
text: kstring
|
||||
preview: bool
|
||||
loading: bool
|
||||
error: Option[PostError]
|
||||
rendering: Option[kstring]
|
||||
onPost: proc (id: int)
|
||||
|
||||
proc newReplyBox*(onPost: proc (id: int)): ReplyBox =
|
||||
ReplyBox(
|
||||
text: "",
|
||||
onPost: onPost
|
||||
)
|
||||
|
||||
proc performScroll() =
|
||||
let replyBox = dom.document.getElementById("reply-box")
|
||||
replyBox.scrollIntoView()
|
||||
|
||||
proc show*(state: ReplyBox) =
|
||||
# Scroll to the reply box.
|
||||
if not state.shown:
|
||||
# TODO: It would be nice for Karax to give us an event when it renders
|
||||
# things. That way we can remove this crappy hack.
|
||||
discard dom.window.setTimeout(performScroll, 50)
|
||||
else:
|
||||
performScroll()
|
||||
|
||||
state.shown = true
|
||||
|
||||
proc getText*(state: ReplyBox): kstring = state.text
|
||||
proc setText*(state: ReplyBox, text: kstring) = state.text = text
|
||||
|
||||
proc onPreviewPost(httpStatus: int, response: kstring, state: ReplyBox) =
|
||||
postFinished:
|
||||
echo response
|
||||
state.rendering = some[kstring](response)
|
||||
|
||||
proc onPreviewClick(e: Event, n: VNode, state: ReplyBox) =
|
||||
state.preview = true
|
||||
state.loading = true
|
||||
state.error = none[PostError]()
|
||||
state.rendering = none[kstring]()
|
||||
|
||||
let formData = newFormData()
|
||||
formData.append("msg", state.text)
|
||||
let uri = makeUri("/preview")
|
||||
ajaxPost(uri, @[], formData.to(cstring),
|
||||
(s: int, r: kstring) => onPreviewPost(s, r, state))
|
||||
|
||||
proc onMessageClick(e: Event, n: VNode, state: ReplyBox) =
|
||||
state.preview = false
|
||||
state.error = none[PostError]()
|
||||
|
||||
proc onReplyPost(httpStatus: int, response: kstring, state: ReplyBox) =
|
||||
postFinished:
|
||||
state.text = ""
|
||||
state.shown = false
|
||||
state.onPost(parseJson($response).getInt())
|
||||
|
||||
proc onReplyClick(e: Event, n: VNode, state: ReplyBox,
|
||||
thread: Thread, replyingTo: Option[Post]) =
|
||||
state.loading = true
|
||||
state.error = none[PostError]()
|
||||
|
||||
let formData = newFormData()
|
||||
formData.append("msg", state.text)
|
||||
formData.append("threadId", $thread.id)
|
||||
if replyingTo.isSome:
|
||||
formData.append("replyingTo", $replyingTo.get().id)
|
||||
let uri = makeUri("/createPost")
|
||||
ajaxPost(uri, @[], formData.to(cstring),
|
||||
(s: int, r: kstring) => onReplyPost(s, r, state))
|
||||
|
||||
proc onCancelClick(e: Event, n: VNode, state: ReplyBox) =
|
||||
# TODO: Double check reply box contents and ask user whether to discard.
|
||||
state.shown = false
|
||||
|
||||
proc onChange(e: Event, n: VNode, state: ReplyBox) =
|
||||
# TODO: Please document this better in Karax.
|
||||
state.text = n.value
|
||||
|
||||
proc renderContent*(state: ReplyBox, thread: Option[Thread],
|
||||
post: Option[Post]): VNode =
|
||||
result = buildHtml():
|
||||
tdiv(class="panel"):
|
||||
tdiv(class="panel-nav"):
|
||||
ul(class="tab tab-block"):
|
||||
li(class=class({"active": not state.preview}, "tab-item"),
|
||||
onClick=(e: Event, n: VNode) =>
|
||||
onMessageClick(e, n, state)):
|
||||
a(class="c-hand"):
|
||||
text "Message"
|
||||
li(class=class({"active": state.preview}, "tab-item"),
|
||||
onClick=(e: Event, n: VNode) =>
|
||||
onPreviewClick(e, n, state)):
|
||||
a(class="c-hand"):
|
||||
text "Preview"
|
||||
tdiv(class="panel-body"):
|
||||
if state.preview:
|
||||
if state.loading:
|
||||
tdiv(class="loading")
|
||||
elif state.rendering.isSome():
|
||||
verbatim(state.rendering.get())
|
||||
else:
|
||||
textarea(id="reply-textarea",
|
||||
class="form-input post-text-area", rows="5",
|
||||
onChange=(e: Event, n: VNode) =>
|
||||
onChange(e, n, state),
|
||||
value=state.text)
|
||||
a(href=makeUri("/about/rst"), target="blank_"):
|
||||
text "Styling with RST is supported"
|
||||
|
||||
if state.error.isSome():
|
||||
span(class="text-error",
|
||||
style=style(StyleAttr.marginTop, "0.4rem")):
|
||||
text state.error.get().message
|
||||
|
||||
if thread.isSome:
|
||||
tdiv(class="panel-footer"):
|
||||
button(class=class(
|
||||
{"loading": state.loading},
|
||||
"btn btn-primary float-right"
|
||||
),
|
||||
onClick=(e: Event, n: VNode) =>
|
||||
onReplyClick(e, n, state, thread.get(), post)):
|
||||
text "Reply"
|
||||
button(class="btn btn-link float-right",
|
||||
onClick=(e: Event, n: VNode) =>
|
||||
onCancelClick(e, n, state)):
|
||||
text "Cancel"
|
||||
|
||||
proc render*(state: ReplyBox, thread: Thread, post: Option[Post],
|
||||
hasMore: bool): VNode =
|
||||
if not state.shown:
|
||||
return buildHtml(tdiv(id="reply-box"))
|
||||
|
||||
result = buildHtml():
|
||||
tdiv(class=class({"no-border": hasMore}, "information"), id="reply-box"):
|
||||
tdiv(class="information-icon"):
|
||||
italic(class="fas fa-reply")
|
||||
tdiv(class="information-main", style=style(StyleAttr.width, "100%")):
|
||||
tdiv(class="information-title"):
|
||||
if post.isNone:
|
||||
text fmt("Replying to \"{thread.topic}\"")
|
||||
else:
|
||||
text "Replying to "
|
||||
renderUserMention(post.get().author)
|
||||
tdiv(class="post-buttons",
|
||||
style=style(StyleAttr.marginTop, "-0.3rem")):
|
||||
a(href=renderPostUrl(post.get(), thread)):
|
||||
button(class="btn"):
|
||||
italic(class="fas fa-arrow-up")
|
||||
tdiv(class="information-content"):
|
||||
renderContent(state, some(thread), post)
|
||||
@@ -0,0 +1,156 @@
|
||||
when defined(js):
|
||||
import sugar, httpcore, options, json, uri
|
||||
import dom except Event, KeyboardEvent
|
||||
import jsffi except `&`
|
||||
|
||||
include karax/prelude
|
||||
import karax / [kajax, kdom]
|
||||
|
||||
import error
|
||||
import karaxutils
|
||||
|
||||
type
|
||||
ResetPassword* = ref object
|
||||
loading: bool
|
||||
status: HttpCode
|
||||
error: Option[PostError]
|
||||
newPassword: kstring
|
||||
|
||||
proc newResetPassword*(): ResetPassword =
|
||||
ResetPassword(
|
||||
status: Http200,
|
||||
newPassword: ""
|
||||
)
|
||||
|
||||
proc onPassChange(e: Event, n: VNode, state: ResetPassword) =
|
||||
state.newPassword = n.value
|
||||
|
||||
proc onPost(httpStatus: int, response: kstring, state: ResetPassword) =
|
||||
postFinished:
|
||||
navigateTo(makeUri("/resetPassword/success"))
|
||||
|
||||
proc onSetClick(
|
||||
ev: Event, n: VNode,
|
||||
state: ResetPassword
|
||||
) =
|
||||
state.loading = true
|
||||
state.error = none[PostError]()
|
||||
|
||||
let uri = makeUri("resetPassword", ("newPassword", encodeUrl($state.newPassword)))
|
||||
ajaxPost(uri, @[], "",
|
||||
(s: int, r: kstring) => onPost(s, r, state))
|
||||
|
||||
proc render*(state: ResetPassword): VNode =
|
||||
if state.loading:
|
||||
return buildHtml(tdiv(class="loading"))
|
||||
|
||||
result = buildHtml():
|
||||
section(class="container grid-xl"):
|
||||
tdiv(id="resetpassword"):
|
||||
tdiv(class="title"):
|
||||
p(): text "Reset Password"
|
||||
tdiv(class="content"):
|
||||
label(class="form-label", `for`="password"):
|
||||
text "Password"
|
||||
input(class="form-input", `type`="password", name="password",
|
||||
placeholder="Type your new password here",
|
||||
oninput=(e: Event, n: VNode) => onPassChange(e, n, state))
|
||||
if state.error.isSome():
|
||||
p(class="text-error"):
|
||||
text state.error.get().message
|
||||
tdiv(class="footer"):
|
||||
button(class=class(
|
||||
{"loading": state.loading},
|
||||
"btn btn-primary"
|
||||
),
|
||||
onClick=(ev: Event, n: VNode) =>
|
||||
(onSetClick(ev, n, state))):
|
||||
text "Set password"
|
||||
|
||||
|
||||
type
|
||||
ResetPasswordModal* = ref object
|
||||
shown: bool
|
||||
loading: bool
|
||||
error: Option[PostError]
|
||||
sent: bool
|
||||
|
||||
proc onPost(httpStatus: int, response: kstring, state: ResetPasswordModal) =
|
||||
postFinished:
|
||||
state.sent = true
|
||||
|
||||
proc onClick(ev: Event, n: VNode, state: ResetPasswordModal) =
|
||||
state.loading = true
|
||||
state.error = none[PostError]()
|
||||
|
||||
let uri = makeUri("sendResetPassword")
|
||||
let form = dom.document.getElementById("resetpassword-form")
|
||||
# TODO: This is a hack, karax should support this.
|
||||
let formData = newFormData(form)
|
||||
ajaxPost(uri, @[], formData.to(cstring),
|
||||
(s: int, r: kstring) => onPost(s, r, state))
|
||||
|
||||
ev.preventDefault()
|
||||
|
||||
proc onClose(ev: Event, n: VNode, state: ResetPasswordModal) =
|
||||
state.shown = false
|
||||
ev.preventDefault()
|
||||
|
||||
proc newResetPasswordModal*(): ResetPasswordModal =
|
||||
ResetPasswordModal(
|
||||
shown: false
|
||||
)
|
||||
|
||||
proc show*(state: ResetPasswordModal) =
|
||||
state.shown = true
|
||||
|
||||
proc onKeyDown(e: Event, n: VNode, state: ResetPasswordModal) =
|
||||
let event = cast[KeyboardEvent](e)
|
||||
if event.key == "Enter":
|
||||
onClick(e, n, state)
|
||||
|
||||
proc render*(state: ResetPasswordModal,
|
||||
recaptchaSiteKey: Option[string]): VNode =
|
||||
result = buildHtml():
|
||||
tdiv(class=class({"active": state.shown}, "modal"),
|
||||
id="resetpassword-modal"):
|
||||
a(href="", class="modal-overlay", "aria-label"="close",
|
||||
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
|
||||
tdiv(class="modal-container"):
|
||||
tdiv(class="modal-header"):
|
||||
a(href="", class="btn btn-clear float-right",
|
||||
"aria-label"="close",
|
||||
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
|
||||
tdiv(class="modal-title h5"):
|
||||
text "Reset your password"
|
||||
tdiv(class="modal-body"):
|
||||
tdiv(class="content"):
|
||||
form(id="resetpassword-form",
|
||||
onKeyDown=(ev: Event, n: VNode) => onKeyDown(ev, n, state)):
|
||||
genFormField(
|
||||
state.error,
|
||||
"email",
|
||||
"Enter your email or username and we will send you a " &
|
||||
"password reset email.",
|
||||
"text",
|
||||
true,
|
||||
placeholder="Username or email"
|
||||
)
|
||||
if recaptchaSiteKey.isSome:
|
||||
tdiv(id="recaptcha"):
|
||||
tdiv(class="g-recaptcha",
|
||||
"data-sitekey"=recaptchaSiteKey.get())
|
||||
script(src="https://www.google.com/recaptcha/api.js")
|
||||
tdiv(class="modal-footer"):
|
||||
if state.sent:
|
||||
span(class="text-success"):
|
||||
italic(class="fas fa-check-circle")
|
||||
text " Sent"
|
||||
else:
|
||||
button(class=class(
|
||||
{"loading": state.loading},
|
||||
"btn btn-primary"
|
||||
),
|
||||
`type`="button",
|
||||
onClick=(ev: Event, n: VNode) => onClick(ev, n, state)):
|
||||
text "Reset password"
|
||||
@@ -0,0 +1,102 @@
|
||||
|
||||
import user, options, httpcore, json, times
|
||||
type
|
||||
SearchResultKind* = enum
|
||||
ThreadMatch, PostMatch
|
||||
|
||||
SearchResult* = object
|
||||
kind*: SearchResultKind
|
||||
threadId*: int
|
||||
postId*: int
|
||||
threadTitle*: string
|
||||
postContent*: string
|
||||
author*: User
|
||||
creation*: int64
|
||||
|
||||
proc isModerated*(searchResult: SearchResult): bool =
|
||||
return searchResult.author.rank <= Moderated
|
||||
|
||||
when defined(js):
|
||||
from dom import nil
|
||||
|
||||
include karax/prelude
|
||||
import karax / [kajax]
|
||||
|
||||
import karaxutils, error, threadlist, sugar
|
||||
|
||||
type
|
||||
Search* = ref object
|
||||
list: Option[seq[SearchResult]]
|
||||
loading: bool
|
||||
status: HttpCode
|
||||
query: string
|
||||
|
||||
proc newSearch*(): Search =
|
||||
Search(
|
||||
list: none[seq[SearchResult]](),
|
||||
loading: false,
|
||||
status: Http200,
|
||||
query: ""
|
||||
)
|
||||
|
||||
proc onList(httpStatus: int, response: kstring, state: Search) =
|
||||
state.loading = false
|
||||
state.status = httpStatus.HttpCode
|
||||
if state.status != Http200: return
|
||||
|
||||
let parsed = parseJson($response)
|
||||
let list = to(parsed, seq[SearchResult])
|
||||
|
||||
state.list = some(list)
|
||||
|
||||
proc genSearchResult(searchResult: SearchResult): VNode =
|
||||
let url = renderPostUrl(searchResult.threadId, searchResult.postId)
|
||||
result = buildHtml():
|
||||
tdiv(class="post", id = $searchResult.postId):
|
||||
tdiv(class="post-icon"):
|
||||
render(searchResult.author, "post-avatar")
|
||||
tdiv(class="post-main"):
|
||||
tdiv(class="post-title"):
|
||||
tdiv(class="thread-title"):
|
||||
a(href=url, onClick=anchorCB):
|
||||
verbatim(searchResult.threadTitle)
|
||||
tdiv(class="post-username"):
|
||||
text searchResult.author.name
|
||||
renderUserRank(searchResult.author)
|
||||
tdiv(class="post-metadata"):
|
||||
# TODO: History and replying to.
|
||||
let title = searchResult.creation.fromUnix().local.
|
||||
format("MMM d, yyyy HH:mm")
|
||||
a(href=url, title=title, onClick=anchorCB):
|
||||
text renderActivity(searchResult.creation)
|
||||
tdiv(class="post-content"):
|
||||
verbatim(searchResult.postContent)
|
||||
|
||||
proc render*(state: Search, query: string, currentUser: Option[User]): VNode =
|
||||
if state.query != query:
|
||||
state.list = none[seq[SearchResult]]()
|
||||
state.status = Http200
|
||||
state.query = query
|
||||
|
||||
if state.status != Http200:
|
||||
return renderError("Couldn't retrieve search results.", state.status)
|
||||
|
||||
if state.list.isNone:
|
||||
var params = @[("q", state.query)]
|
||||
let uri = makeUri("search.json", params)
|
||||
ajaxGet(uri, @[], (s: int, r: kstring) => onList(s, r, state))
|
||||
|
||||
return buildHtml(tdiv(class="loading loading-lg"))
|
||||
|
||||
let list = state.list.get()
|
||||
result = buildHtml():
|
||||
section(class="container grid-xl"):
|
||||
tdiv(class="title"):
|
||||
p(): text "Search results"
|
||||
tdiv(class="searchresults"):
|
||||
if list.len == 0:
|
||||
renderMessage("No results found", "", "fa-exclamation")
|
||||
else:
|
||||
for searchResult in list:
|
||||
if not searchResult.visibleTo(currentUser): continue
|
||||
genSearchResult(searchResult)
|
||||
@@ -0,0 +1,97 @@
|
||||
when defined(js):
|
||||
import sugar, httpcore, options, json
|
||||
import dom except Event
|
||||
import jsffi except `&`
|
||||
|
||||
include karax/prelude
|
||||
import karax / [kajax, kdom]
|
||||
|
||||
import error
|
||||
import karaxutils
|
||||
|
||||
type
|
||||
SignupModal* = ref object
|
||||
shown: bool
|
||||
loading: bool
|
||||
onSignUp, onLogIn: proc ()
|
||||
error: Option[PostError]
|
||||
|
||||
proc onSignUpPost(httpStatus: int, response: kstring, state: SignupModal) =
|
||||
postFinished:
|
||||
state.shown = false
|
||||
state.onSignUp()
|
||||
|
||||
proc onSignUpClick(ev: Event, n: VNode, state: SignupModal) =
|
||||
state.loading = true
|
||||
state.error = none[PostError]()
|
||||
|
||||
let uri = makeUri("signup")
|
||||
let form = dom.document.getElementById("signup-form")
|
||||
# TODO: This is a hack, karax should support this.
|
||||
let formData = newFormData(form)
|
||||
ajaxPost(uri, @[], formData.to(cstring),
|
||||
(s: int, r: kstring) => onSignUpPost(s, r, state))
|
||||
|
||||
proc onClose(ev: Event, n: VNode, state: SignupModal) =
|
||||
state.shown = false
|
||||
ev.preventDefault()
|
||||
|
||||
proc newSignupModal*(onSignUp, onLogIn: proc ()): SignupModal =
|
||||
SignupModal(
|
||||
shown: false,
|
||||
onLogIn: onLogIn,
|
||||
onSignUp: onSignUp
|
||||
)
|
||||
|
||||
proc show*(state: SignupModal) =
|
||||
state.shown = true
|
||||
|
||||
proc render*(state: SignupModal, recaptchaSiteKey: Option[string]): VNode =
|
||||
setForeignNodeId("recaptcha")
|
||||
|
||||
result = buildHtml():
|
||||
tdiv(class=class({"active": state.shown}, "modal"),
|
||||
id="signup-modal"):
|
||||
a(href="", class="modal-overlay", "aria-label"="close",
|
||||
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
|
||||
tdiv(class="modal-container"):
|
||||
tdiv(class="modal-header"):
|
||||
a(href="", class="btn btn-clear float-right",
|
||||
"aria-label"="close",
|
||||
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
|
||||
tdiv(class="modal-title h5"):
|
||||
text "Create a new account"
|
||||
tdiv(class="modal-body"):
|
||||
tdiv(class="content"):
|
||||
form(id="signup-form"):
|
||||
genFormField(state.error, "email", "Email", "email", false)
|
||||
genFormField(state.error, "username", "Username", "text", false)
|
||||
genFormField(
|
||||
state.error,
|
||||
"password",
|
||||
"Password",
|
||||
"password",
|
||||
true
|
||||
)
|
||||
if recaptchaSiteKey.isSome:
|
||||
tdiv(id="recaptcha"):
|
||||
tdiv(class="g-recaptcha",
|
||||
"data-sitekey"=recaptchaSiteKey.get())
|
||||
script(src="https://www.google.com/recaptcha/api.js")
|
||||
tdiv(class="modal-footer"):
|
||||
button(class=class({"loading": state.loading},
|
||||
"btn btn-primary create-account-btn"),
|
||||
onClick=(ev: Event, n: VNode) => onSignUpClick(ev, n, state)):
|
||||
text "Create account"
|
||||
button(class="btn login-btn",
|
||||
onClick=(ev: Event, n: VNode) =>
|
||||
(state.onLogIn(); state.shown = false)):
|
||||
text "Log in"
|
||||
|
||||
p(class="license-text text-gray"):
|
||||
text "By registering, you agree to the "
|
||||
a(id="license", href=makeUri("/about/license"),
|
||||
onClick=(ev: Event, n: VNode) =>
|
||||
(state.shown = false; anchorCB(ev, n))):
|
||||
text "content license"
|
||||
text "."
|
||||
@@ -0,0 +1,251 @@
|
||||
import strformat, times, options, json, httpcore, sugar
|
||||
|
||||
import category, user
|
||||
|
||||
type
|
||||
Thread* = object
|
||||
id*: int
|
||||
topic*: string
|
||||
category*: Category
|
||||
author*: User
|
||||
users*: seq[User]
|
||||
replies*: int
|
||||
views*: int
|
||||
activity*: int64 ## Unix timestamp
|
||||
creation*: int64 ## Unix timestamp
|
||||
isLocked*: bool
|
||||
isSolved*: bool
|
||||
isPinned*: bool
|
||||
|
||||
ThreadList* = ref object
|
||||
threads*: seq[Thread]
|
||||
moreCount*: int ## How many more threads are left
|
||||
|
||||
proc isModerated*(thread: Thread): bool =
|
||||
## Determines whether the specified thread is under moderation.
|
||||
## (i.e. whether the specified thread is invisible to ordinary users).
|
||||
thread.author.rank <= Moderated
|
||||
|
||||
when defined(js):
|
||||
import sugar
|
||||
include karax/prelude
|
||||
import karax / [vstyles, kajax, kdom]
|
||||
|
||||
import karaxutils, error, user, mainbuttons
|
||||
|
||||
type
|
||||
State = ref object
|
||||
list: Option[ThreadList]
|
||||
refreshList: bool
|
||||
loading: bool
|
||||
status: HttpCode
|
||||
mainButtons: MainButtons
|
||||
|
||||
var state: State
|
||||
|
||||
proc newState(): State =
|
||||
State(
|
||||
list: none[ThreadList](),
|
||||
loading: false,
|
||||
status: Http200,
|
||||
mainButtons: newMainButtons(
|
||||
onCategoryChange =
|
||||
(oldCategory: Category, newCategory: Category) => (state.list = none[ThreadList]())
|
||||
)
|
||||
)
|
||||
|
||||
state = newState()
|
||||
|
||||
proc visibleTo*[T](thread: T, user: Option[User]): bool =
|
||||
## Determines whether the specified thread (or post) should be
|
||||
## shown to the user. This procedure is generic and works on any
|
||||
## object with a `isModerated` proc.
|
||||
##
|
||||
## The rules for this are determined by the rank of the user, their
|
||||
## settings (TODO), and whether the thread's creator is moderated or not.
|
||||
##
|
||||
## The ``user`` argument refers to the currently logged in user.
|
||||
mixin isModerated
|
||||
if user.isNone(): return not thread.isModerated
|
||||
|
||||
let rank = user.get().rank
|
||||
if rank < Rank.Moderator and thread.isModerated:
|
||||
return thread.author == user.get()
|
||||
|
||||
return true
|
||||
|
||||
proc genUserAvatars(users: seq[User]): VNode =
|
||||
result = buildHtml(td(class="thread-users")):
|
||||
for user in users:
|
||||
render(user, "avatar avatar-sm", showStatus=true)
|
||||
text " "
|
||||
|
||||
proc renderActivity*(activity: int64): string =
|
||||
let currentTime = getTime()
|
||||
let activityTime = fromUnix(activity)
|
||||
let duration = currentTime - activityTime
|
||||
if currentTime.local().year != activityTime.local().year:
|
||||
return activityTime.local().format("MMM yyyy")
|
||||
elif duration.inDays > 30 and duration.inDays < 300:
|
||||
return activityTime.local().format("MMM dd")
|
||||
elif duration.inDays != 0:
|
||||
return $duration.inDays & "d"
|
||||
elif duration.inHours != 0:
|
||||
return $duration.inHours & "h"
|
||||
elif duration.inMinutes != 0:
|
||||
return $duration.inMinutes & "m"
|
||||
else:
|
||||
return $duration.inSeconds & "s"
|
||||
|
||||
proc genThread(pos: int, thread: Thread, isNew: bool, noBorder: bool, displayCategory=true): VNode =
|
||||
let isOld = (getTime() - thread.creation.fromUnix).inWeeks > 2
|
||||
let isBanned = thread.author.rank.isBanned()
|
||||
result = buildHtml():
|
||||
tr(class=class({"no-border": noBorder, "banned": isBanned, "pinned": thread.isPinned, "thread-" & $pos: true})):
|
||||
td(class="thread-title"):
|
||||
if thread.isLocked:
|
||||
italic(class="fas fa-lock fa-xs",
|
||||
title="Thread cannot be replied to")
|
||||
if thread.isPinned:
|
||||
italic(class="fas fa-thumbtack fa-xs",
|
||||
title="Pinned post")
|
||||
if isBanned:
|
||||
italic(class="fas fa-ban fa-xs",
|
||||
title="Thread author is banned")
|
||||
if thread.isModerated:
|
||||
italic(class="fas fa-eye-slash fa-xs",
|
||||
title="Thread is moderated")
|
||||
if thread.isSolved:
|
||||
italic(class="fas fa-check-square fa-xs",
|
||||
title="Thread has a solution")
|
||||
a(href=makeUri("/t/" & $thread.id), onClick=anchorCB):
|
||||
text thread.topic
|
||||
tdiv(class="show-sm" & class({"d-none": not displayCategory})):
|
||||
render(thread.category)
|
||||
|
||||
td(class="hide-sm" & class({"d-none": not displayCategory})):
|
||||
render(thread.category)
|
||||
genUserAvatars(thread.users)
|
||||
td(class="thread-replies"): text $thread.replies
|
||||
td(class="hide-sm" & class({
|
||||
"views-text": thread.views < 999,
|
||||
"popular-text": thread.views > 999 and thread.views < 5000,
|
||||
"super-popular-text": thread.views > 5000
|
||||
})):
|
||||
if thread.views > 999:
|
||||
text fmt"{thread.views/1000:.1f}k"
|
||||
else:
|
||||
text $thread.views
|
||||
|
||||
let friendlyCreation = thread.creation.fromUnix.local.format(
|
||||
"'First post:' MMM d, yyyy HH:mm'\n'"
|
||||
)
|
||||
let friendlyActivity = thread.activity.fromUnix.local.format(
|
||||
"'Last reply:' MMM d, yyyy HH:mm"
|
||||
)
|
||||
td(class=class({"is-new": isNew, "is-old": isOld}, "thread-time"),
|
||||
title=friendlyCreation & friendlyActivity):
|
||||
text renderActivity(thread.activity)
|
||||
|
||||
proc onThreadList(httpStatus: int, response: kstring) =
|
||||
state.loading = false
|
||||
state.status = httpStatus.HttpCode
|
||||
if state.status != Http200: return
|
||||
|
||||
let parsed = parseJson($response)
|
||||
let list = to(parsed, ThreadList)
|
||||
|
||||
if state.list.isSome:
|
||||
state.list.get().threads.add(list.threads)
|
||||
state.list.get().moreCount = list.moreCount
|
||||
else:
|
||||
state.list = some(list)
|
||||
|
||||
proc onLoadMore(ev: Event, n: VNode, categoryId: Option[int]) =
|
||||
state.loading = true
|
||||
let start = state.list.get().threads.len
|
||||
if categoryId.isSome:
|
||||
ajaxGet(makeUri("threads.json?start=" & $start & "&categoryId=" & $categoryId.get()), @[], onThreadList)
|
||||
else:
|
||||
ajaxGet(makeUri("threads.json?start=" & $start), @[], onThreadList)
|
||||
|
||||
proc getInfo(
|
||||
list: seq[Thread], i: int, currentUser: Option[User]
|
||||
): tuple[isLastUnseen, isNew: bool] =
|
||||
## Determines two properties about a thread.
|
||||
##
|
||||
## * isLastUnseen - Whether this is the last thread that had new
|
||||
## activity since the last time the user visited the forum.
|
||||
## * isNew - Whether this thread was created during the time that the
|
||||
# user was absent from the forum.
|
||||
let previousVisitAt =
|
||||
if currentUser.isSome(): currentUser.get().previousVisitAt
|
||||
else: getTime().toUnix
|
||||
assert previousVisitAt != 0
|
||||
|
||||
let thread = list[i]
|
||||
let isUnseen = thread.activity > previousVisitAt
|
||||
let isNextUnseen = i+1 < list.len and list[i+1].activity > previousVisitAt
|
||||
|
||||
return (
|
||||
isLastUnseen: isUnseen and (not isNextUnseen),
|
||||
isNew: thread.creation > previousVisitAt
|
||||
)
|
||||
|
||||
proc genThreadList(currentUser: Option[User], categoryId: Option[int]): VNode =
|
||||
if state.status != Http200:
|
||||
return renderError("Couldn't retrieve threads.", state.status)
|
||||
|
||||
if state.list.isNone:
|
||||
if not state.loading:
|
||||
state.loading = true
|
||||
if categoryId.isSome:
|
||||
ajaxGet(makeUri("threads.json?categoryId=" & $categoryId.get()), @[], onThreadList)
|
||||
else:
|
||||
ajaxGet(makeUri("threads.json"), @[], onThreadList)
|
||||
|
||||
return buildHtml(tdiv(class="loading loading-lg"))
|
||||
|
||||
let displayCategory = categoryId.isNone
|
||||
|
||||
let list = state.list.get()
|
||||
result = buildHtml():
|
||||
section(class="thread-list"):
|
||||
table(class="table", id="threads-list"):
|
||||
thead():
|
||||
tr:
|
||||
th(text "Topic")
|
||||
th(class="hide-sm" & class({"d-none": not displayCategory})): text "Category"
|
||||
th(class="thread-users"): text "Users"
|
||||
th(class="centered-header"): text "Replies"
|
||||
th(class="hide-sm centered-header"): text "Views"
|
||||
th(class="centered-header"): text "Activity"
|
||||
tbody():
|
||||
for i in 0 ..< list.threads.len:
|
||||
let thread = list.threads[i]
|
||||
if not visibleTo(thread, currentUser): continue
|
||||
|
||||
let isLastThread = i+1 == list.threads.len
|
||||
let (isLastUnseen, isNew) = getInfo(list.threads, i, currentUser)
|
||||
genThread(i+1, thread, isNew,
|
||||
noBorder=isLastUnseen or isLastThread,
|
||||
displayCategory=displayCategory)
|
||||
if isLastUnseen and (not isLastThread):
|
||||
tr(class="last-visit-separator"):
|
||||
td(colspan="6"):
|
||||
span(text "last visit")
|
||||
|
||||
if list.moreCount > 0:
|
||||
tr(class="load-more-separator"):
|
||||
if state.loading:
|
||||
td(colspan="6"):
|
||||
tdiv(class="loading loading-lg")
|
||||
else:
|
||||
td(colspan="6",
|
||||
onClick = (ev: Event, n: VNode) => (onLoadMore(ev, n, categoryId))):
|
||||
span(text "load more threads")
|
||||
|
||||
proc renderThreadList*(currentUser: Option[User], categoryId = none(int)): VNode =
|
||||
result = buildHtml(tdiv):
|
||||
state.mainButtons.render(currentUser, categoryId=categoryId)
|
||||
genThreadList(currentUser, categoryId)
|
||||
@@ -0,0 +1,82 @@
|
||||
import times, options
|
||||
|
||||
type
|
||||
# If you add more "Banned" states, be sure to modify forum's threadsQuery too.
|
||||
Rank* {.pure.} = enum ## serialized as 'status'
|
||||
AutoSpammer ## Account automatically marked as spammer
|
||||
Spammer ## spammer: every post is invisible
|
||||
Moderated ## new member: posts manually reviewed before everybody
|
||||
## can see them
|
||||
Troll ## troll: cannot write new posts
|
||||
Banned ## A non-specific ban
|
||||
EmailUnconfirmed ## member with unconfirmed email address. Their posts
|
||||
## are visible, but cannot make new posts. This is so that
|
||||
## when a user with existing posts changes their email,
|
||||
## their posts don't disappear.
|
||||
User ## Ordinary user
|
||||
Moderator ## Moderator: can change a user's rank
|
||||
Admin ## Admin: can do everything
|
||||
|
||||
User* = object
|
||||
id*: string
|
||||
name*: string
|
||||
avatarUrl*: string
|
||||
lastOnline*: int64
|
||||
previousVisitAt*: int64 ## Tracks the "last visit" line position
|
||||
rank*: Rank
|
||||
isDeleted*: bool
|
||||
|
||||
const bannedRanks* = {AutoSpammer, Spammer, Troll, Banned}
|
||||
|
||||
proc isOnline*(user: User): bool =
|
||||
return getTime().toUnix() - user.lastOnline < (60*5)
|
||||
|
||||
proc isAdmin*(user: Option[User]): bool =
|
||||
return user.isSome and user.get().rank == Admin
|
||||
|
||||
proc `==`*(u1, u2: User): bool =
|
||||
u1.name == u2.name
|
||||
|
||||
proc canPost*(rank: Rank): bool =
|
||||
## Determines whether the specified rank can make new posts.
|
||||
rank >= Rank.User or rank == Moderated
|
||||
|
||||
proc isBanned*(rank: Rank): bool =
|
||||
rank in bannedRanks
|
||||
|
||||
when defined(js):
|
||||
include karax/prelude
|
||||
import karaxutils
|
||||
|
||||
proc render*(user: User, class: string, showStatus=false): VNode =
|
||||
result = buildHtml():
|
||||
a(href=renderProfileUrl(user.name), onClick=anchorCB):
|
||||
figure(class=class):
|
||||
img(src=user.avatarUrl, title=user.name)
|
||||
if user.isOnline and showStatus:
|
||||
italic(class="avatar-presence online")
|
||||
|
||||
proc renderUserMention*(user: User): VNode =
|
||||
result = buildHtml():
|
||||
a(class="user-mention",
|
||||
href=makeUri("/profile/" & user.name),
|
||||
onClick=anchorCB):
|
||||
text "@" & user.name
|
||||
|
||||
proc renderUserRank*(user: User): VNode =
|
||||
result = buildHtml():
|
||||
case user.rank
|
||||
of bannedRanks:
|
||||
italic(class="fas fa-eye-ban",
|
||||
title="User is banned")
|
||||
of Rank.User, EmailUnconfirmed:
|
||||
span()
|
||||
of Moderated:
|
||||
italic(class="fas fa-eye-slash",
|
||||
title="User is moderated")
|
||||
of Moderator:
|
||||
italic(class="fas fa-shield-alt",
|
||||
title="User is a moderator")
|
||||
of Admin:
|
||||
italic(class="fas fa-chess-knight",
|
||||
title="User is an admin")
|
||||
@@ -0,0 +1,66 @@
|
||||
|
||||
when defined(js):
|
||||
import sugar
|
||||
|
||||
include karax/prelude
|
||||
import karax/[vstyles]
|
||||
import karaxutils
|
||||
|
||||
import user
|
||||
type
|
||||
UserMenu* = ref object
|
||||
shown: bool
|
||||
user: User
|
||||
onLogout: proc ()
|
||||
|
||||
proc newUserMenu*(onLogout: proc ()): UserMenu =
|
||||
UserMenu(
|
||||
shown: false,
|
||||
onLogout: onLogout
|
||||
)
|
||||
|
||||
proc onClick(e: Event, n: VNode, state: UserMenu) =
|
||||
state.shown = not state.shown
|
||||
|
||||
proc render*(state: UserMenu, user: User): VNode =
|
||||
result = buildHtml():
|
||||
tdiv(id="profile-btn"):
|
||||
figure(class="avatar c-hand",
|
||||
onClick=(e: Event, n: VNode) => onClick(e, n, state)):
|
||||
img(src=user.avatarUrl, title=user.name)
|
||||
if user.isOnline:
|
||||
italic(class="avatar-presense online")
|
||||
|
||||
tdiv(style=style([
|
||||
(StyleAttr.width, kstring"999999px"),
|
||||
(StyleAttr.height, kstring"999999px"),
|
||||
(StyleAttr.position, kstring"absolute"),
|
||||
(StyleAttr.left, kstring"0"),
|
||||
(StyleAttr.top, kstring"0"),
|
||||
(
|
||||
StyleAttr.display,
|
||||
if state.shown: kstring"block" else: kstring"none"
|
||||
)
|
||||
]),
|
||||
onClick=(e: Event, n: VNode) => (state.shown = false))
|
||||
|
||||
ul(class="menu menu-right", style=style(
|
||||
StyleAttr.display, if state.shown: "inherit" else: "none"
|
||||
)):
|
||||
li(class="menu-item"):
|
||||
tdiv(class="tile tile-centered"):
|
||||
tdiv(class="tile-icon"):
|
||||
img(class="avatar", src=user.avatarUrl,
|
||||
title=user.name)
|
||||
tdiv(id="profile-name", class="tile-content"):
|
||||
text user.name
|
||||
li(class="divider")
|
||||
li(class="menu-item"):
|
||||
a(id="myprofile-btn",
|
||||
href=makeUri("/profile/" & user.name)):
|
||||
text "My profile"
|
||||
li(class="menu-item c-hand"):
|
||||
a(id="logout-btn",
|
||||
onClick = (e: Event, n: VNode) =>
|
||||
(state.shown=false; state.onLogout())):
|
||||
text "Logout"
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
-- selects just threads,
|
||||
-- those where title doesn't coinside with some of its posts' titles
|
||||
-- by now selects only the threads title (no post snippet)
|
||||
SELECT
|
||||
thread_id,
|
||||
snippet(thread_fts, '<b>', '</b>', '<b>...</b>') AS thread,
|
||||
post_id,
|
||||
post_content,
|
||||
cdate,
|
||||
person.id,
|
||||
person.name AS author,
|
||||
person.email AS email,
|
||||
strftime('%s', person.lastOnline) AS lastOnline,
|
||||
strftime('%s', person.previousVisitAt) AS previousVisitAt,
|
||||
person.status AS status,
|
||||
person.isDeleted as person_isDeleted,
|
||||
0 AS what
|
||||
FROM (
|
||||
SELECT
|
||||
thread_fts.id AS thread_id,
|
||||
post.id AS post_id,
|
||||
post.content AS post_content,
|
||||
strftime('%s', post.creation) AS cdate,
|
||||
MIN(post.creation) AS cdate,
|
||||
post.author AS author_id
|
||||
FROM thread_fts
|
||||
JOIN post ON post.thread=thread_id
|
||||
WHERE thread_fts MATCH ?
|
||||
GROUP BY thread_id, post_id
|
||||
HAVING thread_id NOT IN (
|
||||
SELECT thread
|
||||
FROM post_fts JOIN post USING(id)
|
||||
WHERE post_fts MATCH ?
|
||||
)
|
||||
LIMIT ? OFFSET ?
|
||||
)
|
||||
JOIN thread_fts ON thread_fts.id=thread_id
|
||||
JOIN person ON person.id=author_id
|
||||
WHERE thread_fts MATCH ?
|
||||
UNION
|
||||
-- the main query, selects posts
|
||||
SELECT
|
||||
thread.id AS thread_id,
|
||||
thread.name AS thread,
|
||||
post.id AS post_id,
|
||||
CASE what WHEN 1
|
||||
THEN snippet(post_fts, '**', '**', '...', what, -45)
|
||||
ELSE SUBSTR(post_fts.content, 1, 200) END AS content,
|
||||
cdate,
|
||||
person.id,
|
||||
person.name AS author,
|
||||
person.email AS email,
|
||||
strftime('%s', person.lastOnline) AS lastOnline,
|
||||
strftime('%s', person.previousVisitAt) AS previousVisitAt,
|
||||
person.status AS status,
|
||||
person.isDeleted as person_isDeleted,
|
||||
what
|
||||
FROM post_fts JOIN (
|
||||
-- inner query, selects ids of matching posts, orders and limits them,
|
||||
-- so snippets only for limited count of posts are created (in outer query)
|
||||
SELECT id, strftime('%s', post.creation) AS cdate, thread, 1 AS what, post.author AS author
|
||||
FROM post_fts JOIN post USING(id)
|
||||
WHERE post_fts.content MATCH ?
|
||||
ORDER BY what, cdate DESC
|
||||
LIMIT ? OFFSET ?
|
||||
) AS post USING(id)
|
||||
JOIN thread ON thread.id=thread
|
||||
JOIN person ON person.id=author
|
||||
WHERE post_fts MATCH ?
|
||||
ORDER BY what ASC, cdate DESC
|
||||
LIMIT 300 -- hardcoded limit just in case
|
||||
;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import strutils
|
||||
when NimMajor > 1:
|
||||
import db_connector/db_sqlite
|
||||
else:
|
||||
import std/db_sqlite
|
||||
|
||||
let origFile = "nimforum.db-21-05-18"
|
||||
let targetFile = "nimforum-blank.db"
|
||||
|
||||
let orig = open(connection=origFile, user="", password="",
|
||||
database="nimforum")
|
||||
let target = open(connection=targetFile, user="", password="",
|
||||
database="nimforum")
|
||||
|
||||
block:
|
||||
let fields = "id, name, views, modified"
|
||||
for thread in getAllRows(orig, sql("select $1 from thread;" % fields)):
|
||||
target.exec(
|
||||
sql("""
|
||||
insert into thread($1)
|
||||
values (?, ?, ?, ?)
|
||||
""" % fields),
|
||||
thread
|
||||
)
|
||||
|
||||
block:
|
||||
let fields = "id, name, password, email, creation, salt, status, lastOnline"
|
||||
for person in getAllRows(orig, sql("select $1 from person;" % fields)):
|
||||
target.exec(
|
||||
sql("""
|
||||
insert into person($1)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""" % fields),
|
||||
person
|
||||
)
|
||||
|
||||
block:
|
||||
let fields = "id, author, ip, content, thread, creation"
|
||||
for post in getAllRows(orig, sql("select $1 from post;" % fields)):
|
||||
target.exec(
|
||||
sql("""
|
||||
insert into post($1)
|
||||
values (?, ?, ?, ?, ?, ?)
|
||||
""" % fields),
|
||||
post
|
||||
)
|
||||
|
||||
block:
|
||||
let fields = "id, name"
|
||||
for t in getAllRows(orig, sql("select $1 from thread_fts;" % fields)):
|
||||
target.exec(
|
||||
sql("""
|
||||
insert into thread_fts($1)
|
||||
values (?, ?)
|
||||
""" % fields),
|
||||
t
|
||||
)
|
||||
|
||||
block:
|
||||
let fields = "id, content"
|
||||
for p in getAllRows(orig, sql("select $1 from post_fts;" % fields)):
|
||||
target.exec(
|
||||
sql("""
|
||||
insert into post_fts($1)
|
||||
values (?, ?)
|
||||
""" % fields),
|
||||
p
|
||||
)
|
||||
|
||||
echo("Imported!")
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
#? stdtmpl | standard
|
||||
#template `!`(idx: untyped): untyped =
|
||||
# row[idx]
|
||||
#end template
|
||||
#proc genRSSHeaders(c: TForumData): string =
|
||||
# result = ""
|
||||
<link href="${c.req.makeUri("/threadActivity.xml")}" title="Thread activity"
|
||||
type="application/atom+xml" rel="alternate">
|
||||
<link href="${c.req.makeUri("/postActivity.xml")}" title="Post activity"
|
||||
type="application/atom+xml" rel="alternate">
|
||||
#end proc
|
||||
#
|
||||
#proc genThreadsRSS(c: TForumData): string =
|
||||
# result = ""
|
||||
# const query = sql"""SELECT A.id, A.name,
|
||||
# strftime('%Y-%m-%dT%H:%M:%SZ', (A.modified)),
|
||||
# COUNT(B.id), C.name, B.content, B.id
|
||||
# FROM thread AS A, post AS B, person AS C
|
||||
# WHERE A.id = b.thread AND B.author = C.id
|
||||
# GROUP BY B.thread
|
||||
# ORDER BY modified DESC LIMIT ?"""
|
||||
# const threadId = 0
|
||||
# const name = 1
|
||||
# const threadDate = 2
|
||||
# const postCount = 3
|
||||
# const postAuthor = 4
|
||||
# const postContent = 5
|
||||
# const postId = 6
|
||||
# let frontQuery = c.req.makeUri("/")
|
||||
# let recent = getValue(db, sql"""SELECT
|
||||
# strftime('%Y-%m-%dT%H:%M:%SZ', (modified)) FROM thread
|
||||
# ORDER BY modified DESC LIMIT 1""")
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>${config.name} thread activity</title>
|
||||
<link href="${c.req.makeUri("/threadActivity.xml")}" rel="self" />
|
||||
<link href="${frontQuery}" />
|
||||
<id>${frontQuery}</id>
|
||||
<updated>${recent}</updated>
|
||||
# for row in rows(db, query, 10):
|
||||
<entry>
|
||||
<title>${xmlEncode(!name)}</title>
|
||||
<id>urn:entry:${!threadid}</id>
|
||||
# let url = c.genThreadUrl(threadid = !threadid) &
|
||||
# "#" & !postId
|
||||
<link rel="alternate" type="text/html"
|
||||
href="${c.req.makeUri(url)}"/>
|
||||
<published>${!threadDate}</published>
|
||||
<updated>${!threadDate}</updated>
|
||||
<author><name>${xmlEncode(!postAuthor)}</name></author>
|
||||
<content type="html"
|
||||
>Posts ${!postCount}, ${xmlEncode(!postAuthor)} said:
|
||||
<p>
|
||||
${xmlEncode(rstToHtml(!postContent))}</content>
|
||||
</entry>
|
||||
# end for
|
||||
</feed>
|
||||
#end proc
|
||||
#
|
||||
#proc genPostsRSS(c: TForumData): string =
|
||||
# result = ""
|
||||
# const query = sql"""SELECT A.id, B.name, A.content, A.thread, T.name,
|
||||
# strftime('%Y-%m-%dT%H:%M:%SZ', A.creation),
|
||||
# A.creation, COUNT(C.id)
|
||||
# FROM post AS A, person AS B, post AS C, thread AS T
|
||||
# WHERE A.author = B.id AND A.thread = C.thread AND C.id <= A.id
|
||||
# AND T.id = A.thread
|
||||
# GROUP BY A.id
|
||||
# ORDER BY A.creation DESC LIMIT 10"""
|
||||
# const postId = 0
|
||||
# const postAuthor = 1
|
||||
# const postContent = 2
|
||||
# const postThread = 3
|
||||
# const postHeader = 4
|
||||
# const postRssDate = 5
|
||||
# const postHumanDate = 6
|
||||
# const postPosition = 7
|
||||
# let frontQuery = c.req.makeUri("/")
|
||||
# let recent = getValue(db, sql"""SELECT
|
||||
# strftime('%Y-%m-%dT%H:%M:%SZ', creation) FROM post
|
||||
# ORDER BY creation DESC LIMIT 1""")
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>${config.name} post activity</title>
|
||||
<link href="${c.req.makeUri("/postActivity.xml")}" rel="self" />
|
||||
<link href="${frontQuery}" />
|
||||
<id>${frontQuery}</id>
|
||||
<updated>${recent}</updated>
|
||||
# for row in rows(db, query):
|
||||
<entry>
|
||||
<title>${xmlEncode(!postHeader)}</title>
|
||||
<id>urn:entry:${!postId}</id>
|
||||
# let url = c.genThreadUrl(threadid = !postThread) &
|
||||
# "#" & !postId
|
||||
<link rel="alternate" type="text/html"
|
||||
href="${c.req.makeUri(url)}"/>
|
||||
<published>${!postRssDate}</published>
|
||||
<updated>${!postRssDate}</updated>
|
||||
<author><name>${xmlEncode(!postAuthor)}</name></author>
|
||||
<content type="html"
|
||||
>On ${xmlEncode(!postHumanDate)}, ${xmlEncode(!postAuthor)} said:
|
||||
<p>
|
||||
${xmlEncode(rstToHtml(!postContent))}</content>
|
||||
</entry>
|
||||
# end for
|
||||
</feed>
|
||||
#end proc
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
|
||||
# we need the documentation generator of the compiler:
|
||||
path="$lib/packages/docutils"
|
||||
path="$nim"
|
||||
|
||||
-d:ssl
|
||||
# Use asynchttpserver instead of httpbeast. httpbeast spawns OS threads that
|
||||
# share the global `db` ref (containing async TCP state), causing GC/runtime
|
||||
# corruption and segfaults at startup. asynchttpserver is single-threaded.
|
||||
-d:useStdLib
|
||||
|
||||
# --threads:on
|
||||
# --threadAnalysis:off
|
||||
@@ -0,0 +1,374 @@
|
||||
#
|
||||
#
|
||||
# The Nim Forum
|
||||
# (c) Copyright 2018 Andreas Rumpf, Dominik Picheta
|
||||
# Look at license.txt for more info.
|
||||
# All rights reserved.
|
||||
#
|
||||
# Script to initialise the nimforum.
|
||||
|
||||
import strutils, os, times, json, options, terminal
|
||||
|
||||
import baradb_sqlite
|
||||
|
||||
import auth, frontend/user
|
||||
|
||||
proc backup(path: string, contents: Option[string]=none[string]()) =
|
||||
if fileExists(path):
|
||||
if contents.isSome() and readFile(path) == contents.get():
|
||||
# Don't backup if the files are equivalent.
|
||||
echo("Not backing up because new file is the same.")
|
||||
return
|
||||
|
||||
let backupPath = path & "." & $getTime().toUnix()
|
||||
echo(path, " already exists. Moving to ", backupPath)
|
||||
moveFile(path, backupPath)
|
||||
|
||||
proc createUser(db: DbConn, user: tuple[username, password, email: string],
|
||||
rank: Rank) =
|
||||
assert user.username.len != 0
|
||||
let salt = makeSalt()
|
||||
let password = makePassword(user.password, salt)
|
||||
|
||||
let id = db.nextId("person")
|
||||
echo "createUser: ", user.username, " id=", id
|
||||
exec(db, sql"""
|
||||
INSERT INTO person(id, name, password, email, salt, usrStatus, creation, lastOnline, previousVisitAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW(), NOW())
|
||||
""", $id, user.username, password, user.email, salt, $rank)
|
||||
echo "createUser done: ", user.username
|
||||
|
||||
proc initialiseDb(admin: tuple[username, password, email: string],
|
||||
filename="nimforum.db") =
|
||||
echo "initialiseDb starting..."
|
||||
let
|
||||
path = getCurrentDir() / filename
|
||||
isTest = "-test" in filename
|
||||
isDev = "-dev" in filename
|
||||
|
||||
if not isDev and not isTest:
|
||||
backup(path)
|
||||
|
||||
echo "Removing file..."
|
||||
removeFile(path)
|
||||
echo "Opening DB..."
|
||||
var db = open(connection="127.0.0.1:9472", user="", password="",
|
||||
database="nimforum")
|
||||
echo "DB opened"
|
||||
|
||||
const
|
||||
userNameType = "varchar(20)"
|
||||
passwordType = "varchar(300)"
|
||||
emailType = "varchar(254)" # https://stackoverflow.com/a/574698/492186
|
||||
|
||||
# -- Category
|
||||
echo "Creating category table..."
|
||||
db.exec(sql"""
|
||||
create table category(
|
||||
id integer primary key,
|
||||
name varchar(100) not null,
|
||||
description varchar(500) not null,
|
||||
color varchar(10) not null
|
||||
);
|
||||
""")
|
||||
|
||||
db.exec(sql"""
|
||||
insert into category (id, name, description, color)
|
||||
values (0, 'Unsorted', 'No category has been chosen yet.', '000000');
|
||||
""")
|
||||
|
||||
# -- Thread
|
||||
|
||||
db.exec(sql"""
|
||||
create table thread(
|
||||
id integer primary key,
|
||||
name varchar(100) not null,
|
||||
views integer not null,
|
||||
modified datetime not null default '1970-01-01 00:00:00',
|
||||
category integer not null default 0,
|
||||
isLocked boolean not null default 0,
|
||||
solution integer,
|
||||
isDeleted boolean not null default 0,
|
||||
isPinned boolean not null default 0,
|
||||
|
||||
foreign key (category) references category(id),
|
||||
foreign key (solution) references post(id)
|
||||
);""", [])
|
||||
|
||||
db.exec(sql"""
|
||||
create unique index ThreadNameIx on thread (name);
|
||||
""", [])
|
||||
|
||||
# -- Person
|
||||
|
||||
db.exec(sql("""
|
||||
create table person(
|
||||
id integer primary key,
|
||||
name $# not null,
|
||||
password $# not null,
|
||||
email $# not null,
|
||||
creation datetime not null default '1970-01-01 00:00:00',
|
||||
salt varbin(128) not null,
|
||||
usrStatus varchar(30) not null,
|
||||
lastOnline datetime not null default '1970-01-01 00:00:00',
|
||||
previousVisitAt datetime not null default '1970-01-01 00:00:00',
|
||||
isDeleted boolean not null default 0,
|
||||
needsPasswordReset boolean not null default 0
|
||||
);""" % [userNameType, passwordType, emailType]), [])
|
||||
|
||||
db.exec(sql"""
|
||||
create unique index UserNameIx on person (name);
|
||||
""", [])
|
||||
db.exec sql"create index PersonStatusIdx on person(usrStatus);"
|
||||
|
||||
# Create default user.
|
||||
echo "Creating admin..."
|
||||
db.createUser(admin, Admin)
|
||||
|
||||
# Create some test data for development
|
||||
if isTest or isDev:
|
||||
echo "Creating dev users..."
|
||||
for rank in AutoSpammer..Moderator:
|
||||
let rankLower = toLowerAscii($rank)
|
||||
let user = (username: $rankLower,
|
||||
password: $rankLower,
|
||||
email: $rankLower & "@localhost.local")
|
||||
echo "Creating user: ", rankLower
|
||||
db.createUser(user, rank)
|
||||
|
||||
echo "Inserting categories..."
|
||||
db.exec(sql"""
|
||||
insert into category (id, name, description, color)
|
||||
values (1, 'Libraries', 'Libraries and library development', '0198E1'),
|
||||
(2, 'Announcements', 'Announcements by Nim core devs', 'FFEB3B'),
|
||||
(3, 'Fun', 'Posts that are just for fun', '00897B'),
|
||||
(4, 'Potential Issues', 'Potential Nim compiler issues', 'E53935');
|
||||
""")
|
||||
|
||||
# -- Post
|
||||
|
||||
db.exec(sql"""
|
||||
create table post(
|
||||
id integer primary key,
|
||||
author integer not null,
|
||||
ip string not null,
|
||||
content varchar(1000) not null,
|
||||
thread integer not null,
|
||||
creation datetime not null default '1970-01-01 00:00:00',
|
||||
isDeleted boolean not null default 0,
|
||||
replyingTo integer,
|
||||
|
||||
foreign key (thread) references thread(id),
|
||||
foreign key (author) references person(id),
|
||||
foreign key (replyingTo) references post(id)
|
||||
);""", [])
|
||||
|
||||
db.exec sql"create index PostByAuthorIdx on post(thread, author);"
|
||||
|
||||
db.exec(sql"""
|
||||
create table postRevision(
|
||||
id integer primary key,
|
||||
creation datetime not null default '1970-01-01 00:00:00',
|
||||
original integer not null,
|
||||
content varchar(1000) not null,
|
||||
|
||||
foreign key (original) references post(id)
|
||||
)
|
||||
""")
|
||||
|
||||
# -- Session
|
||||
|
||||
db.exec(sql("""
|
||||
create table session(
|
||||
id integer primary key,
|
||||
ip string not null,
|
||||
sessionKey $# not null,
|
||||
userid integer not null,
|
||||
lastModified datetime not null default '1970-01-01 00:00:00',
|
||||
foreign key (userid) references person(id)
|
||||
);""" % [passwordType]), [])
|
||||
|
||||
# -- Likes
|
||||
|
||||
db.exec(sql("""
|
||||
create table postLike(
|
||||
id integer primary key,
|
||||
author integer not null,
|
||||
post integer not null,
|
||||
creation datetime not null default '1970-01-01 00:00:00',
|
||||
|
||||
foreign key (author) references person(id),
|
||||
foreign key (post) references post(id)
|
||||
)
|
||||
"""))
|
||||
|
||||
# -- Report
|
||||
|
||||
db.exec(sql("""
|
||||
create table report(
|
||||
id integer primary key,
|
||||
author integer not null,
|
||||
post integer not null,
|
||||
kind varchar(30) not null,
|
||||
content varchar(500) not null default '',
|
||||
|
||||
foreign key (author) references person(id),
|
||||
foreign key (post) references post(id)
|
||||
)
|
||||
"""))
|
||||
|
||||
# -- FTS disabled for BaraDB demo
|
||||
echo "FTS tables skipped (BaraDB does not support fts4)"
|
||||
|
||||
close(db)
|
||||
|
||||
proc initialiseConfig(
|
||||
name, title, hostname: string,
|
||||
recaptcha: tuple[siteKey, secretKey: string],
|
||||
smtp: tuple[address, user, password, fromAddr: string, tls: bool],
|
||||
isDev: bool,
|
||||
dbPath: string,
|
||||
ga: string=""
|
||||
) =
|
||||
let path = getCurrentDir() / "forum.json"
|
||||
|
||||
var j = %{
|
||||
"name": %name,
|
||||
"title": %title,
|
||||
"hostname": %hostname,
|
||||
"recaptchaSiteKey": %recaptcha.siteKey,
|
||||
"recaptchaSecretKey": %recaptcha.secretKey,
|
||||
"smtpAddress": %smtp.address,
|
||||
"smtpUser": %smtp.user,
|
||||
"smtpPassword": %smtp.password,
|
||||
"smtpFromAddr": %smtp.fromAddr,
|
||||
"smtpTls": %smtp.tls,
|
||||
"isDev": %isDev,
|
||||
"dbPath": %dbPath
|
||||
}
|
||||
if ga.len > 0:
|
||||
j["ga"] = %ga
|
||||
|
||||
backup(path, some(pretty(j)))
|
||||
writeFile(path, pretty(j))
|
||||
|
||||
proc question(q: string): string =
|
||||
while result.len == 0:
|
||||
stdout.write(q)
|
||||
result = stdin.readLine()
|
||||
|
||||
proc setup() =
|
||||
echo("""
|
||||
Welcome to the NimForum setup script. Please answer the following questions.
|
||||
These can be changed later in the generated forum.json file.
|
||||
""")
|
||||
|
||||
let name = question("Forum full name: ")
|
||||
let title = question("Forum short name: ")
|
||||
|
||||
let hostname = question("Forum hostname: ")
|
||||
|
||||
let adminUser = question("Admin username: ")
|
||||
let adminPass = readPasswordFromStdin("Admin password: ")
|
||||
let adminEmail = question("Admin email: ")
|
||||
|
||||
echo("")
|
||||
echo("The following question are related to recaptcha. \nYou must set up a " &
|
||||
"recaptcha v2 for your forum before answering them. \nPlease do so now " &
|
||||
"and then answer these questions: https://www.google.com/recaptcha/admin")
|
||||
let recaptchaSiteKey = question("Recaptcha site key: ")
|
||||
let recaptchaSecretKey = question("Recaptcha secret key: ")
|
||||
|
||||
|
||||
echo("The following questions are related to smtp. You must set up a \n" &
|
||||
"mailing server for your forum or use an external service.")
|
||||
let smtpAddress = question("SMTP address (eg: mail.hostname.com): ")
|
||||
let smtpUser = question("SMTP user: ")
|
||||
let smtpPassword = readPasswordFromStdin("SMTP pass: ")
|
||||
let smtpFromAddr = question("SMTP sending email address (eg: mail@mail.hostname.com): ")
|
||||
let smtpTls = parseBool(question("Enable TLS for SMTP: "))
|
||||
|
||||
echo("The following is optional. You can specify your Google Analytics ID " &
|
||||
"if you wish. Otherwise just leave it blank.")
|
||||
stdout.write("Google Analytics (eg: UA-12345678-1): ")
|
||||
let ga = stdin.readLine().strip()
|
||||
|
||||
let dbPath = "nimforum.db"
|
||||
initialiseConfig(
|
||||
name, title, hostname, (recaptchaSiteKey, recaptchaSecretKey),
|
||||
(smtpAddress, smtpUser, smtpPassword, smtpFromAddr, smtpTls), isDev=false,
|
||||
dbPath, ga
|
||||
)
|
||||
|
||||
initialiseDb(
|
||||
admin=(adminUser, adminPass, adminEmail),
|
||||
dbPath
|
||||
)
|
||||
|
||||
echo("Setup complete!")
|
||||
|
||||
proc echoHelp() =
|
||||
quit("""
|
||||
Usage: setup_nimforum opts
|
||||
|
||||
Options:
|
||||
--setup Performs first time setup for end users.
|
||||
|
||||
Development options:
|
||||
--dev Creates a new development DB and config.
|
||||
--test Creates a new test DB and config.
|
||||
--blank Creates a new blank DB.
|
||||
""")
|
||||
|
||||
when isMainModule:
|
||||
if paramCount() > 0:
|
||||
case paramStr(1)
|
||||
of "--dev":
|
||||
let dbPath = "nimforum-dev.db"
|
||||
echo("Initialising nimforum for development...")
|
||||
initialiseConfig(
|
||||
"Development Forum",
|
||||
"Development Forum",
|
||||
"localhost",
|
||||
recaptcha=("", ""),
|
||||
smtp=("", "", "", "", false),
|
||||
isDev=true,
|
||||
dbPath
|
||||
)
|
||||
|
||||
initialiseDb(
|
||||
admin=("admin", "admin", "admin@localhost.local"),
|
||||
dbPath
|
||||
)
|
||||
of "--test":
|
||||
let dbPath = "nimforum-test.db"
|
||||
echo("Initialising nimforum for testing...")
|
||||
initialiseConfig(
|
||||
"Test Forum",
|
||||
"Test Forum",
|
||||
"localhost",
|
||||
recaptcha=("", ""),
|
||||
smtp=("", "", "", "", false),
|
||||
isDev=true,
|
||||
dbPath
|
||||
)
|
||||
|
||||
initialiseDb(
|
||||
admin=("admin", "admin", "admin@localhost.local"),
|
||||
dbPath
|
||||
)
|
||||
of "--blank":
|
||||
let dbPath = "nimforum-blank.db"
|
||||
echo("Initialising blank DB...")
|
||||
initialiseDb(
|
||||
admin=("", "", ""),
|
||||
dbPath
|
||||
)
|
||||
of "--setup":
|
||||
setup()
|
||||
else:
|
||||
echoHelp()
|
||||
else:
|
||||
echoHelp()
|
||||
|
||||
quit()
|
||||
@@ -0,0 +1,374 @@
|
||||
#
|
||||
#
|
||||
# The Nim Forum
|
||||
# (c) Copyright 2018 Andreas Rumpf, Dominik Picheta
|
||||
# Look at license.txt for more info.
|
||||
# All rights reserved.
|
||||
#
|
||||
# Script to initialise the nimforum.
|
||||
|
||||
import strutils, os, times, json, options, terminal
|
||||
|
||||
import baradb_sqlite
|
||||
|
||||
import auth, frontend/user
|
||||
|
||||
proc backup(path: string, contents: Option[string]=none[string]()) =
|
||||
if fileExists(path):
|
||||
if contents.isSome() and readFile(path) == contents.get():
|
||||
# Don't backup if the files are equivalent.
|
||||
echo("Not backing up because new file is the same.")
|
||||
return
|
||||
|
||||
let backupPath = path & "." & $getTime().toUnix()
|
||||
echo(path, " already exists. Moving to ", backupPath)
|
||||
moveFile(path, backupPath)
|
||||
|
||||
proc createUser(db: DbConn, user: tuple[username, password, email: string],
|
||||
rank: Rank) =
|
||||
assert user.username.len != 0
|
||||
let salt = makeSalt()
|
||||
let password = makePassword(user.password, salt)
|
||||
|
||||
let id = db.nextId("person")
|
||||
echo "createUser: ", user.username, " id=", id
|
||||
exec(db, sql"""
|
||||
INSERT INTO person(id, name, password, email, salt, usrStatus, creation, lastOnline, previousVisitAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW(), NOW())
|
||||
""", $id, user.username, password, user.email, salt, $rank)
|
||||
echo "createUser done: ", user.username
|
||||
|
||||
proc initialiseDb(admin: tuple[username, password, email: string],
|
||||
filename="nimforum.db") =
|
||||
echo "initialiseDb starting..."
|
||||
let
|
||||
path = getCurrentDir() / filename
|
||||
isTest = "-test" in filename
|
||||
isDev = "-dev" in filename
|
||||
|
||||
if not isDev and not isTest:
|
||||
backup(path)
|
||||
|
||||
echo "Removing file..."
|
||||
removeFile(path)
|
||||
echo "Opening DB..."
|
||||
var db = open(connection="127.0.0.1:9472", user="", password="",
|
||||
database="nimforum")
|
||||
echo "DB opened"
|
||||
|
||||
const
|
||||
userNameType = "varchar(20)"
|
||||
passwordType = "varchar(300)"
|
||||
emailType = "varchar(254)" # https://stackoverflow.com/a/574698/492186
|
||||
|
||||
# -- Category
|
||||
echo "Creating category table..."
|
||||
db.exec(sql"""
|
||||
create table category(
|
||||
id integer primary key,
|
||||
name varchar(100) not null,
|
||||
description varchar(500) not null,
|
||||
color varchar(10) not null
|
||||
);
|
||||
""")
|
||||
|
||||
db.exec(sql"""
|
||||
insert into category (id, name, description, color)
|
||||
values (0, 'Unsorted', 'No category has been chosen yet.', '000000');
|
||||
""")
|
||||
|
||||
# -- Thread
|
||||
|
||||
db.exec(sql"""
|
||||
create table thread(
|
||||
id integer primary key,
|
||||
name varchar(100) not null,
|
||||
views integer not null,
|
||||
modified datetime not null default '1970-01-01 00:00:00',
|
||||
category integer not null default 0,
|
||||
isLocked boolean not null default 0,
|
||||
solution integer,
|
||||
isDeleted boolean not null default 0,
|
||||
isPinned boolean not null default 0,
|
||||
|
||||
foreign key (category) references category(id),
|
||||
foreign key (solution) references post(id)
|
||||
);""", [])
|
||||
|
||||
db.exec(sql"""
|
||||
create unique index ThreadNameIx on thread (name);
|
||||
""", [])
|
||||
|
||||
# -- Person
|
||||
|
||||
db.exec(sql("""
|
||||
create table person(
|
||||
id integer primary key,
|
||||
name $# not null,
|
||||
password $# not null,
|
||||
email $# not null,
|
||||
creation datetime not null default '1970-01-01 00:00:00',
|
||||
salt varbin(128) not null,
|
||||
usrStatus varchar(30) not null,
|
||||
lastOnline datetime not null default '1970-01-01 00:00:00',
|
||||
previousVisitAt datetime not null default '1970-01-01 00:00:00',
|
||||
isDeleted boolean not null default 0,
|
||||
needsPasswordReset boolean not null default 0
|
||||
);""" % [userNameType, passwordType, emailType]), [])
|
||||
|
||||
db.exec(sql"""
|
||||
create unique index UserNameIx on person (name);
|
||||
""", [])
|
||||
db.exec sql"create index PersonStatusIdx on person(usrStatus);"
|
||||
|
||||
# Create default user.
|
||||
echo "Creating admin..."
|
||||
db.createUser(admin, Admin)
|
||||
|
||||
# Create some test data for development
|
||||
if isTest or isDev:
|
||||
echo "Creating dev users..."
|
||||
for rank in AutoSpammer..Moderator:
|
||||
let rankLower = toLowerAscii($rank)
|
||||
let user = (username: $rankLower,
|
||||
password: $rankLower,
|
||||
email: $rankLower & "@localhost.local")
|
||||
echo "Creating user: ", rankLower
|
||||
db.createUser(user, rank)
|
||||
|
||||
echo "Inserting categories..."
|
||||
db.exec(sql"""
|
||||
insert into category (id, name, description, color)
|
||||
values (1, 'Libraries', 'Libraries and library development', '0198E1'),
|
||||
(2, 'Announcements', 'Announcements by Nim core devs', 'FFEB3B'),
|
||||
(3, 'Fun', 'Posts that are just for fun', '00897B'),
|
||||
(4, 'Potential Issues', 'Potential Nim compiler issues', 'E53935');
|
||||
""")
|
||||
|
||||
# -- Post
|
||||
|
||||
db.exec(sql"""
|
||||
create table post(
|
||||
id integer primary key,
|
||||
author integer not null,
|
||||
ip string not null,
|
||||
content varchar(1000) not null,
|
||||
thread integer not null,
|
||||
creation datetime not null default '1970-01-01 00:00:00',
|
||||
isDeleted boolean not null default 0,
|
||||
replyingTo integer,
|
||||
|
||||
foreign key (thread) references thread(id),
|
||||
foreign key (author) references person(id),
|
||||
foreign key (replyingTo) references post(id)
|
||||
);""", [])
|
||||
|
||||
db.exec sql"create index PostByAuthorIdx on post(thread, author);"
|
||||
|
||||
db.exec(sql"""
|
||||
create table postRevision(
|
||||
id integer primary key,
|
||||
creation datetime not null default '1970-01-01 00:00:00',
|
||||
original integer not null,
|
||||
content varchar(1000) not null,
|
||||
|
||||
foreign key (original) references post(id)
|
||||
)
|
||||
""")
|
||||
|
||||
# -- Session
|
||||
|
||||
db.exec(sql("""
|
||||
create table session(
|
||||
id integer primary key,
|
||||
ip string not null,
|
||||
sessionKey $# not null,
|
||||
userid integer not null,
|
||||
lastModified datetime not null default '1970-01-01 00:00:00',
|
||||
foreign key (userid) references person(id)
|
||||
);""" % [passwordType]), [])
|
||||
|
||||
# -- Likes
|
||||
|
||||
db.exec(sql("""
|
||||
create table postLike(
|
||||
id integer primary key,
|
||||
author integer not null,
|
||||
post integer not null,
|
||||
creation datetime not null default '1970-01-01 00:00:00',
|
||||
|
||||
foreign key (author) references person(id),
|
||||
foreign key (post) references post(id)
|
||||
)
|
||||
"""))
|
||||
|
||||
# -- Report
|
||||
|
||||
db.exec(sql("""
|
||||
create table report(
|
||||
id integer primary key,
|
||||
author integer not null,
|
||||
post integer not null,
|
||||
kind varchar(30) not null,
|
||||
content varchar(500) not null default '',
|
||||
|
||||
foreign key (author) references person(id),
|
||||
foreign key (post) references post(id)
|
||||
)
|
||||
"""))
|
||||
|
||||
# -- FTS disabled for BaraDB demo
|
||||
echo "FTS tables skipped (BaraDB does not support fts4)"
|
||||
|
||||
close(db)
|
||||
|
||||
proc initialiseConfig(
|
||||
name, title, hostname: string,
|
||||
recaptcha: tuple[siteKey, secretKey: string],
|
||||
smtp: tuple[address, user, password, fromAddr: string, tls: bool],
|
||||
isDev: bool,
|
||||
dbPath: string,
|
||||
ga: string=""
|
||||
) =
|
||||
let path = getCurrentDir() / "forum.json"
|
||||
|
||||
var j = %{
|
||||
"name": %name,
|
||||
"title": %title,
|
||||
"hostname": %hostname,
|
||||
"recaptchaSiteKey": %recaptcha.siteKey,
|
||||
"recaptchaSecretKey": %recaptcha.secretKey,
|
||||
"smtpAddress": %smtp.address,
|
||||
"smtpUser": %smtp.user,
|
||||
"smtpPassword": %smtp.password,
|
||||
"smtpFromAddr": %smtp.fromAddr,
|
||||
"smtpTls": %smtp.tls,
|
||||
"isDev": %isDev,
|
||||
"dbPath": %dbPath
|
||||
}
|
||||
if ga.len > 0:
|
||||
j["ga"] = %ga
|
||||
|
||||
backup(path, some(pretty(j)))
|
||||
writeFile(path, pretty(j))
|
||||
|
||||
proc question(q: string): string =
|
||||
while result.len == 0:
|
||||
stdout.write(q)
|
||||
result = stdin.readLine()
|
||||
|
||||
proc setup() =
|
||||
echo("""
|
||||
Welcome to the NimForum setup script. Please answer the following questions.
|
||||
These can be changed later in the generated forum.json file.
|
||||
""")
|
||||
|
||||
let name = question("Forum full name: ")
|
||||
let title = question("Forum short name: ")
|
||||
|
||||
let hostname = question("Forum hostname: ")
|
||||
|
||||
let adminUser = question("Admin username: ")
|
||||
let adminPass = readPasswordFromStdin("Admin password: ")
|
||||
let adminEmail = question("Admin email: ")
|
||||
|
||||
echo("")
|
||||
echo("The following question are related to recaptcha. \nYou must set up a " &
|
||||
"recaptcha v2 for your forum before answering them. \nPlease do so now " &
|
||||
"and then answer these questions: https://www.google.com/recaptcha/admin")
|
||||
let recaptchaSiteKey = question("Recaptcha site key: ")
|
||||
let recaptchaSecretKey = question("Recaptcha secret key: ")
|
||||
|
||||
|
||||
echo("The following questions are related to smtp. You must set up a \n" &
|
||||
"mailing server for your forum or use an external service.")
|
||||
let smtpAddress = question("SMTP address (eg: mail.hostname.com): ")
|
||||
let smtpUser = question("SMTP user: ")
|
||||
let smtpPassword = readPasswordFromStdin("SMTP pass: ")
|
||||
let smtpFromAddr = question("SMTP sending email address (eg: mail@mail.hostname.com): ")
|
||||
let smtpTls = parseBool(question("Enable TLS for SMTP: "))
|
||||
|
||||
echo("The following is optional. You can specify your Google Analytics ID " &
|
||||
"if you wish. Otherwise just leave it blank.")
|
||||
stdout.write("Google Analytics (eg: UA-12345678-1): ")
|
||||
let ga = stdin.readLine().strip()
|
||||
|
||||
let dbPath = "nimforum.db"
|
||||
initialiseConfig(
|
||||
name, title, hostname, (recaptchaSiteKey, recaptchaSecretKey),
|
||||
(smtpAddress, smtpUser, smtpPassword, smtpFromAddr, smtpTls), isDev=false,
|
||||
dbPath, ga
|
||||
)
|
||||
|
||||
initialiseDb(
|
||||
admin=(adminUser, adminPass, adminEmail),
|
||||
dbPath
|
||||
)
|
||||
|
||||
echo("Setup complete!")
|
||||
|
||||
proc echoHelp() =
|
||||
quit("""
|
||||
Usage: setup_nimforum opts
|
||||
|
||||
Options:
|
||||
--setup Performs first time setup for end users.
|
||||
|
||||
Development options:
|
||||
--dev Creates a new development DB and config.
|
||||
--test Creates a new test DB and config.
|
||||
--blank Creates a new blank DB.
|
||||
""")
|
||||
|
||||
when isMainModule:
|
||||
if paramCount() > 0:
|
||||
case paramStr(1)
|
||||
of "--dev":
|
||||
let dbPath = "nimforum-dev.db"
|
||||
echo("Initialising nimforum for development...")
|
||||
initialiseConfig(
|
||||
"Development Forum",
|
||||
"Development Forum",
|
||||
"localhost",
|
||||
recaptcha=("", ""),
|
||||
smtp=("", "", "", "", false),
|
||||
isDev=true,
|
||||
dbPath
|
||||
)
|
||||
|
||||
initialiseDb(
|
||||
admin=("admin", "admin", "admin@localhost.local"),
|
||||
dbPath
|
||||
)
|
||||
of "--test":
|
||||
let dbPath = "nimforum-test.db"
|
||||
echo("Initialising nimforum for testing...")
|
||||
initialiseConfig(
|
||||
"Test Forum",
|
||||
"Test Forum",
|
||||
"localhost",
|
||||
recaptcha=("", ""),
|
||||
smtp=("", "", "", "", false),
|
||||
isDev=true,
|
||||
dbPath
|
||||
)
|
||||
|
||||
initialiseDb(
|
||||
admin=("admin", "admin", "admin@localhost.local"),
|
||||
dbPath
|
||||
)
|
||||
of "--blank":
|
||||
let dbPath = "nimforum-blank.db"
|
||||
echo("Initialising blank DB...")
|
||||
initialiseDb(
|
||||
admin=("", "", ""),
|
||||
dbPath
|
||||
)
|
||||
of "--setup":
|
||||
setup()
|
||||
else:
|
||||
echoHelp()
|
||||
else:
|
||||
echoHelp()
|
||||
|
||||
quit()
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1,21 @@
|
||||
import baradb_sqlite
|
||||
|
||||
var db = open("127.0.0.1:9472", "", "", "default")
|
||||
|
||||
db.exec(sql"CREATE TABLE test_adapter (id INT PRIMARY KEY, name STRING)")
|
||||
db.exec(sql"INSERT INTO test_adapter (id, name) VALUES (1, 'hello')")
|
||||
db.exec(sql"INSERT INTO test_adapter (id, name) VALUES (2, 'world')")
|
||||
|
||||
let rows = db.getAllRows(sql"SELECT * FROM test_adapter")
|
||||
echo "All rows: ", rows
|
||||
|
||||
let row = db.getRow(sql"SELECT * FROM test_adapter WHERE id = 1")
|
||||
echo "Row 1: ", row
|
||||
|
||||
let val = db.getValue(sql"SELECT name FROM test_adapter WHERE id = 2")
|
||||
echo "Value: ", val
|
||||
|
||||
let cnt = db.getValue(sql"SELECT count(*) FROM test_adapter")
|
||||
echo "Count: ", cnt
|
||||
|
||||
db.close()
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
import asyncdispatch, smtp, strutils, json, os, xmltree, strtabs,
|
||||
htmlparser, streams, parseutils, options, logging
|
||||
from times import getTime, utc, format
|
||||
|
||||
import packages/docutils/[rst, rstgen]
|
||||
|
||||
# Used to be:
|
||||
# {'A'..'Z', 'a'..'z', '0'..'9', '_', '\128'..'\255'}
|
||||
let
|
||||
UsernameIdent* = IdentChars + {'-'} # TODO: Double check that everyone follows this.
|
||||
|
||||
import frontend/[karaxutils, error]
|
||||
export parseInt
|
||||
|
||||
type
|
||||
Config* = object
|
||||
smtpAddress*: string
|
||||
smtpPort*: int
|
||||
smtpUser*: string
|
||||
smtpPassword*: string
|
||||
smtpFromAddr*: string
|
||||
smtpTls*: bool
|
||||
smtpSsl*: bool
|
||||
mlistAddress*: string
|
||||
recaptchaSecretKey*: string
|
||||
recaptchaSiteKey*: string
|
||||
isDev*: bool
|
||||
dbPath*: string
|
||||
hostname*: string
|
||||
name*, title*: string
|
||||
ga*: string
|
||||
port*: int
|
||||
|
||||
ForumError* = object of Exception
|
||||
data*: PostError
|
||||
|
||||
proc newForumError*(message: string,
|
||||
fields: seq[string] = @[]): ref ForumError =
|
||||
new(result)
|
||||
result.msg = message
|
||||
result.data =
|
||||
PostError(
|
||||
errorFields: fields,
|
||||
message: message
|
||||
)
|
||||
|
||||
var docConfig: StringTableRef
|
||||
|
||||
docConfig = rstgen.defaultConfig()
|
||||
docConfig["doc.listing_start"] = "<pre class='code' data-lang='$2'><code>"
|
||||
docConfig["doc.listing_end"] = "</code><div class='code-buttons'><button class='btn btn-primary btn-sm'>Run</button></div></pre>"
|
||||
|
||||
proc loadConfig*(filename = getCurrentDir() / "forum.json"): Config =
|
||||
result = Config(smtpAddress: "", smtpPort: 25, smtpUser: "",
|
||||
smtpPassword: "", mlistAddress: "")
|
||||
let root = parseFile(filename)
|
||||
result.smtpAddress = root{"smtpAddress"}.getStr("")
|
||||
result.smtpPort = root{"smtpPort"}.getInt(25)
|
||||
result.smtpUser = root{"smtpUser"}.getStr("")
|
||||
result.smtpPassword = root{"smtpPassword"}.getStr("")
|
||||
result.smtpFromAddr = root{"smtpFromAddr"}.getStr("")
|
||||
result.smtpTls = root{"smtpTls"}.getBool(false)
|
||||
result.smtpSsl = root{"smtpSsl"}.getBool(false)
|
||||
result.mlistAddress = root{"mlistAddress"}.getStr("")
|
||||
result.recaptchaSecretKey = root{"recaptchaSecretKey"}.getStr("")
|
||||
result.recaptchaSiteKey = root{"recaptchaSiteKey"}.getStr("")
|
||||
result.isDev = root{"isDev"}.getBool()
|
||||
result.dbPath = root{"dbPath"}.getStr("nimforum.db")
|
||||
result.hostname = root["hostname"].getStr()
|
||||
result.name = root["name"].getStr()
|
||||
result.title = root["title"].getStr()
|
||||
result.ga = root{"ga"}.getStr()
|
||||
result.port = root{"port"}.getInt(5000)
|
||||
|
||||
proc processGT(n: XmlNode, tag: string): (int, XmlNode, string) =
|
||||
result = (0, newElement(tag), tag)
|
||||
if n.kind == xnElement and len(n) == 1 and n[0].kind == xnElement:
|
||||
return processGT(n[0], if n[0].kind == xnElement: n[0].tag else: tag)
|
||||
|
||||
var countGT = true
|
||||
for c in items(n):
|
||||
case c.kind
|
||||
of xnText:
|
||||
if c.text == ">" and countGT:
|
||||
result[0].inc()
|
||||
else:
|
||||
countGT = false
|
||||
result[1].add(newText(c.text))
|
||||
else:
|
||||
result[1].add(c)
|
||||
|
||||
proc blockquoteFinish(currentBlockquote, newNode: var XmlNode, n: XmlNode) =
|
||||
if currentBlockquote.len > 0:
|
||||
#echo(currentBlockquote.repr)
|
||||
newNode.add(currentBlockquote)
|
||||
currentBlockquote = newElement("blockquote")
|
||||
newNode.add(n)
|
||||
|
||||
proc processQuotes(node: XmlNode): XmlNode =
|
||||
# Bolt on quotes.
|
||||
# TODO: Yes, this is ugly. I wrote it quickly. PRs welcome ;)
|
||||
result = newElement("div")
|
||||
var currentBlockquote = newElement("blockquote")
|
||||
for n in items(node):
|
||||
case n.kind
|
||||
of xnElement:
|
||||
case n.tag
|
||||
of "p":
|
||||
let (nesting, contentNode, _) = processGT(n, "p")
|
||||
if nesting > 0:
|
||||
var bq = currentBlockquote
|
||||
for i in 1 ..< nesting:
|
||||
var newBq = bq.child("blockquote")
|
||||
if newBq.isNil:
|
||||
newBq = newElement("blockquote")
|
||||
bq.add(newBq)
|
||||
bq = newBq
|
||||
bq.add(contentNode)
|
||||
else:
|
||||
blockquoteFinish(currentBlockquote, result, n)
|
||||
else:
|
||||
blockquoteFinish(currentBlockquote, result, n)
|
||||
of xnText:
|
||||
if n.text[0] == '\10':
|
||||
result.add(n)
|
||||
else:
|
||||
blockquoteFinish(currentBlockquote, result, n)
|
||||
else:
|
||||
blockquoteFinish(currentBlockquote, result, n)
|
||||
|
||||
proc replaceMentions(node: XmlNode): seq[XmlNode] =
|
||||
assert node.kind == xnText
|
||||
result = @[]
|
||||
|
||||
var current = ""
|
||||
var i = 0
|
||||
while i < len(node.text):
|
||||
i += parseUntil(node.text, current, {'@'}, i)
|
||||
if i >= len(node.text): break
|
||||
if node.text[i] == '@':
|
||||
i.inc # Skip @
|
||||
var username = ""
|
||||
i += parseWhile(node.text, username, UsernameIdent, i)
|
||||
|
||||
if username.len == 0:
|
||||
result.add(newText(current & "@"))
|
||||
else:
|
||||
let el = <>span(
|
||||
class="user-mention",
|
||||
data-username=username,
|
||||
newText("@" & username)
|
||||
)
|
||||
|
||||
result.add(newText(current))
|
||||
current = ""
|
||||
result.add(el)
|
||||
|
||||
result.add(newText(current))
|
||||
|
||||
proc processMentions(node: XmlNode): XmlNode =
|
||||
case node.kind
|
||||
of xnText:
|
||||
result = newElement("span")
|
||||
for child in replaceMentions(node):
|
||||
result.add(child)
|
||||
of xnElement:
|
||||
case node.tag
|
||||
of "pre", "code", "tt", "a":
|
||||
return node
|
||||
else:
|
||||
result = newElement(node.tag)
|
||||
result.attrs = node.attrs
|
||||
for n in items(node):
|
||||
result.add(processMentions(n))
|
||||
else:
|
||||
return node
|
||||
|
||||
proc rstToHtml*(content: string): string {.gcsafe.}=
|
||||
{.cast(gcsafe).}:
|
||||
result = rstgen.rstToHtml(content, {roSupportMarkdown},
|
||||
docConfig)
|
||||
try:
|
||||
var node = parseHtml(newStringStream(result))
|
||||
# rst.nim parser did not support quotes until Nim 1.7:
|
||||
when (NimMajor, NimMinor) < (1, 7):
|
||||
if node.kind == xnElement:
|
||||
node = processQuotes(node)
|
||||
node = processMentions(node)
|
||||
result = ""
|
||||
add(result, node, indWidth=0, addNewLines=false)
|
||||
except:
|
||||
warn("Could not parse rst html.")
|
||||
Reference in New Issue
Block a user