aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore4
-rw-r--r--README.md28
-rw-r--r--build.gradle.kts51
-rw-r--r--gradle/wrapper/gradle-wrapper.properties5
-rwxr-xr-xgradlew183
-rw-r--r--gradlew.bat103
-rw-r--r--settings.gradle.kts1
-rw-r--r--src/main/kotlin/io/jamesbarnett/redditlite/RedditLiteApplication.kt11
-rw-r--r--src/main/kotlin/io/jamesbarnett/redditlite/controller/LandingPageController.kt13
-rw-r--r--src/main/kotlin/io/jamesbarnett/redditlite/controller/SubredditController.kt37
-rw-r--r--src/main/kotlin/io/jamesbarnett/redditlite/model/Comment.kt44
-rw-r--r--src/main/kotlin/io/jamesbarnett/redditlite/model/Post.kt41
-rw-r--r--src/main/kotlin/io/jamesbarnett/redditlite/model/PostDetail.kt9
-rw-r--r--src/main/kotlin/io/jamesbarnett/redditlite/service/SubredditService.kt62
-rw-r--r--src/main/resources/application.properties1
-rw-r--r--src/main/resources/static/stylesheets/style.css143
-rw-r--r--src/main/resources/templates/landing.ftlh58
-rw-r--r--src/main/resources/templates/lib.ftlh62
-rw-r--r--src/main/resources/templates/postDetail.ftlh19
-rw-r--r--src/main/resources/templates/posts.ftlh30
-rw-r--r--src/test/kotlin/io/jamesbarnett/redditlite/RedditLiteApplicationTests.kt13
21 files changed, 917 insertions, 1 deletions
diff --git a/.gitignore b/.gitignore
index a1c2a23..38dd892 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,7 @@
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
+
+.idea
+.gradle
+build \ No newline at end of file
diff --git a/README.md b/README.md
index 1db4eba..daba22a 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,27 @@
-# reddit-lite \ No newline at end of file
+# reddit-lite
+Demo [here](https://reddit.james-barnett.net)
+
+A lightweight, minimal, readonly Reddit client, designed for mobile devices or slow connections.
+![comments](https://james-barnett.net/files/reddit-lite/screenshots/rl3.png)
+
+## Running locally
+Reddit-lite is written in [play](https://www.playframework.com/).
+Running a non distribution version requires [sbt](http://www.scala-sbt.org/index.html) (which sucks btw).
+
+```sh
+git clone https://github.com/jamesbarnett91/reddit-lite && cd reddit-lite
+mv conf/application.conf.sample conf/application.conf
+sbt run
+```
+Alternatively, just use IntelliJ with the scala plugin and import the project.
+
+## TODOs
+* ~~option to hide thumbnails~~ [done]
+* ~~collapsible comments~~ [done]
+* async load of deeply nested comments
+* sort posts/comments by top/hot/new
+* view subreddit info/sidebar
+* highlight gilded posts/comments
+* highlight comments from OP
+* clean up css and maybe inline it to save an http request
+* should probably write some tests...
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..99ff691
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,51 @@
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+
+plugins {
+ id("org.springframework.boot") version "2.2.6.RELEASE"
+ id("io.spring.dependency-management") version "1.0.9.RELEASE"
+ kotlin("jvm") version "1.3.71"
+ kotlin("plugin.spring") version "1.3.71"
+}
+
+group = "io.jamesbarnett"
+version = "0.0.1-SNAPSHOT"
+java.sourceCompatibility = JavaVersion.VERSION_11
+
+val developmentOnly by configurations.creating
+configurations {
+ runtimeClasspath {
+ extendsFrom(developmentOnly)
+ }
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ implementation("org.springframework.boot:spring-boot-starter-freemarker")
+ implementation("org.springframework.boot:spring-boot-starter-web")
+ implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
+ implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
+ implementation("org.jetbrains.kotlin:kotlin-reflect")
+ implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
+ implementation("com.github.marlonlom:timeago:4.0.1")
+ implementation("org.apache.commons:commons-text:1.8")
+
+ developmentOnly("org.springframework.boot:spring-boot-devtools")
+
+ testImplementation("org.springframework.boot:spring-boot-starter-test") {
+ exclude(group = "org.junit.vintage", module = "junit-vintage-engine")
+ }
+}
+
+tasks.withType<Test> {
+ useJUnitPlatform()
+}
+
+tasks.withType<KotlinCompile> {
+ kotlinOptions {
+ freeCompilerArgs = listOf("-Xjsr305=strict")
+ jvmTarget = "1.8"
+ }
+}
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..a4b4429
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-6.3-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..2fe81a7
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,183 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..9109989
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,103 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windows variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..06f2a64
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1 @@
+rootProject.name = "reddit-lite"
diff --git a/src/main/kotlin/io/jamesbarnett/redditlite/RedditLiteApplication.kt b/src/main/kotlin/io/jamesbarnett/redditlite/RedditLiteApplication.kt
new file mode 100644
index 0000000..b6d7f4c
--- /dev/null
+++ b/src/main/kotlin/io/jamesbarnett/redditlite/RedditLiteApplication.kt
@@ -0,0 +1,11 @@
+package io.jamesbarnett.redditlite
+
+import org.springframework.boot.autoconfigure.SpringBootApplication
+import org.springframework.boot.runApplication
+
+@SpringBootApplication
+class RedditLiteApplication
+
+fun main(args: Array<String>) {
+ runApplication<RedditLiteApplication>(*args)
+}
diff --git a/src/main/kotlin/io/jamesbarnett/redditlite/controller/LandingPageController.kt b/src/main/kotlin/io/jamesbarnett/redditlite/controller/LandingPageController.kt
new file mode 100644
index 0000000..c2881bd
--- /dev/null
+++ b/src/main/kotlin/io/jamesbarnett/redditlite/controller/LandingPageController.kt
@@ -0,0 +1,13 @@
+package io.jamesbarnett.redditlite.controller
+
+import org.springframework.stereotype.Controller
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.servlet.ModelAndView
+
+@Controller
+class LandingPageController {
+ @GetMapping("/")
+ fun renderLandingPage() : ModelAndView {
+ return ModelAndView("landing")
+ }
+} \ No newline at end of file
diff --git a/src/main/kotlin/io/jamesbarnett/redditlite/controller/SubredditController.kt b/src/main/kotlin/io/jamesbarnett/redditlite/controller/SubredditController.kt
new file mode 100644
index 0000000..182d269
--- /dev/null
+++ b/src/main/kotlin/io/jamesbarnett/redditlite/controller/SubredditController.kt
@@ -0,0 +1,37 @@
+package io.jamesbarnett.redditlite.controller
+
+import io.jamesbarnett.redditlite.service.SubredditService
+import org.springframework.stereotype.Controller
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RequestParam
+import org.springframework.web.servlet.ModelAndView
+
+@Controller
+@RequestMapping("/r")
+class SubredditController (val subredditService: SubredditService) {
+
+ @GetMapping("/{subreddit}")
+ fun renderPosts(@PathVariable subreddit: String,
+ @RequestParam("after", required = false) postAfterId: String?,
+ @RequestParam("showThumbs", defaultValue = "true") showThumbs: Boolean
+ ): ModelAndView {
+ val posts = subredditService.getPosts(subreddit, postAfterId)
+ return ModelAndView("posts")
+ .addObject("showThumbs", showThumbs)
+ .addObject("postAfterId", postAfterId)
+ .addObject("subreddit", subreddit)
+ .addObject("posts", posts)
+ .addObject("nextPostId", posts.last().name)
+ }
+
+ @GetMapping("/{subreddit}/comments/{postId}")
+ fun renderPostDetail(@PathVariable subreddit: String, @PathVariable postId: String): ModelAndView {
+ val postDetail = subredditService.getPostDetail(subreddit, postId)
+ return ModelAndView("postDetail")
+ .addObject("subreddit", subreddit)
+ .addObject("postDetail", postDetail)
+ }
+}
+
diff --git a/src/main/kotlin/io/jamesbarnett/redditlite/model/Comment.kt b/src/main/kotlin/io/jamesbarnett/redditlite/model/Comment.kt
new file mode 100644
index 0000000..219270d
--- /dev/null
+++ b/src/main/kotlin/io/jamesbarnett/redditlite/model/Comment.kt
@@ -0,0 +1,44 @@
+package io.jamesbarnett.redditlite.model
+
+import com.fasterxml.jackson.annotation.JsonIgnore
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+import com.fasterxml.jackson.databind.JsonNode
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.github.marlonlom.utilities.timeago.TimeAgo
+import org.apache.commons.text.StringEscapeUtils
+import java.time.Instant
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class Comment(
+ val author: String?,
+ val score: Int,
+ @JsonProperty("is_submitter")
+ val isSubmitter: Boolean,
+ @JsonProperty("created_utc")
+ val createdDate: Instant?,
+ @JsonProperty("author_flair_text")
+ val flairText: String?,
+ val body: String?,
+ @JsonProperty("body_html")
+ val bodyHtml: String?,
+ val depth: Int,
+ @JsonIgnore
+ val replies: MutableList<Comment> = mutableListOf()
+) {
+ companion object {
+ operator fun invoke(jsonNode: JsonNode, objectMapper: ObjectMapper): Comment {
+ val topLevelComment = objectMapper.treeToValue(jsonNode, Comment::class.java)
+ jsonNode
+ .path("replies")
+ .path("data")
+ .path("children")
+ .findValues("data")
+ .forEach {topLevelComment.replies.add(invoke(it, objectMapper))}
+ return topLevelComment
+ }
+ }
+ val isChild get() = depth > 0
+ val relativeCreatedDate: String? get() = createdDate?.let { TimeAgo.using(createdDate.toEpochMilli()) }
+ val bodyHtmlUnescaped: String? get() = StringEscapeUtils.unescapeHtml4(bodyHtml)
+} \ No newline at end of file
diff --git a/src/main/kotlin/io/jamesbarnett/redditlite/model/Post.kt b/src/main/kotlin/io/jamesbarnett/redditlite/model/Post.kt
new file mode 100644
index 0000000..4cd723d
--- /dev/null
+++ b/src/main/kotlin/io/jamesbarnett/redditlite/model/Post.kt
@@ -0,0 +1,41 @@
+package io.jamesbarnett.redditlite.model
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties
+import com.fasterxml.jackson.annotation.JsonProperty
+import com.github.marlonlom.utilities.timeago.TimeAgo
+import java.time.Instant
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+data class Post (
+ val id: String,
+ val name: String,
+ val title: String,
+ val domain: String,
+ val score: Int,
+ val author: String,
+ private val thumbnail: String?,
+ val ups: Int,
+ @JsonProperty("num_comments")
+ val commentCount: Int,
+ val url: String,
+ @JsonProperty("created_utc")
+ val createdDate: Instant,
+ @JsonProperty("is_self")
+ val isSelfPost: Boolean,
+ @JsonProperty("selftext_html")
+ val selftextHtml: String?,
+ val subreddit: String
+) {
+ val primaryLink: String get() {
+ return if (isSelfPost) {
+ "/r/${subreddit}/comments/${id}"
+ } else {
+ url
+ }
+ }
+ val subredditPath get() = "/r/${subreddit}"
+ val relativeCreatedDate: String get() = TimeAgo.using(createdDate.toEpochMilli())
+ val thumbnailUrl get() = thumbnail?.let { if(thumbnail.startsWith("http")) thumbnail else null }
+//val thumbnail get() = if(thumbnailRaw?.startsWith("http") == true) thumbnailRaw else null
+
+} \ No newline at end of file
diff --git a/src/main/kotlin/io/jamesbarnett/redditlite/model/PostDetail.kt b/src/main/kotlin/io/jamesbarnett/redditlite/model/PostDetail.kt
new file mode 100644
index 0000000..c5b58f0
--- /dev/null
+++ b/src/main/kotlin/io/jamesbarnett/redditlite/model/PostDetail.kt
@@ -0,0 +1,9 @@
+package io.jamesbarnett.redditlite.model
+
+import org.apache.commons.text.StringEscapeUtils
+
+data class PostDetail(val post: Post, val comments: List<Comment>) {
+ val isSelfPost get() = post.isSelfPost
+ val selftextHtmlUnescaped: String? get() = StringEscapeUtils.unescapeHtml4(post.selftextHtml)
+ val commentCount get() = post.commentCount
+}
diff --git a/src/main/kotlin/io/jamesbarnett/redditlite/service/SubredditService.kt b/src/main/kotlin/io/jamesbarnett/redditlite/service/SubredditService.kt
new file mode 100644
index 0000000..8c01c93
--- /dev/null
+++ b/src/main/kotlin/io/jamesbarnett/redditlite/service/SubredditService.kt
@@ -0,0 +1,62 @@
+package io.jamesbarnett.redditlite.service
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import com.fasterxml.jackson.module.kotlin.treeToValue
+import io.jamesbarnett.redditlite.model.Comment
+import io.jamesbarnett.redditlite.model.Post
+import io.jamesbarnett.redditlite.model.PostDetail
+import org.springframework.boot.web.client.RestTemplateBuilder
+import org.springframework.stereotype.Service
+import org.springframework.web.client.RestTemplate
+import java.time.Duration
+
+const val REDDIT_API_ROOT_URL = "https://reddit.com/r/"
+
+@Service
+class SubredditService (restTemplateBuilder: RestTemplateBuilder) {
+
+ val objectMapper: ObjectMapper = jacksonObjectMapper()
+ .registerModule(JavaTimeModule())
+
+ val restTemplate: RestTemplate = restTemplateBuilder
+ .setConnectTimeout(Duration.ofSeconds(5))
+ .setReadTimeout(Duration.ofSeconds(10))
+ // Custom UA required to prevent rate limiting: https://github.com/reddit-archive/reddit/wiki/API#rules
+ .defaultHeader("User-Agent", "reddit-lite")
+ .build()
+
+ fun getPosts(subreddit: String, postsAfterId: String?): List<Post> {
+ val jsonResponse = restTemplate.getForObject("${REDDIT_API_ROOT_URL}${subreddit}/.json${if (postsAfterId != null) "?after=${postsAfterId}" else ""}", String::class.java)
+ return objectMapper.readTree(jsonResponse)
+ .path("data")
+ .path("children")
+ .findValues("data")
+ .map { objectMapper.treeToValue(it, Post::class.java) }
+ }
+
+ fun getPostDetail(subreddit: String, postId: String): PostDetail {
+ val jsonResponse = restTemplate.getForObject("${REDDIT_API_ROOT_URL}${subreddit}/comments/${postId}/.json", String::class.java)
+ val rootNode = objectMapper.readTree(jsonResponse)
+
+ val post = objectMapper.treeToValue(
+ rootNode
+ .path(0)
+ .path("data")
+ .path("children")
+ .path(0)
+ .path("data"),
+ Post::class.java)
+
+ val comments = rootNode
+ .path(1)
+ .path("data")
+ .path("children")
+ .findValues("data")
+ .map { Comment(it, objectMapper) }
+
+ return PostDetail(post, comments)
+ }
+
+} \ No newline at end of file
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
new file mode 100644
index 0000000..4d1b8ec
--- /dev/null
+++ b/src/main/resources/application.properties
@@ -0,0 +1 @@
+spring.freemarker.settings.template_exception_handler=rethrow
diff --git a/src/main/resources/static/stylesheets/style.css b/src/main/resources/static/stylesheets/style.css
new file mode 100644
index 0000000..c561bb7
--- /dev/null
+++ b/src/main/resources/static/stylesheets/style.css
@@ -0,0 +1,143 @@
+html {
+ -webkit-text-size-adjust: 100%;
+}
+
+body {
+ font-family: Verdana, Geneva, sans-serif;
+ font-size: 10pt;
+}
+
+p {
+ margin: 5pt 0pt;
+}
+
+.header {
+ background-color: #c0c0c0;
+ display: table;
+}
+
+.subreddit-title {
+ font-weight: bold;
+ font-size: 15pt;
+ padding: 4pt 10pt;
+ display: table-cell;
+ vertical-align: middle;
+ text-decoration: none;
+ color: #000000;
+ white-space: nowrap;
+}
+
+.header-links {
+ display: table-cell;
+ vertical-align: middle;
+ width: 100%;
+}
+
+.header-links a {
+ color: #000000;
+}
+
+.post-list {
+ display: table;
+ border-spacing: 5pt;
+}
+
+.no-list-style {
+ list-style-type: none;
+ padding: 0;
+ margin: 0;
+}
+
+.post {
+ color: #828282;
+ display: table-row;
+}
+
+.post-body {
+ display: table-cell;
+ vertical-align: top;
+}
+
+.post-thumbnail {
+ display: table-cell;
+ vertical-align: top;
+ width: 100px;
+ height: auto;
+}
+
+.post-title {
+ color: #000000;
+ text-decoration: none;
+}
+
+.post-domain {
+ font-size: 8pt;
+}
+
+.post-info {
+ font-size: 7pt;
+}
+
+.nowrap {
+ white-space: nowrap;
+}
+
+.next-page-link {
+ padding: 10pt;
+}
+
+.post-summary {
+ padding: 10pt 5pt;
+}
+
+.subreddit-link {
+ color: #828282;
+}
+
+.post-selftext {
+ padding: 0pt 15pt;
+}
+
+.comments {
+ padding: 10pt 5pt;
+}
+
+.comment-heading {
+ padding-left: 5pt;
+}
+
+.comment {
+ margin-bottom: 10pt;
+}
+
+.comment.child {
+ margin-left: 10pt;
+}
+
+.comment-details {
+ font-size: 8pt;
+ color: #828282;
+}
+
+summary {
+ outline: none;
+ margin-bottom: -5pt;
+}
+
+blockquote {
+ background: #f9f9f9;
+ border-left: 5pt solid #ccc;
+ margin: 10pt 10pt 5pt;
+ padding: 2pt 10pt;
+}
+
+pre {
+ background: #f9f9f9;
+ font-size: 8pt;
+ padding: 2pt 10pt;
+}
+
+.landing-desc {
+ padding: 10pt 5pt;
+ font-size: 10pt;
+} \ No newline at end of file
diff --git a/src/main/resources/templates/landing.ftlh b/src/main/resources/templates/landing.ftlh
new file mode 100644
index 0000000..4e99c17
--- /dev/null
+++ b/src/main/resources/templates/landing.ftlh
@@ -0,0 +1,58 @@
+<#include 'lib.ftlh'>
+
+<@wrapper title="Reddit-lite">
+ <div class="header">
+ <a class="subreddit-title">reddit-lite</a>
+ <span class="header-links"> </span>
+ </div>
+ <div class="landing-desc">
+ <p>
+ A lightweight, minimal, readonly Reddit client, designed for mobile devices or slow connections. Source is available on <a href="https://github.com/jamesbarnett91/reddit-lite">GitHub</a>.
+ </p>
+ <div>
+ Navigate to /r/&lt;subreddit> or try some of the examples below:
+ <ul>
+ <li>
+ <a href="/r/popular">Reddit frontpage (popular subreddits)</a>
+ </li>
+ <li>
+ <a href="/r/programming">/r/programming</a>
+ </li>
+ <li>
+ <a href="/r/webdev">/r/webdev</a>
+ </li>
+ <li>
+ <a href="/r/videos">/r/videos</a>
+ </li>
+ <li>
+ <a href="/r/youtubehaiku">/r/youtubehaiku</a>
+ </li>
+ <li>
+ <a href="/r/worldnews">/r/worldnews/</a>
+ </li>
+ <li>
+ <a href="/r/food">/r/food</a>
+ </li>
+ </ul>
+ </div>
+ <p>You can also paste any Reddit link (post or comment) here to view the corresponding page in this client</p>
+ <input id="reddit-link" placeholder="https://reddit.com/r/example/comments/1jke3nj2/some_example_post"> </input> <button id="view-link">view in reddit-lite</button>
+ <p id="link-error" hidden >specified link does not contain "reddit.com"</p>
+ </div>
+
+ <script>
+ document.addEventListener("DOMContentLoaded", function() {
+ document.getElementById("view-link").onclick = function(e) {
+ var link = document.getElementById("reddit-link").value;
+ var linkTokens = link.split("reddit.com");
+ if(linkTokens.length === 2) {
+ window.location = linkTokens[1];
+ }
+ else {
+ document.getElementById("link-error").removeAttribute('hidden');
+ }
+ }
+ });
+ </script>
+
+</@wrapper> \ No newline at end of file
diff --git a/src/main/resources/templates/lib.ftlh b/src/main/resources/templates/lib.ftlh
new file mode 100644
index 0000000..9b9e96b
--- /dev/null
+++ b/src/main/resources/templates/lib.ftlh
@@ -0,0 +1,62 @@
+<#macro wrapper title>
+<html>
+ <head>
+ <title>${title}</title>
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <link href="/stylesheets/style.css" media="screen" rel="stylesheet" type="text/css" />
+ </head>
+ <body>
+ <#nested>
+ </body>
+</html>
+</#macro>
+
+<#macro header subreddit>
+ <div class="header">
+ <a class="subreddit-title" href="/r/${subreddit}">/r/${subreddit}</a>
+ <span class="header-links">
+ <#nested>
+ </span>
+ </div>
+</#macro>
+
+<#macro postSummary post>
+ <div class="post-body">
+ <div>
+ <a class="post-title" href="${post.primaryLink}">${post.title}</a>
+ <span class="post-domain">(${post.domain})</span>
+ </div>
+ <div class="post-info">
+ <span class="nowrap"><@pluralise post.score "point"/></span>
+ <span class="nowrap">by ${post.author}</span>
+ <span class="nowrap"> in <a class="subreddit-link" href="${post.subredditPath}">${post.subredditPath}</a></span>
+ <span class="nowrap">${post.relativeCreatedDate}</span> | <span class="nowrap"><a href="${post.subredditPath}/comments/${post.id}"><@pluralise post.commentCount, "comment"/></a></span>
+ </div>
+ </div>
+</#macro>
+
+<#macro postComment comment>
+ <div class="comment<#if comment.isChild()> child</#if>">
+ <#if comment.author?has_content>
+ <details open>
+ <summary>
+ <span class="comment-details">${comment.author} <#if comment.flairText?has_content>${comment.flairText}</#if> | ${comment.relativeCreatedDate} |