From 463c202d80f57efb8e46bab4ee4ad7038444b25d Mon Sep 17 00:00:00 2001 From: Elias Haugsbakk Date: Mon, 6 Jul 2026 21:12:40 +0200 Subject: implement post searching --- .../java/no/eliashaugsbakk/clams/server/Main.java | 2 +- .../clams/server/controller/BlogController.java | 147 +++++++++++++-------- .../clams/server/model/PostMetaData.java | 2 +- .../clams/server/repository/BlogPostRepo.java | 3 +- .../server/repository/BlogPostRepoSqlite.java | 43 +++++- .../clams/server/service/BlogSearchService.java | 49 +++++++ .../src/main/resources/public/css/blog.css | 87 ++++++++++-- .../src/main/resources/public/css/common.css | 60 ++++++--- .../src/main/resources/public/css/home.css | 18 ++- .../src/main/resources/public/css/post.css | 4 +- .../src/main/resources/public/css/projects.css | 18 +++ .../src/main/resources/templates/blog-search.html | 41 ++++++ .../src/main/resources/templates/blog.html | 25 +++- .../src/main/resources/templates/layout.html | 5 + .../src/main/resources/templates/post.html | 1 + 15 files changed, 406 insertions(+), 99 deletions(-) create mode 100644 clams-server/src/main/java/no/eliashaugsbakk/clams/server/service/BlogSearchService.java create mode 100644 clams-server/src/main/resources/templates/blog-search.html (limited to 'clams-server/src') diff --git a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/Main.java b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/Main.java index e456e7b..5ba7c31 100644 --- a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/Main.java +++ b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/Main.java @@ -27,8 +27,8 @@ public class Main { config.routes.get("/projects", ctx -> ctx.render("templates/projects.html", Map.of("page_title", "Projects - Elias Haugsbakk", "page_css", "projects"))); + config.routes.get("/blog", blogController::handleBlogRequest); config.routes.get("/blog/{slug}", blogController::handleGetPost); - config.routes.get("/blog", blogController::handleGetOverview); config.events.serverStopped(dbManager::close); }).start(7070); diff --git a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/controller/BlogController.java b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/controller/BlogController.java index 9111a0a..b82c3fe 100644 --- a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/controller/BlogController.java +++ b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/controller/BlogController.java @@ -3,86 +3,123 @@ package no.eliashaugsbakk.clams.server.controller; import io.javalin.http.Context; import java.time.ZoneId; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; +import no.eliashaugsbakk.clams.server.model.Post; +import no.eliashaugsbakk.clams.server.model.PostMetaData; import no.eliashaugsbakk.clams.server.repository.BlogPostRepo; import no.eliashaugsbakk.clams.server.repository.BlogPostRepoSqlite; import no.eliashaugsbakk.clams.server.repository.SqliteManager; +import no.eliashaugsbakk.clams.server.service.BlogSearchService; import no.eliashaugsbakk.clams.server.utils.MarkdownConverter; public class BlogController { + private static final ZoneId OSLO_ZONE = ZoneId.of("Europe/Oslo"); + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("MMM dd, yyyy"); + private final BlogPostRepo blogPostRepo; + private final BlogSearchService blogSearchService; + + public record SearchResultItem(String title, String slug, String summary, String formattedDate) {} public BlogController(SqliteManager sqliteManager) { this.blogPostRepo = new BlogPostRepoSqlite(sqliteManager); + this.blogSearchService = new BlogSearchService(blogPostRepo); } public void handleGetPost(Context ctx) { - blogPostRepo.getPost(ctx.pathParam("slug")).ifPresentOrElse(post -> ctx.render( - "templates/post.html", - Map.of("page_title", post.title() + " -- Elias Haugsbakk", - "page_css", "post", - "title", post.title(), - "published_date", post.timePublished().atZone(ZoneId.of("Europe/Oslo")).format( - DateTimeFormatter.ofPattern("MMM dd, yyyy")), - "author", "by Elias Haugsbakk", - "content", MarkdownConverter.convertToHtml(post.content()), - "back_to_index", "back to all posts" - )), () -> ctx.status(404)); + var postOpt = blogPostRepo.getPost(ctx.pathParam("slug")); + + if (postOpt.isEmpty()) { + ctx.status(404); + return; + } + + Post post = postOpt.get(); + String formattedDate = post.timePublished().atZone(OSLO_ZONE).format(DATE_FORMATTER); + + ctx.render("templates/post.html", Map.of( + "page_title", post.title() + " -- Elias Haugsbakk", + "page_css", "post", + "title", post.title(), + "published_date", formattedDate, + "author", "by Elias Haugsbakk", + "content", MarkdownConverter.convertToHtml(post.content()), + "back_to_index", "back to all posts" + )); } - public void handleGetOverview(Context ctx) { - StringBuilder allPosts = new StringBuilder(); - - int prevYear = blogPostRepo.listPostsByPublishedDesc().getFirst().published() - .atZone(ZoneId.of("Europe/Oslo")).getYear(); - - allPosts.append(String.format(""" -
- %d - -
-
- %d - -
- """); + } + private void handleBlogIndex(Context ctx) { - var featuredSlugs = List.of("test-page", "another-one"); + List allPosts = blogPostRepo.listPostsMetaData(); + Map> postsByYear = groupPostsByYear(allPosts); - StringBuilder featuredPosts = new StringBuilder(); - featuredPosts.append("
    "); + // Manually list featured posts + var featuredSlugs = List.of( + "" + ); + List featuredPosts = new ArrayList<>(); for (String slug : featuredSlugs) { - blogPostRepo.getPost(slug).ifPresent(post -> featuredPosts.append(String.format(""" -
  • - %s -- %s -
  • - """, post.slug(), post.title(), post.summary()))); + blogPostRepo.getPost(slug).ifPresent(featuredPosts::add); } - featuredPosts.append("
"); ctx.render("templates/blog.html", - Map.of("page_title", "Elias Haugsbakk's Blog", "page_css", "blog", "all_posts", - allPosts.toString(), "recent_posts", featuredPosts.toString())); + Map.of( + "page_title", "Elias Haugsbakk's Blog", + "page_css", "blog", + "posts_by_year", postsByYear, + "featured_posts", featuredPosts, + "search_value", "" + )); + } + + private void handleSearch(Context ctx, String searchTerm) { + String query = searchTerm.trim(); + List results = query.isEmpty() ? List.of() : blogSearchService.searchPosts(query); + + List resultItems = results.stream() + .map(post -> new SearchResultItem( + post.title(), + post.slug(), + post.summary() != null ? post.summary() : "", + post.timePublished().atZone(OSLO_ZONE).format(DATE_FORMATTER) + )) + .toList(); + + ctx.render("templates/blog-search.html", + Map.of( + "page_title", "Search Results -- Elias Haugsbakk", + "page_css", "blog", + "search_term", query, + "results", resultItems, + "results_count", resultItems.size() + )); + } + + private Map> groupPostsByYear(List posts) { + Map> unsortedGroups = posts.stream() + .collect(Collectors.groupingBy( + post -> post.timePublished().atZone(OSLO_ZONE).getYear(), + Collectors.toList() + )); + + Map> sortedGroups = new TreeMap<>(Comparator.reverseOrder()); + sortedGroups.putAll(unsortedGroups); + + return sortedGroups; } } diff --git a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/model/PostMetaData.java b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/model/PostMetaData.java index 98a1c49..44d667d 100644 --- a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/model/PostMetaData.java +++ b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/model/PostMetaData.java @@ -2,5 +2,5 @@ package no.eliashaugsbakk.clams.server.model; import java.time.Instant; -public record PostMetaData(String title, String slug, Instant published, Instant lastEdit) { +public record PostMetaData(String title, String slug, String summary, Instant timePublished, Instant lastEdit) { } diff --git a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/BlogPostRepo.java b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/BlogPostRepo.java index d63e095..8e43e2c 100644 --- a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/BlogPostRepo.java +++ b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/BlogPostRepo.java @@ -6,6 +6,7 @@ import no.eliashaugsbakk.clams.server.model.Post; import no.eliashaugsbakk.clams.server.model.PostMetaData; public interface BlogPostRepo { - List listPostsByPublishedDesc(); + List listPostsMetaData(); Optional getPost(String slug); + List searchPostsBody(String query); } diff --git a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/BlogPostRepoSqlite.java b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/BlogPostRepoSqlite.java index 2979589..77a7407 100644 --- a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/BlogPostRepoSqlite.java +++ b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/repository/BlogPostRepoSqlite.java @@ -19,9 +19,9 @@ public class BlogPostRepoSqlite implements BlogPostRepo { } @Override - public List listPostsByPublishedDesc() { + public List listPostsMetaData() { String sql = """ - SELECT title, slug, published, last_edited + SELECT title, slug, summary, published, last_edited FROM posts ORDER BY published DESC """; @@ -35,13 +35,14 @@ public class BlogPostRepoSqlite implements BlogPostRepo { while (resultSet.next()) { String title = resultSet.getString("title"); String slug = resultSet.getString("slug"); + String summary = resultSet.getString("summary"); Instant published = Instant.parse(resultSet.getString("published")); String lastEditRaw = resultSet.getString("last_edited"); Instant lastEdit = (lastEditRaw != null) ? Instant.parse(lastEditRaw) : null; - posts.add(new PostMetaData(title, slug, published, lastEdit)); + posts.add(new PostMetaData(title, slug, summary, published, lastEdit)); } return posts; @@ -83,4 +84,40 @@ public class BlogPostRepoSqlite implements BlogPostRepo { throw new RepoException("Error fetching full post by slug: " + slug, e); } } + + @Override + public List searchPostsBody(String query) { + String sql = """ + SELECT title, slug, summary, published, last_edited + FROM posts + WHERE content LIKE ? + ORDER BY published DESC + """; + + List posts = new ArrayList<>(); + + try (Connection conn = manager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + + String likeParam = "%" + query + "%"; + stmt.setString(1, likeParam); + + try (ResultSet resultSet = stmt.executeQuery()) { + while (resultSet.next()) { + String title = resultSet.getString("title"); + String slug = resultSet.getString("slug"); + String summary = resultSet.getString("summary"); + Instant published = Instant.parse(resultSet.getString("published")); + Instant lastEdit = Instant.parse(resultSet.getString("last_edited")); + + posts.add(new PostMetaData(title, slug, summary, published, lastEdit)); + } + } + + return posts; + + } catch (SQLException e) { + throw new RepoException("Error searching posts for query: " + query, e); + } + } } diff --git a/clams-server/src/main/java/no/eliashaugsbakk/clams/server/service/BlogSearchService.java b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/service/BlogSearchService.java new file mode 100644 index 0000000..26595f1 --- /dev/null +++ b/clams-server/src/main/java/no/eliashaugsbakk/clams/server/service/BlogSearchService.java @@ -0,0 +1,49 @@ +package no.eliashaugsbakk.clams.server.service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import no.eliashaugsbakk.clams.server.model.PostMetaData; +import no.eliashaugsbakk.clams.server.repository.BlogPostRepo; + +public class BlogSearchService { + private final BlogPostRepo blogPostRepo; + + public BlogSearchService(BlogPostRepo blogPostRepo) { + this.blogPostRepo = blogPostRepo; + } + + public List searchPosts(String query) { + if (query == null || query.isBlank()) return List.of(); + + int titleValue = 10; + int summaryValue = 5; + int bodyValue = 1; + + Map searchRanking = new HashMap<>(); + String lowerQuery = query.toLowerCase(); + + List postsMetaData = blogPostRepo.listPostsMetaData(); + List bodyResults = blogPostRepo.searchPostsBody(query); + + postsMetaData.stream() + .filter(post -> post.title().toLowerCase().contains(lowerQuery)) + .forEach(post -> searchRanking.merge(post, titleValue, Integer::sum)); + + postsMetaData.stream() + .filter(post -> post.summary() != null && post.summary().toLowerCase().contains(lowerQuery)) + .forEach(post -> searchRanking.merge(post, summaryValue, Integer::sum)); + + bodyResults.forEach(post -> searchRanking.merge(post, bodyValue, Integer::sum)); + + for (var item : searchRanking.entrySet()) { + IO.println(item.getKey()); + IO.println(item.getValue()); + } + + return searchRanking.entrySet().stream() + .sorted((e1, e2) -> Integer.compare(e2.getValue(), e1.getValue())) + .map(Map.Entry::getKey) + .toList(); + } +} diff --git a/clams-server/src/main/resources/public/css/blog.css b/clams-server/src/main/resources/public/css/blog.css index c0fd080..f323de4 100644 --- a/clams-server/src/main/resources/public/css/blog.css +++ b/clams-server/src/main/resources/public/css/blog.css @@ -1,3 +1,13 @@ +.content-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 60px; + margin-top: 30px; +} + +.left-column { text-align: right; } +.right-column { text-align: left; } + .intro-text { text-align: center; max-width: 600px; @@ -41,17 +51,6 @@ background: #ddd; } -@media (max-width: 850px) { - .search-form { - position: static; - text-align: center; - margin-bottom: 40px; - } - .search-form input { - width: 250px; - } -} - .year-accordion { margin-bottom: 1.5rem; cursor: pointer; @@ -92,3 +91,69 @@ color: var(--text-color); opacity: 0.85; } + +.search-results-container { + max-width: 600px; + margin: 30px auto 0 auto; +} + +.search-results-list { + list-style-type: none; + padding: 0; + margin: 0; +} + +.search-result-item { + margin-bottom: 1.25rem; + font-size: 1rem; + line-height: 1.6; +} + +.search-result-date { + display: inline-block; + font-size: 0.9rem; + color: var(--text-color); + opacity: 0.65; + margin-right: 15px; + min-width: 110px; +} + +.search-result-item a { + font-size: 1rem; +} + +.search-result-summary { + font-weight: normal; + color: var(--text-color); + opacity: 0.85; +} + +.no-results { + padding: 20px; + background: #e8e8e4; + border-radius: 4px; + text-align: center; + font-style: italic; +} + +.back-to-blog { + margin-top: 40px; + display: inline-block; +} + +@media (max-width: 768px) { + .content-grid { + grid-template-columns: 1fr; + gap: 40px; + } + .left-column, .right-column { text-align: center; } + + .search-form { + position: static; + text-align: center; + margin-bottom: 40px; + } + .search-form input { + width: 250px; + } +} diff --git a/clams-server/src/main/resources/public/css/common.css b/clams-server/src/main/resources/public/css/common.css index ffed73f..f5f6d2a 100644 --- a/clams-server/src/main/resources/public/css/common.css +++ b/clams-server/src/main/resources/public/css/common.css @@ -1,10 +1,19 @@ * { box-sizing: border-box; } :root { - --bg-color: #efefeb; - --text-color: #373838; + --bg-color: #f4f4f0; + --text-color: #363737; --border-color: #ccc; - --font-serif: "Times New Roman", serif; + --font-serif: "Spectral", Georgia, serif; + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; +} + +h1, h2 { + font-family: var(--font-sans), serif; +} + +html { + scrollbar-gutter: stable; } html, body { @@ -43,20 +52,9 @@ h2 { border-bottom: 1px solid var(--border-color); margin-bottom: 20px; padding-bottom: 5px; - text-transform: uppercase; letter-spacing: 1px; } -.content-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 60px; - margin-top: 30px; -} - -.left-column { text-align: right; } -.right-column { text-align: left; } - .main-container ul { list-style-type: none; padding: 0; @@ -106,13 +104,7 @@ footer p { opacity: 0.5; } -@media (max-width: 600px) { - .content-grid { - grid-template-columns: 1fr; - gap: 40px; - } - .left-column, .right-column { text-align: center; } - +@media (max-width: 768px) { footer p { flex-direction: column; gap: 10px; @@ -148,3 +140,29 @@ footer p { scrollbar-width: thin; scrollbar-color: #d1d1cd var(--bg-color); } + +.back-link-top { + position: absolute; + top: 20px; + left: 20px; + font-size: 0.85rem; + text-decoration: none; + font-weight: bold; + opacity: 0.7; + transition: opacity 0.2s; +} + +.back-link-top:hover { + opacity: 1; +} + +@media (max-width: 768px) { + .back-link-top { + position: static; + display: block; + margin-top: 10px; + margin-bottom: 20px; + text-align: left; + } +} + diff --git a/clams-server/src/main/resources/public/css/home.css b/clams-server/src/main/resources/public/css/home.css index 2cf0bd8..742ed38 100644 --- a/clams-server/src/main/resources/public/css/home.css +++ b/clams-server/src/main/resources/public/css/home.css @@ -1,3 +1,13 @@ +.content-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 60px; + margin-top: 30px; +} + +.left-column { text-align: right; } +.right-column { text-align: left; } + .profile-img { border: 3px solid var(--text-color); border-radius: 20px; @@ -16,7 +26,13 @@ word-wrap: break-word; } -@media (max-width: 600px) { +@media (max-width: 768px) { + .content-grid { + grid-template-columns: 1fr; + gap: 40px; + } + .left-column, .right-column { text-align: center; } + .profile-img { margin: 0 auto 20px auto; } diff --git a/clams-server/src/main/resources/public/css/post.css b/clams-server/src/main/resources/public/css/post.css index 8c4b91d..6a237e1 100644 --- a/clams-server/src/main/resources/public/css/post.css +++ b/clams-server/src/main/resources/public/css/post.css @@ -1,6 +1,7 @@ .post-header { display: flex; justify-content: space-between; + margin-top: 45px; margin-bottom: 40px; font-size: 0.9rem; opacity: 0.7; @@ -39,8 +40,9 @@ border-top: 1px dashed var(--border-color); } -@media (max-width: 600px) { +@media (max-width: 768px) { .post-header { + margin-top: 10px; flex-direction: column; align-items: center; gap: 10px; diff --git a/clams-server/src/main/resources/public/css/projects.css b/clams-server/src/main/resources/public/css/projects.css index 4886a6d..3e5ccc0 100644 --- a/clams-server/src/main/resources/public/css/projects.css +++ b/clams-server/src/main/resources/public/css/projects.css @@ -1,3 +1,13 @@ +.content-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 60px; + margin-top: 30px; +} + +.left-column { text-align: right; } +.right-column { text-align: left; } + .projects-category { margin-bottom: 80px; } @@ -63,3 +73,11 @@ opacity: 0.6; margin: 0 10px; } + +@media (max-width: 768px) { + .content-grid { + grid-template-columns: 1fr; + gap: 40px; + } + .left-column, .right-column { text-align: center; } +} diff --git a/clams-server/src/main/resources/templates/blog-search.html b/clams-server/src/main/resources/templates/blog-search.html new file mode 100644 index 0000000..e2188cf --- /dev/null +++ b/clams-server/src/main/resources/templates/blog-search.html @@ -0,0 +1,41 @@ +{% extends "templates/layout.html" %} + +{% block content %} +
+ ← back +
+

Elias Haugsbakk's Blog

+

For search: "{{ search_term }}" -- Found {{ results_count }} matching posts

+ +
+ + +
+
+ +
+ {% if results is empty %} +
+ {% if search_term is empty %} +

Please enter a search term to search the blog posts. Or go back to all posts.

+ {% else %} +

Found no posts matching "{{ search_term }}".
+ Go back to all posts.

+ {% endif %} +
+ {% else %} +
    + {% for post in results %} +
  • + {{ post.formattedDate }} + {{ post.title }} + {% if post.summary is not empty %} + -- {{ post.summary }} + {% endif %} +
  • + {% endfor %} +
+ {% endif %} +
+
+{% endblock %} diff --git a/clams-server/src/main/resources/templates/blog.html b/clams-server/src/main/resources/templates/blog.html index 200a144..2c484a1 100644 --- a/clams-server/src/main/resources/templates/blog.html +++ b/clams-server/src/main/resources/templates/blog.html @@ -4,10 +4,12 @@

Elias Haugsbakk's Blog

-

This is the home page for my blog. The list of blog posts is a diverse collection of some of my writings; if you're just browsing, this might be a good place to start.

+

This is the home page for my blog. The featured list is a diverse collection of some + of my writings; if you're just browsing, this might be a good place to start.

- +
@@ -16,14 +18,29 @@

All Posts

- {{ all_posts| raw }} + {% for group in posts_by_year %} +
+ {{ group.key }} + +
+ {% endfor %}

Featured

- {{ recent_posts | raw }} +
    + {% for post in featured_posts %} +
  • + {{ post.title }} -- {{ post.summary }} +
  • + {% endfor %} +
diff --git a/clams-server/src/main/resources/templates/layout.html b/clams-server/src/main/resources/templates/layout.html index 4c8b8dc..1460721 100644 --- a/clams-server/src/main/resources/templates/layout.html +++ b/clams-server/src/main/resources/templates/layout.html @@ -6,6 +6,11 @@ {{ page_title }} + + + + + diff --git a/clams-server/src/main/resources/templates/post.html b/clams-server/src/main/resources/templates/post.html index 7e99e1f..7899099 100644 --- a/clams-server/src/main/resources/templates/post.html +++ b/clams-server/src/main/resources/templates/post.html @@ -2,6 +2,7 @@ {% block content %}
+ ← back
-- cgit v1.2.3