Compare commits
4 commits
a7a270101d
...
1675efb514
Author | SHA1 | Date | |
---|---|---|---|
1675efb514 | |||
4f3e1d3a8d | |||
464f3a0ae3 | |||
1c17c6f508 |
34 changed files with 1064 additions and 189 deletions
CLAUDE.md
config
lib
core.ex
mix.exsmix.lockcore
schema
web
components
controllers
blog_controller.exblog_html.ex
blog_html
page_controller.expage_html
post_controller.expost_html.expost_html
status_controller.exstatus_html.exstatus_html
live
markdown.expaths.exrouter.exprompts
23
CLAUDE.md
Normal file
23
CLAUDE.md
Normal file
|
@ -0,0 +1,23 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Build/Test Commands
|
||||
- Setup: `mix setup`, `mix deps.get`, `mix ecto.setup`
|
||||
- Run server: `mix phx.server`
|
||||
- Tests: `mix test`, `mix test path/to/test_file.exs`, `mix test path/to/test_file.exs:line_number`
|
||||
- Format/Lint: `mix format`, `mix credo`
|
||||
- Assets: `mix assets.setup`, `mix assets.build`, `mix assets.deploy`
|
||||
|
||||
## Code Style Guidelines
|
||||
- Formatting: Max line length 120, use `mix format`
|
||||
- Naming: snake_case for variables/functions, descriptive module namespacing (Core.Accounts.User)
|
||||
- Organization: Boundary-driven modules (core/, schema/, web/)
|
||||
- Error handling: Return {:ok, result} or {:error, changeset} tuples, pattern matching
|
||||
- Testing: Descriptive test names, fixtures, shared setup, tagged tests
|
||||
- Imports: Organize at top, import frequently used functions, follow boundary pattern
|
||||
- Documentation: `@doc` for public functions, `@moduledoc false` for internal modules
|
||||
- Use `@impl` for callback implementations
|
||||
- Styling: Prefer CSS/tailwind based styling using things like group, first:, focus:, hover:, etc. to any sort of dynamic classes using Elixir or JavaScript
|
||||
- All data should be microformat2 compatible
|
||||
- Use lists of classes instead of string interpolation
|
|
@ -30,7 +30,13 @@ config :sloanely_but_surely, Web.Endpoint,
|
|||
config :sloanely_but_surely,
|
||||
namespace: Core,
|
||||
ecto_repos: [Core.Repo],
|
||||
generators: [timestamp_type: :utc_datetime_usec, binary_id: true]
|
||||
generators: [timestamp_type: :utc_datetime_usec, binary_id: true],
|
||||
author: %{
|
||||
name: "Sloane",
|
||||
username: "sloanelybutsurely",
|
||||
avatar_emoji: "💜",
|
||||
avatar_url: nil
|
||||
}
|
||||
|
||||
config :sloanely_but_surely, Core.Repo, migration_timestamps: [type: :utc_datetime_usec]
|
||||
|
||||
|
@ -45,4 +51,6 @@ config :tailwind,
|
|||
cd: Path.expand("../assets", __DIR__)
|
||||
]
|
||||
|
||||
config :flop, repo: Core.Repo
|
||||
|
||||
import_config "#{config_env()}.exs"
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
defmodule Core do
|
||||
@moduledoc false
|
||||
use Boundary, deps: [Schema], exports: [Accounts, Posts]
|
||||
use Boundary, deps: [Schema], exports: [Accounts, Posts, Author]
|
||||
end
|
||||
|
|
19
lib/core/author.ex
Normal file
19
lib/core/author.ex
Normal file
|
@ -0,0 +1,19 @@
|
|||
defmodule Core.Author do
|
||||
@moduledoc false
|
||||
|
||||
@author_config Application.compile_env!(:sloanely_but_surely, :author)
|
||||
|
||||
@doc """
|
||||
Returns the author configuration.
|
||||
"""
|
||||
def get do
|
||||
@author_config
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a specific attribute from the author configuration.
|
||||
"""
|
||||
def get(attribute) when is_atom(attribute) do
|
||||
Map.get(@author_config, attribute)
|
||||
end
|
||||
end
|
|
@ -37,6 +37,19 @@ defmodule Core.Posts do
|
|||
|> Core.Repo.all()
|
||||
end
|
||||
|
||||
def list_posts(kind, params \\ %{}) do
|
||||
Post.Query.base()
|
||||
|> Post.Query.where_kind(kind)
|
||||
|> Flop.validate_and_run(params, for: Schema.Post)
|
||||
end
|
||||
|
||||
def list_published_posts(kind, params \\ %{}) do
|
||||
Post.Query.base()
|
||||
|> Post.Query.published()
|
||||
|> Post.Query.where_kind(kind)
|
||||
|> Flop.validate_and_run(params, for: Schema.Post)
|
||||
end
|
||||
|
||||
def publish_date(%Schema.Post{published_at: nil}), do: nil
|
||||
|
||||
def publish_date(%Schema.Post{published_at: published_at}) do
|
||||
|
|
|
@ -2,6 +2,20 @@ defmodule Schema.Post do
|
|||
@moduledoc false
|
||||
use Schema
|
||||
|
||||
@derive {
|
||||
Flop.Schema,
|
||||
filterable: [],
|
||||
sortable: [:published_at, :id],
|
||||
pagination_types: [:first, :last],
|
||||
default_order: %{
|
||||
order_by: [:published_at, :id],
|
||||
order_directions: [:desc, :desc]
|
||||
},
|
||||
default_pagination_type: :first,
|
||||
default_limit: 20,
|
||||
max_limit: 50
|
||||
}
|
||||
|
||||
@post_kinds ~w[status blog]a
|
||||
|
||||
schema "posts" do
|
||||
|
|
|
@ -154,43 +154,94 @@ defmodule Web.CoreComponents do
|
|||
Module.concat(Timex.Format.DateTime.Formatters, :string.titlecase("#{formatter}"))
|
||||
end
|
||||
|
||||
attr :id, :string, required: true
|
||||
attr :posts, :any, required: true
|
||||
@doc """
|
||||
Renders markdown content as HTML.
|
||||
|
||||
slot :inner_block, required: true
|
||||
## Examples
|
||||
|
||||
def post_list(assigns) do
|
||||
<.markdown content={@post.body} />
|
||||
"""
|
||||
attr :content, :string, required: true
|
||||
attr :class, :string, default: ""
|
||||
|
||||
def markdown(assigns) do
|
||||
~H"""
|
||||
<ol id={@id} phx-update={if is_struct(@posts, Phoenix.LiveView.LiveStream), do: "phx-update"}>
|
||||
<li
|
||||
:for={{dom_id, item} <- normalize_posts(@posts)}
|
||||
id={dom_id}
|
||||
class="flex flex-row justify-between"
|
||||
>
|
||||
<span>{render_slot(@inner_block, item)}</span>
|
||||
<span>
|
||||
<%= if item.deleted_at do %>
|
||||
deleted
|
||||
<% else %>
|
||||
<%= if item.published_at do %>
|
||||
<%= case item.kind do %>
|
||||
<% :blog -> %>
|
||||
<.timex value={item.published_at} format="{YYYY}-{0M}-{0D}" />
|
||||
<% :status -> %>
|
||||
<.timex value={item.published_at} format="{relative}" formatter={:relative} />
|
||||
<% end %>
|
||||
<% else %>
|
||||
draft
|
||||
<% end %>
|
||||
<% end %>
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
<div class={["markdown-content", @class]}>
|
||||
{Phoenix.HTML.raw(Web.Markdown.to_html(@content))}
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp normalize_posts(%Phoenix.LiveView.LiveStream{} = stream), do: stream
|
||||
@doc """
|
||||
Renders pagination controls for navigating through a paginated list.
|
||||
|
||||
defp normalize_posts(posts) when is_list(posts),
|
||||
do: Enum.with_index(posts, &{"#{&1.kind}-#{&2}", &1})
|
||||
## Examples
|
||||
|
||||
<.pagination meta={@meta} path={~p"/blog"} schema={Schema.Post} />
|
||||
"""
|
||||
attr :meta, :map, required: true, doc: "the pagination metadata from Flop"
|
||||
attr :path, :string, required: true, doc: "the base path for pagination links"
|
||||
attr :schema, :atom, required: true, doc: "the schema module for Flop.Phoenix.build_path"
|
||||
attr :class, :string, default: "mt-4", doc: "additional CSS classes"
|
||||
|
||||
def pagination(assigns) do
|
||||
~H"""
|
||||
<div class={["flex justify-between", @class]}>
|
||||
<%= if @meta.has_previous_page? do %>
|
||||
<.link
|
||||
navigate={Flop.Phoenix.build_path(@path, Flop.to_previous_cursor(@meta), for: @schema)}
|
||||
class="text-gray-500 hover:text-gray-800"
|
||||
>
|
||||
Newer posts
|
||||
</.link>
|
||||
<% else %>
|
||||
<div></div>
|
||||
<% end %>
|
||||
|
||||
<%= if @meta.has_next_page? do %>
|
||||
<.link
|
||||
navigate={Flop.Phoenix.build_path(@path, Flop.to_next_cursor(@meta), for: @schema)}
|
||||
class="text-gray-500 hover:text-gray-800"
|
||||
>
|
||||
Older posts
|
||||
</.link>
|
||||
<% else %>
|
||||
<div></div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :id, :string, required: true
|
||||
attr :stream, Phoenix.LiveView.LiveStream, required: true
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
|
||||
slot :col do
|
||||
attr :label, :string
|
||||
attr :class, :string
|
||||
end
|
||||
|
||||
def table(assigns) do
|
||||
~H"""
|
||||
<table id={@id} class={["border-collapse", @class]} {@rest}>
|
||||
<thead>
|
||||
<tr>
|
||||
<%= for col <- @col do %>
|
||||
<th class="border p-2">{col[:label]}</th>
|
||||
<% end %>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id={"#{@id}-stream"} phx-update="stream">
|
||||
<%= for {dom_id, item} <- @stream do %>
|
||||
<tr id={dom_id}>
|
||||
<%= for col <- @col do %>
|
||||
<td class={["border p-2", col[:class]]}>{render_slot(col, item)}</td>
|
||||
<% end %>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,20 +1,14 @@
|
|||
<div class="sticky top-0 z-50 flex flex-row bg-white justify-between py-1 md:mb-2 border-b border-gray-200">
|
||||
<div class="sticky top-0 z-50 flex flex-row bg-white justify-between py-1 border-b border-gray-200">
|
||||
<div class="flex flex-col md:flex-row">
|
||||
<section class="flex flex-row gap-x-2 md:border-r border-gray-200 px-2">
|
||||
<section class="flex flex-row gap-x-2 px-2">
|
||||
<.link navigate={~p"/"} class="font-bold group">
|
||||
💜 <span class="group-hover:underline">sloanelybutsurely.com</span>
|
||||
</.link>
|
||||
<nav>
|
||||
<ul class="flex flex-row gap-x-2">
|
||||
<li><.link href={~p"/blog"}>writing</.link></li>
|
||||
<li><.link href={~p"/microblog"}>microblog</.link></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</section>
|
||||
<section :if={not is_nil(@current_user)} class="ml-2">
|
||||
<nav>
|
||||
<ul class="flex flex-row gap-x-2">
|
||||
<li><.link navigate={~p"/admin"}>admin</.link></li>
|
||||
<li><.link navigate={~p"/admin/writing"}>admin</.link></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</section>
|
||||
|
@ -29,6 +23,6 @@
|
|||
<% end %>
|
||||
</div>
|
||||
|
||||
<main class="p-2 max-w-2xl mx-auto">
|
||||
<main class="mx-auto">
|
||||
{@inner_content}
|
||||
</main>
|
||||
|
|
29
lib/web/controllers/blog_controller.ex
Normal file
29
lib/web/controllers/blog_controller.ex
Normal file
|
@ -0,0 +1,29 @@
|
|||
defmodule Web.BlogController do
|
||||
use Web, :controller
|
||||
|
||||
def index(conn, params) do
|
||||
{:ok, {blogs, meta}} = Core.Posts.list_published_posts(:blog, params)
|
||||
|
||||
blogs_by_year =
|
||||
blogs
|
||||
|> Enum.group_by(fn blog ->
|
||||
date = Core.Posts.publish_date(blog)
|
||||
date && date.year
|
||||
end)
|
||||
|> Enum.sort_by(fn {year, _} -> year end, :desc)
|
||||
|
||||
render(conn, :index, blogs_by_year: blogs_by_year, meta: meta)
|
||||
end
|
||||
|
||||
def show(conn, %{"year" => year, "month" => month, "day" => day, "slug" => slug}) do
|
||||
with {year, ""} <- Integer.parse(year),
|
||||
{month, ""} <- Integer.parse(month),
|
||||
{day, ""} <- Integer.parse(day),
|
||||
{:ok, publish_date} <- Date.new(year, month, day) do
|
||||
blog = Core.Posts.get_published_blog!(publish_date, slug)
|
||||
render(conn, :show, blog: blog)
|
||||
else
|
||||
_ -> raise Ecto.NoResultsError
|
||||
end
|
||||
end
|
||||
end
|
5
lib/web/controllers/blog_html.ex
Normal file
5
lib/web/controllers/blog_html.ex
Normal file
|
@ -0,0 +1,5 @@
|
|||
defmodule Web.BlogHTML do
|
||||
use Web, :html
|
||||
|
||||
embed_templates "blog_html/*"
|
||||
end
|
32
lib/web/controllers/blog_html/index.html.heex
Normal file
32
lib/web/controllers/blog_html/index.html.heex
Normal file
|
@ -0,0 +1,32 @@
|
|||
<div class="max-w-2xl mx-auto">
|
||||
<div class="p-4">
|
||||
<h1 class="text-2xl font-bold mb-6 mt-2">Writing</h1>
|
||||
|
||||
<div class="h-feed">
|
||||
<%= for {year, blogs} <- @blogs_by_year do %>
|
||||
<div class="mb-8">
|
||||
<h2 class="text-lg font-semibold mb-4">{year}</h2>
|
||||
<ul>
|
||||
<%= for blog <- blogs do %>
|
||||
<li>
|
||||
<article class="h-entry">
|
||||
<.link
|
||||
navigate={Web.Paths.public_blog_path(blog)}
|
||||
class="flex justify-between items-center hover:bg-gray-50 -mx-2 px-2 py-1 rounded"
|
||||
>
|
||||
<h3 class="p-name u-url">{blog.title}</h3>
|
||||
<div class="text-gray-500 dt-published">
|
||||
<.timex value={blog.published_at} format="{Mfull} {D}" />
|
||||
</div>
|
||||
</.link>
|
||||
</article>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<.pagination meta={@meta} path={~p"/blog"} schema={Schema.Post} class="my-2" />
|
||||
</div>
|
||||
</div>
|
18
lib/web/controllers/blog_html/show.html.heex
Normal file
18
lib/web/controllers/blog_html/show.html.heex
Normal file
|
@ -0,0 +1,18 @@
|
|||
<div class="p-4 max-w-2xl mx-auto">
|
||||
<article class="h-entry mt-2">
|
||||
<header class="mb-4">
|
||||
<h1 class="p-name text-2xl font-bold mb-2">{@blog.title}</h1>
|
||||
<%= if @blog.published_at do %>
|
||||
<div class="text-sm text-gray-500 flex items-center">
|
||||
<span class="p-author h-card">{Core.Author.get(:name)}</span>
|
||||
<span class="mx-2">·</span>
|
||||
<.timex value={@blog.published_at} format="{Mfull} {D}, {YYYY}" class="dt-published" />
|
||||
</div>
|
||||
<% end %>
|
||||
</header>
|
||||
|
||||
<div class="e-content">
|
||||
<.markdown content={@blog.body} />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
|
@ -2,6 +2,12 @@ defmodule Web.PageController do
|
|||
use Web, :controller
|
||||
|
||||
def home(conn, _params) do
|
||||
render(conn, :home)
|
||||
# Get recent blog posts
|
||||
recent_blogs = Core.Posts.get_published_recent_posts(:blog)
|
||||
|
||||
# Get recent statuses
|
||||
recent_statuses = Core.Posts.get_published_recent_posts(:status)
|
||||
|
||||
render(conn, :home, recent_blogs: recent_blogs, recent_statuses: recent_statuses)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1 +1,94 @@
|
|||
<h1>Home</h1>
|
||||
<div class="max-w-2xl mx-auto mt-2">
|
||||
<!-- Recent Blog Posts -->
|
||||
<section class="mb-8 p-4">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold">Writing</h2>
|
||||
<.link navigate={~p"/blog"} class="text-sm text-gray-500 hover:text-gray-800">
|
||||
See all →
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
<%= for blog <- @recent_blogs do %>
|
||||
<li>
|
||||
<article class="h-entry">
|
||||
<.link
|
||||
navigate={Web.Paths.public_blog_path(blog)}
|
||||
class="flex justify-between items-center hover:bg-gray-50 -mx-2 px-2 py-1 rounded"
|
||||
>
|
||||
<h3 class="p-name u-url">{blog.title}</h3>
|
||||
<div class="text-gray-500 dt-published">
|
||||
<.timex value={blog.published_at} format="{Mfull} {D}" />
|
||||
</div>
|
||||
</.link>
|
||||
</article>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Recent Statuses -->
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<section>
|
||||
<div class="flex justify-between items-center mb-4 py-2 px-4">
|
||||
<h2 class="text-xl font-bold">Microblog</h2>
|
||||
<.link navigate={~p"/microblog"} class="text-sm text-gray-500 hover:text-gray-800">
|
||||
See all →
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<div class="border border-gray-200">
|
||||
<%= for status <- @recent_statuses do %>
|
||||
<.link
|
||||
navigate={~p"/status/#{status.id}"}
|
||||
class="block border-t first:border-t-0 border-gray-200 hover:bg-gray-50 u-url"
|
||||
>
|
||||
<article class="h-entry py-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 pl-3 pr-2">
|
||||
<%= if Core.Author.get(:avatar_url) do %>
|
||||
<img
|
||||
src={Core.Author.get(:avatar_url)}
|
||||
alt={Core.Author.get(:name)}
|
||||
class="w-10 h-10 rounded-full u-photo"
|
||||
/>
|
||||
<% else %>
|
||||
<div class="w-10 h-10 rounded-full bg-purple-100 flex items-center justify-center text-xl u-photo">
|
||||
{Core.Author.get(:avatar_emoji)}
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow min-w-0 pl-1 pr-3">
|
||||
<div class="flex items-center mb-1">
|
||||
<p class="p-name font-bold">
|
||||
{Core.Author.get(:name)}
|
||||
</p>
|
||||
<p class="ml-1 inline">
|
||||
<span class="p-nickname font-normal text-gray-600">
|
||||
@{Core.Author.get(:username)}
|
||||
</span>
|
||||
</p>
|
||||
<span class="mx-2 text-sm text-gray-500">·</span>
|
||||
<div class="text-sm text-gray-500">
|
||||
<.timex
|
||||
value={status.published_at}
|
||||
format="{relative}"
|
||||
formatter={:relative}
|
||||
class="dt-published"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="e-content">
|
||||
<.markdown content={status.body} class="text-gray-900" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</.link>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
defmodule Web.PostController do
|
||||
use Web, :controller
|
||||
|
||||
def show(conn, %{"year" => year, "month" => month, "day" => day, "slug" => slug}) do
|
||||
with {year, ""} <- Integer.parse(year),
|
||||
{month, ""} <- Integer.parse(month),
|
||||
{day, ""} <- Integer.parse(day),
|
||||
{:ok, publish_date} <- Date.new(year, month, day) do
|
||||
post = Core.Posts.get_published_blog!(publish_date, slug)
|
||||
|
||||
conn
|
||||
|> render_post(post)
|
||||
else
|
||||
_ -> raise Ecto.NoResultsError
|
||||
end
|
||||
end
|
||||
|
||||
def show(conn, %{"status_id" => status_id}) do
|
||||
status = Core.Posts.get_published_status!(status_id)
|
||||
|
||||
conn
|
||||
|> render_post(status)
|
||||
end
|
||||
|
||||
def index(%{assigns: %{kind: kind}} = conn, _params) when kind in ~w[blog status]a do
|
||||
posts = Core.Posts.get_published_recent_posts(kind)
|
||||
|
||||
render(conn, :index, posts: posts)
|
||||
end
|
||||
|
||||
defp render_post(conn, %Schema.Post{kind: :blog} = blog) do
|
||||
render(conn, :show_blog, blog: blog)
|
||||
end
|
||||
|
||||
defp render_post(conn, %Schema.Post{kind: :status} = status) do
|
||||
render(conn, :show_status, status: status)
|
||||
end
|
||||
end
|
|
@ -1,11 +0,0 @@
|
|||
defmodule Web.PostHTML do
|
||||
use Web, :html
|
||||
|
||||
embed_templates "post_html/*"
|
||||
|
||||
def blog_path(%Schema.Post{} = blog) do
|
||||
if date = Core.Posts.publish_date(blog) do
|
||||
~p"/blog/#{date.year}/#{date.month}/#{date.day}/#{blog.slug}"
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,8 +0,0 @@
|
|||
<.post_list :let={post} id="recent-posts" posts={@posts}>
|
||||
<%= case post.kind do %>
|
||||
<% :blog -> %>
|
||||
<.link navigate={blog_path(post)}>{post.title}</.link>
|
||||
<% :status -> %>
|
||||
{post.body}
|
||||
<% end %>
|
||||
</.post_list>
|
|
@ -1,9 +0,0 @@
|
|||
<article>
|
||||
<header class="mb-2">
|
||||
<h1 class="text-2xl font-bold">{@blog.title}</h1>
|
||||
</header>
|
||||
|
||||
<p>{@blog.body}</p>
|
||||
|
||||
<%!-- <footer class="mt-4 border-t border-gray-200"></footer> --%>
|
||||
</article>
|
|
@ -1,5 +0,0 @@
|
|||
<article>
|
||||
<p>{@status.body}</p>
|
||||
|
||||
<%!-- <footer class="mt-4 border-t border-gray-200"></footer> --%>
|
||||
</article>
|
13
lib/web/controllers/status_controller.ex
Normal file
13
lib/web/controllers/status_controller.ex
Normal file
|
@ -0,0 +1,13 @@
|
|||
defmodule Web.StatusController do
|
||||
use Web, :controller
|
||||
|
||||
def index(conn, params) do
|
||||
{:ok, {statuses, meta}} = Core.Posts.list_published_posts(:status, params)
|
||||
render(conn, :index, statuses: statuses, meta: meta)
|
||||
end
|
||||
|
||||
def show(conn, %{"status_id" => status_id}) do
|
||||
status = Core.Posts.get_published_status!(status_id)
|
||||
render(conn, :show, status: status)
|
||||
end
|
||||
end
|
69
lib/web/controllers/status_html.ex
Normal file
69
lib/web/controllers/status_html.ex
Normal file
|
@ -0,0 +1,69 @@
|
|||
defmodule Web.StatusHTML do
|
||||
use Web, :html
|
||||
|
||||
embed_templates "status_html/*"
|
||||
|
||||
attr :status, :map, required: true
|
||||
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global
|
||||
|
||||
def status_entry(assigns) do
|
||||
~H"""
|
||||
<.link
|
||||
navigate={~p"/status/#{@status.id}"}
|
||||
class="block border-x border-gray-200 border-t hover:bg-gray-50 u-url"
|
||||
>
|
||||
<article
|
||||
class={[
|
||||
"h-entry py-4",
|
||||
@class
|
||||
]}
|
||||
{@rest}
|
||||
>
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 pl-3 pr-2">
|
||||
<%= if Core.Author.get(:avatar_url) do %>
|
||||
<img
|
||||
src={Core.Author.get(:avatar_url)}
|
||||
alt={Core.Author.get(:name)}
|
||||
class="w-10 h-10 rounded-full u-photo"
|
||||
/>
|
||||
<% else %>
|
||||
<div class="w-10 h-10 rounded-full bg-purple-100 flex items-center justify-center text-xl u-photo">
|
||||
{Core.Author.get(:avatar_emoji)}
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow min-w-0 pl-1 pr-3">
|
||||
<div class="flex items-center mb-1">
|
||||
<p class="p-name font-bold">
|
||||
{Core.Author.get(:name)}
|
||||
</p>
|
||||
<p class="ml-1 inline">
|
||||
<span class="p-nickname font-normal text-gray-600">
|
||||
@{Core.Author.get(:username)}
|
||||
</span>
|
||||
</p>
|
||||
<span class="mx-2 text-sm text-gray-500">·</span>
|
||||
<div class="text-sm text-gray-500">
|
||||
<.timex
|
||||
value={@status.published_at}
|
||||
format="{relative}"
|
||||
formatter={:relative}
|
||||
class="dt-published"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="e-content">
|
||||
<.markdown content={@status.body} class="text-gray-900" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</.link>
|
||||
"""
|
||||
end
|
||||
end
|
13
lib/web/controllers/status_html/index.html.heex
Normal file
13
lib/web/controllers/status_html/index.html.heex
Normal file
|
@ -0,0 +1,13 @@
|
|||
<div class="max-w-2xl mx-auto">
|
||||
<div class="flex items-center py-2 px-4 border-x border-gray-200">
|
||||
<h1 class="text-lg font-bold">Microblog</h1>
|
||||
</div>
|
||||
|
||||
<div class="h-feed border-b border-gray-200">
|
||||
<%= for status <- @statuses do %>
|
||||
<.status_entry status={status} />
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<.pagination meta={@meta} path={~p"/microblog"} schema={Schema.Post} class="my-2" />
|
||||
</div>
|
53
lib/web/controllers/status_html/show.html.heex
Normal file
53
lib/web/controllers/status_html/show.html.heex
Normal file
|
@ -0,0 +1,53 @@
|
|||
<div class="max-w-2xl mx-auto">
|
||||
<div class="flex items-center py-2 px-4 border-x border-gray-200">
|
||||
<.link
|
||||
navigate={~p"/microblog"}
|
||||
class="mr-4 text-gray-500 hover:text-gray-800 flex items-center"
|
||||
>
|
||||
<.icon name="hero-arrow-left" class="h-5 w-5" />
|
||||
</.link>
|
||||
<h1 class="text-lg font-bold">Post</h1>
|
||||
</div>
|
||||
|
||||
<article class="h-entry p-4 border border-gray-200">
|
||||
<header class="flex items-center mb-3">
|
||||
<div class="h-card flex items-center gap-3 p-author">
|
||||
<%= if Core.Author.get(:avatar_url) do %>
|
||||
<img
|
||||
src={Core.Author.get(:avatar_url)}
|
||||
alt={Core.Author.get(:name)}
|
||||
class="w-10 h-10 rounded-full u-photo"
|
||||
/>
|
||||
<% else %>
|
||||
<div class="w-10 h-10 rounded-full bg-purple-100 flex items-center justify-center text-xl u-photo">
|
||||
{Core.Author.get(:avatar_emoji)}
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="flex flex-col">
|
||||
<p class="p-name font-bold">
|
||||
{Core.Author.get(:name)}
|
||||
</p>
|
||||
<p>
|
||||
<span class="p-nickname font-normal text-gray-600">
|
||||
@{Core.Author.get(:username)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="e-content my-4">
|
||||
<.markdown content={@status.body} />
|
||||
</div>
|
||||
|
||||
<footer class="mt-4 border-t border-gray-200 pt-2 text-sm text-gray-500">
|
||||
<%= if @status.published_at do %>
|
||||
<.timex
|
||||
value={@status.published_at}
|
||||
format="{Mfull} {D}, {YYYY} at {h12}:{m} {AM}"
|
||||
class="dt-published"
|
||||
/>
|
||||
<% end %>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
|
@ -2,42 +2,64 @@ defmodule Web.AdminDashboardLive do
|
|||
use Web, :live_view
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
statuses = Core.Posts.get_all_recent_statuses()
|
||||
blogs = Core.Posts.get_all_recent_blogs()
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> stream(:statuses, statuses)
|
||||
|> stream(:blogs, blogs)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
def handle_params(params, _uri, socket) do
|
||||
kind = socket.assigns.live_action
|
||||
|
||||
{:ok, {posts, meta}} = Core.Posts.list_posts(kind, params)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(
|
||||
kind: kind,
|
||||
meta: meta
|
||||
)
|
||||
|> stream(:posts, posts, reset: true)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
attr :post, Schema.Post, required: true
|
||||
|
||||
defp post_status(%{post: %{published_at: nil, deleted_at: nil}} = assigns) do
|
||||
~H"""
|
||||
<div class="flex flex-col gap-y-4">
|
||||
<h1 class="font-bold text-2xl">dashboard</h1>
|
||||
draft
|
||||
"""
|
||||
end
|
||||
|
||||
<section>
|
||||
<header class="flex flex-row justify-between">
|
||||
<h2 class="font-bold text-xl">recent statuses</h2>
|
||||
<.link navigate={~p"/admin/posts/new?kind=status"}>new status</.link>
|
||||
</header>
|
||||
<.post_list :let={status} id="recent-statuses" posts={@streams.statuses}>
|
||||
<.link navigate={~p"/admin/posts/#{status}"}>{status.body}</.link>
|
||||
</.post_list>
|
||||
</section>
|
||||
defp post_status(%{post: %{published_at: _, deleted_at: nil}} = assigns) do
|
||||
~H"""
|
||||
published
|
||||
"""
|
||||
end
|
||||
|
||||
<section>
|
||||
<header class="flex flex-row justify-between">
|
||||
<h2 class="font-bold text-xl">recent blogs</h2>
|
||||
<.link navigate={~p"/admin/posts/new?kind=blog"}>new blog</.link>
|
||||
</header>
|
||||
<.post_list :let={blog} id="recent-blogs" posts={@streams.blogs}>
|
||||
<.link navigate={~p"/admin/posts/#{blog}"}>{blog.title}</.link>
|
||||
</.post_list>
|
||||
</section>
|
||||
defp post_status(assigns) do
|
||||
~H"""
|
||||
deleted
|
||||
"""
|
||||
end
|
||||
|
||||
attr :post, Schema.Post, required: true
|
||||
|
||||
defp post_actions(assigns) do
|
||||
~H"""
|
||||
<div class="flex flex-row gap-x-1">
|
||||
<.link navigate={~p"/admin/posts/#{@post}"}>edit</.link>
|
||||
<.link
|
||||
:if={@post.published_at && is_nil(@post.deleted_at)}
|
||||
navigate={Web.Paths.public_post_path(@post)}
|
||||
>
|
||||
view
|
||||
</.link>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp build_path(:blog, meta),
|
||||
do: Flop.Phoenix.build_path(~p"/admin/writing", meta, for: Schema.Post)
|
||||
|
||||
defp build_path(:status, meta),
|
||||
do: Flop.Phoenix.build_path(~p"/admin/microblog", meta, for: Schema.Post)
|
||||
end
|
||||
|
|
59
lib/web/live/admin_dashboard_live.html.heex
Normal file
59
lib/web/live/admin_dashboard_live.html.heex
Normal file
|
@ -0,0 +1,59 @@
|
|||
<div class="flex flex-col py-4 px-6">
|
||||
<header class="mb-4">
|
||||
<nav>
|
||||
<ul class="flex flex-row gap-x-4">
|
||||
<li>
|
||||
<.link class={[@kind == :blog && "underline"]} patch={~p"/admin/writing"}>
|
||||
writing
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link class={[@kind == :status && "underline"]} patch={~p"/admin/microblog"}>
|
||||
microblog
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="flex flex-col">
|
||||
<.link class="mb-4" navigate={~p"/admin/posts/new?kind=#{@kind}"}>new {@kind}</.link>
|
||||
<%= case @kind do %>
|
||||
<% :blog -> %>
|
||||
<.table id="blog-posts" stream={@streams.posts}>
|
||||
<:col :let={blog} label="title">{blog.title}</:col>
|
||||
<:col :let={blog} label="status">
|
||||
<.post_status post={blog} />
|
||||
</:col>
|
||||
<:col :let={blog}>
|
||||
<.post_actions post={blog} />
|
||||
</:col>
|
||||
</.table>
|
||||
<% :status -> %>
|
||||
<.table id="status-posts" stream={@streams.posts}>
|
||||
<:col :let={status} label="content">
|
||||
{status.body}
|
||||
</:col>
|
||||
<:col :let={status} label="status">
|
||||
<.post_status post={status} />
|
||||
</:col>
|
||||
<:col :let={status}>
|
||||
<.post_actions post={status} />
|
||||
</:col>
|
||||
</.table>
|
||||
<% end %>
|
||||
<footer class="flex flex-row justify-between mt-2">
|
||||
<%= if @meta.has_previous_page? do %>
|
||||
<.link patch={build_path(@kind, Flop.to_previous_cursor(@meta))}>prev</.link>
|
||||
<% else %>
|
||||
<div />
|
||||
<% end %>
|
||||
|
||||
<%= if @meta.has_next_page? do %>
|
||||
<.link patch={build_path(@kind, Flop.to_next_cursor(@meta))}>next</.link>
|
||||
<% else %>
|
||||
<div />
|
||||
<% end %>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
|
@ -116,44 +116,6 @@ defmodule Web.AdminPostLive do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<main>
|
||||
<header>
|
||||
<h1>{page_title(@post, @live_action)}</h1>
|
||||
</header>
|
||||
|
||||
<.form for={@form} phx-change="validate" phx-submit="save">
|
||||
<.input :if={@post.kind == :blog} type="text" field={@form[:title]} />
|
||||
<.input
|
||||
:if={@post.kind == :blog}
|
||||
type="text"
|
||||
field={@form[:slug]}
|
||||
disabled={not update_slug?(@post)}
|
||||
/>
|
||||
<.input type="textarea" field={@form[:body]} />
|
||||
|
||||
<.button type="submit">save</.button>
|
||||
</.form>
|
||||
|
||||
<%= if @live_action == :edit do %>
|
||||
<div>
|
||||
<%= if @post.published_at do %>
|
||||
<.button phx-click="unpublish">unpublish</.button>
|
||||
<% else %>
|
||||
<.button phx-click="publish">publish</.button>
|
||||
<% end %>
|
||||
<%= if @post.deleted_at do %>
|
||||
<.button phx-click="undelete">undelete</.button>
|
||||
<% else %>
|
||||
<.button phx-click="delete">delete</.button>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</main>
|
||||
"""
|
||||
end
|
||||
|
||||
defp page_title(%Schema.Post{kind: :blog}, :new), do: "new blog"
|
||||
defp page_title(%Schema.Post{kind: :status}, :new), do: "new status"
|
||||
defp page_title(%Schema.Post{kind: :blog}, :edit), do: "edit blog"
|
||||
|
|
33
lib/web/live/admin_post_live.html.heex
Normal file
33
lib/web/live/admin_post_live.html.heex
Normal file
|
@ -0,0 +1,33 @@
|
|||
<main>
|
||||
<header>
|
||||
<h1>{page_title(@post, @live_action)}</h1>
|
||||
</header>
|
||||
|
||||
<.form for={@form} phx-change="validate" phx-submit="save">
|
||||
<.input :if={@post.kind == :blog} type="text" field={@form[:title]} />
|
||||
<.input
|
||||
:if={@post.kind == :blog}
|
||||
type="text"
|
||||
field={@form[:slug]}
|
||||
disabled={not update_slug?(@post)}
|
||||
/>
|
||||
<.input type="textarea" field={@form[:body]} />
|
||||
|
||||
<.button type="submit">save</.button>
|
||||
</.form>
|
||||
|
||||
<%= if @live_action == :edit do %>
|
||||
<div>
|
||||
<%= if @post.published_at do %>
|
||||
<.button phx-click="unpublish">unpublish</.button>
|
||||
<% else %>
|
||||
<.button phx-click="publish">publish</.button>
|
||||
<% end %>
|
||||
<%= if @post.deleted_at do %>
|
||||
<.button phx-click="undelete">undelete</.button>
|
||||
<% else %>
|
||||
<.button phx-click="delete">delete</.button>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</main>
|
323
lib/web/markdown.ex
Normal file
323
lib/web/markdown.ex
Normal file
|
@ -0,0 +1,323 @@
|
|||
defmodule Web.Markdown do
|
||||
@moduledoc """
|
||||
Converts markdown to HTML using MDEx, with custom rendering to add Tailwind CSS classes.
|
||||
Uses iodata for efficient string generation.
|
||||
"""
|
||||
|
||||
alias MDEx.Document
|
||||
|
||||
@doc """
|
||||
Converts markdown to HTML with syntax highlighting, additional extensions,
|
||||
and appropriate Tailwind CSS classes for styling.
|
||||
"""
|
||||
def to_html(markdown) when is_binary(markdown) do
|
||||
# First parse the document to get the AST
|
||||
{:ok, document} =
|
||||
MDEx.parse_document(markdown,
|
||||
extension: [
|
||||
strikethrough: true,
|
||||
table: true,
|
||||
autolink: true,
|
||||
tasklist: true,
|
||||
footnotes: true
|
||||
],
|
||||
features: [syntax_highlight_theme: "catppuccin_latte"]
|
||||
)
|
||||
|
||||
# Render the document to HTML with Tailwind classes
|
||||
render_document(document)
|
||||
end
|
||||
|
||||
def to_html(nil), do: ""
|
||||
|
||||
# Core rendering function for the document
|
||||
defp render_document(%Document{} = document) do
|
||||
document.nodes
|
||||
|> Enum.map(&render_node(&1, nil))
|
||||
end
|
||||
|
||||
# Heading (h1-h6) with anchor links
|
||||
defp render_node(%MDEx.Heading{level: level} = node, _parent) do
|
||||
tag = "h#{level}"
|
||||
|
||||
class =
|
||||
case level do
|
||||
1 -> "text-3xl font-bold mt-6 mb-4 group"
|
||||
2 -> "text-2xl font-bold mt-5 mb-3 group"
|
||||
3 -> "text-xl font-bold mt-4 mb-2 group"
|
||||
4 -> "text-lg font-bold mt-3 mb-2 group"
|
||||
5 -> "text-base font-bold mt-3 mb-2 group"
|
||||
6 -> "text-sm font-bold mt-3 mb-2 group"
|
||||
end
|
||||
|
||||
# Generate a slug from the text content for the ID
|
||||
content = Enum.map(node.nodes, &render_node(&1, node))
|
||||
content_text = extract_text_content(node)
|
||||
slug = Slug.slugify(content_text)
|
||||
|
||||
[
|
||||
"<",
|
||||
tag,
|
||||
" id=\"",
|
||||
slug,
|
||||
"\" class=\"",
|
||||
class,
|
||||
" relative\">",
|
||||
"<a href=\"#",
|
||||
slug,
|
||||
"\" class=\"absolute -left-5 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity\">¶</a>",
|
||||
content,
|
||||
"</",
|
||||
tag,
|
||||
">"
|
||||
]
|
||||
end
|
||||
|
||||
# Paragraph
|
||||
defp render_node(%MDEx.Paragraph{} = node, _parent) do
|
||||
content = Enum.map(node.nodes, &render_node(&1, node))
|
||||
["<p>", content, "</p>"]
|
||||
end
|
||||
|
||||
# Text
|
||||
defp render_node(%MDEx.Text{literal: literal}, _parent) do
|
||||
literal
|
||||
end
|
||||
|
||||
# Strong (bold)
|
||||
defp render_node(%MDEx.Strong{} = node, _parent) do
|
||||
content = Enum.map(node.nodes, &render_node(&1, node))
|
||||
["<strong>", content, "</strong>"]
|
||||
end
|
||||
|
||||
# Emphasis (italic)
|
||||
defp render_node(%MDEx.Emph{} = node, _parent) do
|
||||
content = Enum.map(node.nodes, &render_node(&1, node))
|
||||
["<em>", content, "</em>"]
|
||||
end
|
||||
|
||||
# Strikethrough
|
||||
defp render_node(%MDEx.Strikethrough{} = node, _parent) do
|
||||
content = Enum.map(node.nodes, &render_node(&1, node))
|
||||
["<del>", content, "</del>"]
|
||||
end
|
||||
|
||||
# Link
|
||||
defp render_node(%MDEx.Link{url: url, title: title} = node, _parent) do
|
||||
content = Enum.map(node.nodes, &render_node(&1, node))
|
||||
title_attr = if title, do: [" title=\"", title, "\""], else: []
|
||||
|
||||
[
|
||||
"<a href=\"",
|
||||
url,
|
||||
"\"",
|
||||
title_attr,
|
||||
" class=\"text-blue-600 hover:text-blue-800 hover:underline\">",
|
||||
content,
|
||||
"</a>"
|
||||
]
|
||||
end
|
||||
|
||||
# Image
|
||||
defp render_node(%MDEx.Image{url: url, title: title} = node, _parent) do
|
||||
alt = Enum.map(node.nodes, &render_node(&1, node))
|
||||
title_attr = if title, do: [" title=\"", title, "\""], else: []
|
||||
|
||||
[
|
||||
"<img src=\"",
|
||||
url,
|
||||
"\" alt=\"",
|
||||
alt,
|
||||
"\"",
|
||||
title_attr,
|
||||
" class=\"max-w-full my-4 rounded\">"
|
||||
]
|
||||
end
|
||||
|
||||
# Code (inline)
|
||||
defp render_node(%MDEx.Code{literal: literal}, _parent) do
|
||||
["<code class=\"bg-gray-100 rounded px-1 py-0.5 text-sm\">", literal, "</code>"]
|
||||
end
|
||||
|
||||
# Code blocks - use MDEx's built-in HTML rendering to preserve syntax highlighting
|
||||
defp render_node(%MDEx.CodeBlock{} = node, _parent) do
|
||||
# Use MDEx's built-in HTML rendering for code blocks to preserve syntax highlighting
|
||||
{:ok, html} =
|
||||
MDEx.to_html(
|
||||
%MDEx.Document{nodes: [node]},
|
||||
extension: [],
|
||||
features: [syntax_highlight_theme: "catppuccin_latte"]
|
||||
)
|
||||
|
||||
# Return the HTML as is since it's already properly formatted with syntax highlighting
|
||||
String.replace(html, ~s|class="autumn-hl"|, ~s|class="autumn-hl p-2 rounded"|)
|
||||
end
|
||||
|
||||
# Lists - Bullet
|
||||
defp render_node(%MDEx.List{list_type: :bullet} = node, _parent) do
|
||||
content = Enum.map(node.nodes, &render_node(&1, node))
|
||||
["<ul class=\"list-disc pl-6 my-4\">", content, "</ul>"]
|
||||
end
|
||||
|
||||
# Lists - Ordered
|
||||
defp render_node(%MDEx.List{list_type: :ordered} = node, _parent) do
|
||||
content = Enum.map(node.nodes, &render_node(&1, node))
|
||||
["<ol class=\"list-decimal pl-6 my-4\">", content, "</ol>"]
|
||||
end
|
||||
|
||||
# List items
|
||||
defp render_node(%MDEx.ListItem{} = node, _parent) do
|
||||
content = Enum.map(node.nodes, &render_node(&1, node))
|
||||
["<li class=\"mb-1\">", content, "</li>"]
|
||||
end
|
||||
|
||||
# Task items (checkboxes)
|
||||
defp render_node(%MDEx.TaskItem{checked: checked} = node, _parent) do
|
||||
checkbox =
|
||||
if checked do
|
||||
"<input type=\"checkbox\" checked disabled class=\"rounded mr-1.5 text-blue-600\">"
|
||||
else
|
||||
"<input type=\"checkbox\" disabled class=\"rounded mr-1.5 text-blue-600\">"
|
||||
end
|
||||
|
||||
content = Enum.map(node.nodes, &render_node(&1, node))
|
||||
|
||||
[
|
||||
"<li class=\"mb-1 flex items-start\">",
|
||||
checkbox,
|
||||
"<div class=\"inline\">",
|
||||
content,
|
||||
"</div>",
|
||||
"</li>"
|
||||
]
|
||||
end
|
||||
|
||||
# Blockquote
|
||||
defp render_node(%MDEx.BlockQuote{} = node, _parent) do
|
||||
content = Enum.map(node.nodes, &render_node(&1, node))
|
||||
|
||||
[
|
||||
"<blockquote class=\"border-l-4 border-gray-300 pl-4 py-1 my-4 italic\">",
|
||||
content,
|
||||
"</blockquote>"
|
||||
]
|
||||
end
|
||||
|
||||
# Horizontal Rule
|
||||
defp render_node(%MDEx.ThematicBreak{}, _parent) do
|
||||
"<hr class=\"border-t border-gray-300 my-6\">"
|
||||
end
|
||||
|
||||
# Table
|
||||
defp render_node(%MDEx.Table{} = node, _parent) do
|
||||
content = Enum.map(node.nodes, &render_node(&1, node))
|
||||
|
||||
[
|
||||
"<div class=\"overflow-x-auto my-4\"><table class=\"min-w-full divide-y divide-gray-200\">",
|
||||
content,
|
||||
"</table></div>"
|
||||
]
|
||||
end
|
||||
|
||||
# Table row
|
||||
defp render_node(%MDEx.TableRow{header: true} = node, _parent) do
|
||||
content = Enum.map(node.nodes, &render_node(&1, node))
|
||||
["<tr class=\"bg-gray-50 border-b border-gray-200\">", content, "</tr>"]
|
||||
end
|
||||
|
||||
defp render_node(%MDEx.TableRow{header: false} = node, _parent) do
|
||||
content = Enum.map(node.nodes, &render_node(&1, node))
|
||||
["<tr class=\"border-b border-gray-200\">", content, "</tr>"]
|
||||
end
|
||||
|
||||
# Table cell in header row
|
||||
defp render_node(%MDEx.TableCell{} = node, %MDEx.TableRow{header: true}) do
|
||||
content = Enum.map(node.nodes, &render_node(&1, node))
|
||||
|
||||
[
|
||||
"<th class=\"px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider\">",
|
||||
content,
|
||||
"</th>"
|
||||
]
|
||||
end
|
||||
|
||||
# Table cell in normal row
|
||||
defp render_node(%MDEx.TableCell{} = node, _parent) do
|
||||
content = Enum.map(node.nodes, &render_node(&1, node))
|
||||
["<td class=\"px-6 py-4 whitespace-nowrap text-sm\">", content, "</td>"]
|
||||
end
|
||||
|
||||
# HTML blocks (pass through)
|
||||
defp render_node(%MDEx.HtmlBlock{literal: literal}, _parent) do
|
||||
literal
|
||||
end
|
||||
|
||||
# HTML inline (pass through)
|
||||
defp render_node(%MDEx.HtmlInline{literal: literal}, _parent) do
|
||||
literal
|
||||
end
|
||||
|
||||
# Soft break (line break that doesn't create new paragraph)
|
||||
defp render_node(%MDEx.SoftBreak{}, _parent) do
|
||||
"\n"
|
||||
end
|
||||
|
||||
# Hard break (explicit line break with <br>)
|
||||
defp render_node(%MDEx.LineBreak{}, _parent) do
|
||||
"<br>"
|
||||
end
|
||||
|
||||
# Footnote reference
|
||||
defp render_node(%MDEx.FootnoteReference{name: name}, _parent) do
|
||||
[
|
||||
"<sup class=\"footnote-ref\">",
|
||||
"<a href=\"#fn-",
|
||||
name,
|
||||
"\" id=\"fnref-",
|
||||
name,
|
||||
"\" class=\"text-blue-600 hover:text-blue-800\">",
|
||||
name,
|
||||
"</a>",
|
||||
"</sup>"
|
||||
]
|
||||
end
|
||||
|
||||
# Footnote definition
|
||||
defp render_node(%MDEx.FootnoteDefinition{name: name} = node, _parent) do
|
||||
content = Enum.map(node.nodes, &render_node(&1, node))
|
||||
|
||||
[
|
||||
"<div class=\"footnote flex flex-row\" id=\"fn-",
|
||||
name,
|
||||
"\">",
|
||||
"<p class=\"my-1 flex\">",
|
||||
"<span class=\"mr-2 flex-shrink-0\"><sup>",
|
||||
name,
|
||||
".</sup></span>",
|
||||
"<span class=\"inline\">",
|
||||
content,
|
||||
" <a href=\"#fnref-",
|
||||
name,
|
||||
"\" class=\"text-blue-600 hover:text-blue-800 text-sm\">↩</a>",
|
||||
"</span>",
|
||||
"</p>",
|
||||
"</div>"
|
||||
]
|
||||
end
|
||||
|
||||
# Extract plain text from a node tree (for generating slugs)
|
||||
defp extract_text_content(node) do
|
||||
cond do
|
||||
is_map(node) && Map.has_key?(node, :literal) && is_binary(node.literal) ->
|
||||
node.literal
|
||||
|
||||
is_map(node) && Map.has_key?(node, :nodes) ->
|
||||
node.nodes
|
||||
|> Enum.map(&extract_text_content/1)
|
||||
|> Enum.join("")
|
||||
|
||||
true ->
|
||||
""
|
||||
end
|
||||
end
|
||||
end
|
16
lib/web/paths.ex
Normal file
16
lib/web/paths.ex
Normal file
|
@ -0,0 +1,16 @@
|
|||
defmodule Web.Paths do
|
||||
use Web, :html
|
||||
|
||||
def public_post_path(%Schema.Post{kind: :status} = status), do: public_status_path(status)
|
||||
def public_post_path(%Schema.Post{kind: :blog} = blog), do: public_blog_path(blog)
|
||||
|
||||
def public_status_path(%Schema.Post{kind: :status} = status) do
|
||||
~p"/status/#{status}"
|
||||
end
|
||||
|
||||
def public_blog_path(%Schema.Post{kind: :blog} = blog) do
|
||||
if date = Core.Posts.publish_date(blog) do
|
||||
~p"/blog/#{date.year}/#{date.month}/#{date.day}/#{blog.slug}"
|
||||
end
|
||||
end
|
||||
end
|
|
@ -31,7 +31,8 @@ defmodule Web.Router do
|
|||
live_session :require_authenticated_user, on_mount: [{Web.UserAuth, :ensure_authenticated}] do
|
||||
live "/users/settings", UserSettingsLive, :edit
|
||||
|
||||
live "/", AdminDashboardLive
|
||||
live "/writing", AdminDashboardLive, :blog
|
||||
live "/microblog", AdminDashboardLive, :status
|
||||
|
||||
live "/posts/new", AdminPostLive, :new
|
||||
live "/posts/:post_id", AdminPostLive, :edit
|
||||
|
@ -45,10 +46,10 @@ defmodule Web.Router do
|
|||
|
||||
delete "/admin/users/log_out", UserSessionController, :delete
|
||||
|
||||
get "/blog", PostController, :index, assigns: %{kind: :blog}
|
||||
get "/blog/:year/:month/:day/:slug", PostController, :show
|
||||
get "/microblog", PostController, :index, assigns: %{kind: :status}
|
||||
get "/status/:status_id", PostController, :show
|
||||
get "/blog", BlogController, :index
|
||||
get "/blog/:year/:month/:day/:slug", BlogController, :show
|
||||
get "/microblog", StatusController, :index
|
||||
get "/status/:status_id", StatusController, :show
|
||||
|
||||
# live_session :current_user, on_mount: [{Web.UserAuth, :mount_current_user}] do
|
||||
# end
|
||||
|
|
3
mix.exs
3
mix.exs
|
@ -61,6 +61,9 @@ defmodule SlaonelyButSurely.MixProject do
|
|||
{:tzdata, "~> 1.1"},
|
||||
{:slugify, "~> 1.3"},
|
||||
{:timex, "~> 3.7"},
|
||||
{:mdex, "~> 0.5.0"},
|
||||
{:flop, "~> 0.26.1"},
|
||||
{:flop_phoenix, "~> 0.24.1"},
|
||||
|
||||
# Added dev and/or test dependencies
|
||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||
|
|
7
mix.lock
7
mix.lock
|
@ -19,15 +19,19 @@
|
|||
"faker": {:hex, :faker, "0.18.0", "943e479319a22ea4e8e39e8e076b81c02827d9302f3d32726c5bf82f430e6e14", [:mix], [], "hexpm", "bfbdd83958d78e2788e99ec9317c4816e651ad05e24cfd1196ce5db5b3e81797"},
|
||||
"file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"},
|
||||
"floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"},
|
||||
"flop": {:hex, :flop, "0.26.1", "f0e9c6895cf876f667e9ff1c0398e53df87087fcd82d9cea8989332b9c0e1358", [:mix], [{:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}], "hexpm", "5fcab8a1ee78111159fc4752dc9823862343b6d6bd527ff947ec1e1c27018485"},
|
||||
"flop_phoenix": {:hex, :flop_phoenix, "0.24.1", "0eee8721e984cd9cbbfc90357c355fcf5c57da9e0617159f432d35843d01b671", [:mix], [{:flop, ">= 0.23.0 and < 0.27.0", [hex: :flop, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6.0 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "543c8eb70a29c0255b778df855f0de303290f88159fc3e008ce0ac4ace48e6ea"},
|
||||
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
|
||||
"hackney": {:hex, :hackney, "1.22.0", "4efc68df70322d4d2e3d2744e9bd191a39a0cb8d08c35379a08d9fb0f040d595", [:rebar3], [{:certifi, "~> 2.14.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "628569e451820950382be3d3e6481d7c59997e606c7823bddb4ce5d10812dfcb"},
|
||||
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]},
|
||||
"hpax": {:hex, :hpax, "1.0.2", "762df951b0c399ff67cc57c3995ec3cf46d696e41f0bba17da0518d94acd4aac", [:mix], [], "hexpm", "2f09b4c1074e0abd846747329eaa26d535be0eb3d189fa69d812bfb8bfefd32f"},
|
||||
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
||||
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||
"mdex": {:hex, :mdex, "0.5.0", "252c83cebc6a089801dfc1e142b4d98c9c358378ec7096a94796bce8bd13b0fc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: false]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "73e3ddee03130267e3be6aaf47a7f423c6f86add4bb5c62b352465cd9fb87d95"},
|
||||
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
|
||||
"mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"},
|
||||
"mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"},
|
||||
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
|
||||
"phoenix": {:hex, :phoenix, "1.7.19", "36617efe5afbd821099a8b994ff4618a340a5bfb25531a1802c4d4c634017a57", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "ba4dc14458278773f905f8ae6c2ec743d52c3a35b6b353733f64f02dfe096cd6"},
|
||||
"phoenix_ecto": {:hex, :phoenix_ecto, "4.6.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"},
|
||||
|
@ -40,6 +44,8 @@
|
|||
"plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
|
||||
"plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
|
||||
"postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"},
|
||||
"rustler": {:hex, :rustler, "0.36.1", "2d4b1ff57ea2789a44756a40dbb5fbb73c6ee0a13d031dcba96d0a5542598a6a", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.7", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "f3fba4ad272970e0d1bc62972fc4a99809651e54a125c5242de9bad4574b2d02"},
|
||||
"rustler_precompiled": {:hex, :rustler_precompiled, "0.8.2", "5f25cbe220a8fac3e7ad62e6f950fcdca5a5a5f8501835d2823e8c74bf4268d5", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "63d1bd5f8e23096d1ff851839923162096364bac8656a4a3c00d1fff8e83ee0a"},
|
||||
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
|
||||
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
|
||||
"styler": {:hex, :styler, "1.4.0", "5944723d08afe4d38210b674d7e97dd1137a75968a85a633983cc308e86dc5f2", [:mix], [], "hexpm", "07de0e89c27490c8e469bb814d77ddaaa3283d7d8038501021d80a7705cf13e9"},
|
||||
|
@ -49,6 +55,7 @@
|
|||
"telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.3.10", "a9971ebab1dfb36e2710a86b37c3f54973fbc9470d892035334415521fb53328", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17ab1f1b13aadb1f4b4c8e5b59c06874d701119fed082884c9c6d38addad254f"},
|
||||
"timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"},
|
||||
"toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"},
|
||||
"typed_struct": {:hex, :typed_struct, "0.3.0", "939789e3c1dca39d7170c87f729127469d1315dcf99fee8e152bb774b17e7ff7", [:mix], [], "hexpm", "c50bd5c3a61fe4e198a8504f939be3d3c85903b382bde4865579bc23111d1b6d"},
|
||||
"tzdata": {:hex, :tzdata, "1.1.2", "45e5f1fcf8729525ec27c65e163be5b3d247ab1702581a94674e008413eef50b", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "cec7b286e608371602318c414f344941d5eb0375e14cfdab605cca2fe66cba8b"},
|
||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
|
||||
|
|
39
prompts/00001-markdown-authoring.md
Normal file
39
prompts/00001-markdown-authoring.md
Normal file
|
@ -0,0 +1,39 @@
|
|||
# Markdown Authoring
|
||||
|
||||
You must update the site to support Markdown content in blog posts and microblog statuses.
|
||||
|
||||
- Use the [mdex](https://hex.pm/packages/mdex) to convert markdown into HTML
|
||||
- [MDEx documentation](https://hexdocs.pm/mdex/MDEx.html)
|
||||
- Keep all content markdown -> HTML in a new module in the `Web` boundary so that any later customizations to markdown to HTML conversion are applied throughout the site
|
||||
|
||||
- Use the existing `body` field of the `Schema.Post`. assume this content is already markdown
|
||||
|
||||
- Ensure that syntax highlighting is supported in codefences
|
||||
- Use the catppuccin latte colorscheme
|
||||
- Make sure support for the following languges is available:
|
||||
- Elixir
|
||||
- Erlang
|
||||
- JavaScript (including React / JSX)
|
||||
- TypeScript
|
||||
- Bash
|
||||
- HTML
|
||||
- Markdown
|
||||
- Lua
|
||||
- JSON
|
||||
- TOML
|
||||
- SQL
|
||||
- YAML
|
||||
|
||||
- Enable these extensions:
|
||||
- strikethrough
|
||||
- table
|
||||
- autolink
|
||||
- tasklist
|
||||
- footnotes
|
||||
|
||||
|
||||
You will need to update the blog show page, the status show page, the status index page, and the home page where recent statuses are shown.
|
||||
|
||||
Use components to reduced code duplication when possible.
|
||||
|
||||
If components are shared between multiple views create a new module in `lib/web/components/` for the components pertaining to a specific thing.
|
28
prompts/00002-pagination.md
Normal file
28
prompts/00002-pagination.md
Normal file
|
@ -0,0 +1,28 @@
|
|||
# Pagination for Blog posts and Microblog feed
|
||||
|
||||
Your task is to add pagination to the blog posts index page and the statuses index page.
|
||||
|
||||
Requirements:
|
||||
- Must use cursor based pagination NOT limit/offset
|
||||
- Invalid pagination options should just redirect to the first page
|
||||
- h-feed compatibility should be maintained
|
||||
- microformat2 consumers should be able to discover all posts somehow
|
||||
- if the h-feed spec allows for pagination just use the html pages
|
||||
- consider reading the h-feed spec: https://microformats.org/wiki/h-feed to ensure compliance.
|
||||
|
||||
This involves several things
|
||||
|
||||
1. Install the `flop` package
|
||||
- documentation: https://hexdocs.pm/flop/readme.html
|
||||
- make sure to run `mix deps.get` after adding this dependency to the `mix.exs` file.
|
||||
- the current version is `0.26.1`, use this version
|
||||
2. Read through the Flop documentation to understand how the module works and all of the options available
|
||||
3. Derive `Flop.Schema` in `Schema.Post`
|
||||
- Do not support any filtering or sorting for now
|
||||
4. Add new functions to the `Cost.Posts` module that support using Flop
|
||||
5. Add pagination controls to the pages
|
||||
- use the text "Newer posts" and "Older posts" justified with space between at the bottom of the page
|
||||
- only show the pagination controls if there are newer or older posts respectively
|
||||
- the newer posts and older posts links should always be in the same positions even if one is not rendered meaning if "newer posts" is not shown the "older posts" link should not suddenly show on the left-hand side
|
||||
|
||||
Think hard about this problem, review any rerference material, and then ask any clarifying questions or state assumptions for validation before continuing on to complete the task.
|
Loading…
Reference in a new issue