diff options
| author | James Barnett <noreply@jamesbarnett.xyz> | 2020-04-10 13:34:23 +0100 |
|---|---|---|
| committer | James Barnett <noreply@jamesbarnett.xyz> | 2020-04-10 13:34:23 +0100 |
| commit | 78400d587ea5367d3424333913ff4f94ca3f1908 (patch) | |
| tree | 2cf5f5ff8069740b0b7dd00853a4ea8c13d6e05c /src | |
| parent | d5a608143ad2250d8b35b9e4a488d39faaf5a021 (diff) | |
| download | reddit-lite-78400d587ea5367d3424333913ff4f94ca3f1908.tar.xz reddit-lite-78400d587ea5367d3424333913ff4f94ca3f1908.zip | |
Reimplement in Kotlin
Diffstat (limited to 'src')
14 files changed, 543 insertions, 0 deletions
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/<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} | <@pluralise comment.score "point"/></span> + </summary> + <div class="comment-text">${comment.bodyHtmlUnescaped?no_esc}</div> + <#list comment.replies as childComment> + <@postComment childComment/> + </#list> + </details> + <#else> + <a href="#">TODO Load more comments...</a> + </#if> + </div> +</#macro> + +<#macro pluralise count word> + <#if count == 1> + ${count} ${word} + <#else> + ${count} ${word}s + </#if> +</#macro>
\ 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/> + + <div class="post-summary"> + <@postSummary postDetail.post/> + </div> + + ${(postDetail.selftextHtmlUnescaped?no_esc)!""} + + <div class="comment-heading"><@pluralise postDetail.commentCount, "comment"/></div> + <div class="comments"> + <#list postDetail.comments as comment> + <@postComment comment/> + </#list> + </div> + +</@wrapper>
\ 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=</#assign> + <@header subreddit> + <#if showThumbs> + <a href='${thumbnailUrlFragment}false'>hide thumbnails</a> + <#else> + <a href='${thumbnailUrlFragment}true'>show thumbnails</a> + </#if> + </@header> + + <ol class="post-list no-list-style"> + <#list posts as post> + <li> + <div class="post"> + <#if showThumbs && !post.isSelfPost() && post.thumbnailUrl?has_content> + <a class="post-title" href="${post.primaryLink}"> + <img src="${post.thumbnailUrl}" class="post-thumbnail"> </img> + </a> + </#if> + <@postSummary post/> + </div> + </li> + </#list> + </ol> + + <a class="next-page-link" href="/r/${subreddit}?after=${nextPostId}&showThumbs=${showThumbs?c}">next page ></a> + +</@wrapper>
\ 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() { + } + +} |