From 78400d587ea5367d3424333913ff4f94ca3f1908 Mon Sep 17 00:00:00 2001 From: James Barnett Date: Fri, 10 Apr 2020 13:34:23 +0100 Subject: Reimplement in Kotlin --- .gitignore | 4 + README.md | 28 +++- build.gradle.kts | 51 ++++++ gradle/wrapper/gradle-wrapper.properties | 5 + gradlew | 183 +++++++++++++++++++++ gradlew.bat | 103 ++++++++++++ settings.gradle.kts | 1 + .../redditlite/RedditLiteApplication.kt | 11 ++ .../redditlite/controller/LandingPageController.kt | 13 ++ .../redditlite/controller/SubredditController.kt | 37 +++++ .../io/jamesbarnett/redditlite/model/Comment.kt | 44 +++++ .../io/jamesbarnett/redditlite/model/Post.kt | 41 +++++ .../io/jamesbarnett/redditlite/model/PostDetail.kt | 9 + .../redditlite/service/SubredditService.kt | 62 +++++++ src/main/resources/application.properties | 1 + src/main/resources/static/stylesheets/style.css | 143 ++++++++++++++++ src/main/resources/templates/landing.ftlh | 58 +++++++ src/main/resources/templates/lib.ftlh | 62 +++++++ src/main/resources/templates/postDetail.ftlh | 19 +++ src/main/resources/templates/posts.ftlh | 30 ++++ .../redditlite/RedditLiteApplicationTests.kt | 13 ++ 21 files changed, 917 insertions(+), 1 deletion(-) create mode 100644 build.gradle.kts create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts create mode 100644 src/main/kotlin/io/jamesbarnett/redditlite/RedditLiteApplication.kt create mode 100644 src/main/kotlin/io/jamesbarnett/redditlite/controller/LandingPageController.kt create mode 100644 src/main/kotlin/io/jamesbarnett/redditlite/controller/SubredditController.kt create mode 100644 src/main/kotlin/io/jamesbarnett/redditlite/model/Comment.kt create mode 100644 src/main/kotlin/io/jamesbarnett/redditlite/model/Post.kt create mode 100644 src/main/kotlin/io/jamesbarnett/redditlite/model/PostDetail.kt create mode 100644 src/main/kotlin/io/jamesbarnett/redditlite/service/SubredditService.kt create mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/static/stylesheets/style.css create mode 100644 src/main/resources/templates/landing.ftlh create mode 100644 src/main/resources/templates/lib.ftlh create mode 100644 src/main/resources/templates/postDetail.ftlh create mode 100644 src/main/resources/templates/posts.ftlh create mode 100644 src/test/kotlin/io/jamesbarnett/redditlite/RedditLiteApplicationTests.kt 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 { + useJUnitPlatform() +} + +tasks.withType { + 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) { + runApplication(*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 = 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) { + 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 { + 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"> +
+ reddit-lite + +
+
+

+ A lightweight, minimal, readonly Reddit client, designed for mobile devices or slow connections. Source is available on GitHub. +

+
+ Navigate to /r/<subreddit> or try some of the examples below: + +
+

You can also paste any Reddit link (post or comment) here to view the corresponding page in this client

+ + +
+ + + + \ 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> + + + ${title} + + + + + <#nested> + + + + +<#macro header subreddit> +
+ /r/${subreddit} + + <#nested> + +
+ + +<#macro postSummary post> +
+
+ ${post.title} + (${post.domain}) +
+ +
+ + +<#macro postComment comment> +
+ <#if comment.author?has_content> +
+ + ${comment.author} <#if comment.flairText?has_content>${comment.flairText} | ${comment.relativeCreatedDate} | <@pluralise comment.score "point"/> + +
${comment.bodyHtmlUnescaped?no_esc}
+ <#list comment.replies as childComment> + <@postComment childComment/> + +
+ <#else> + TODO Load more comments... + +
+ + +<#macro pluralise count word> + <#if count == 1> + ${count} ${word} + <#else> + ${count} ${word}s + + \ No newline at end of file diff --git a/src/main/resources/templates/postDetail.ftlh b/src/main/resources/templates/postDetail.ftlh new file mode 100644 index 0000000..576e6e1 --- /dev/null +++ b/src/main/resources/templates/postDetail.ftlh @@ -0,0 +1,19 @@ +<#include 'lib.ftlh'> + +<@wrapper title="/r/${subreddit}"> + <@header subreddit/> + +
+ <@postSummary postDetail.post/> +
+ + ${(postDetail.selftextHtmlUnescaped?no_esc)!""} + +
<@pluralise postDetail.commentCount, "comment"/>
+
+ <#list postDetail.comments as comment> + <@postComment comment/> + +
+ + \ No newline at end of file diff --git a/src/main/resources/templates/posts.ftlh b/src/main/resources/templates/posts.ftlh new file mode 100644 index 0000000..56dbd1b --- /dev/null +++ b/src/main/resources/templates/posts.ftlh @@ -0,0 +1,30 @@ +<#include 'lib.ftlh'> + +<@wrapper title="/r/${subreddit}"> + <#assign thumbnailUrlFragment>/r/${subreddit}?after=${postAfterId!}&showThumbs= + <@header subreddit> + <#if showThumbs> + hide thumbnails + <#else> + show thumbnails + + + +
    + <#list posts as post> +
  1. +
    + <#if showThumbs && !post.isSelfPost() && post.thumbnailUrl?has_content> + + + + + <@postSummary post/> +
    +
  2. + +
+ + next page > + + \ No newline at end of file diff --git a/src/test/kotlin/io/jamesbarnett/redditlite/RedditLiteApplicationTests.kt b/src/test/kotlin/io/jamesbarnett/redditlite/RedditLiteApplicationTests.kt new file mode 100644 index 0000000..766a49f --- /dev/null +++ b/src/test/kotlin/io/jamesbarnett/redditlite/RedditLiteApplicationTests.kt @@ -0,0 +1,13 @@ +package io.jamesbarnett.redditlite + +import org.junit.jupiter.api.Test +import org.springframework.boot.test.context.SpringBootTest + +@SpringBootTest +class RedditLiteApplicationTests { + + @Test + fun contextLoads() { + } + +} -- cgit v1.2.3