Compare commits
2 commits
ef3d6d8b7a
...
4cb279b9ac
Author | SHA1 | Date | |
---|---|---|---|
4cb279b9ac | |||
d02833e472 |
18 changed files with 347 additions and 15 deletions
|
@ -1,3 +1,4 @@
|
|||
[tools]
|
||||
elixir = "1.18.2-otp-27"
|
||||
erlang = "27.2.3"
|
||||
node = "22.14.0"
|
||||
|
|
5
assets/js/editor.js
Normal file
5
assets/js/editor.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
import Trix from 'trix'
|
||||
|
||||
document.addEventListener('trix-before-initialize', () => {
|
||||
console.log('trix-before-initialize')
|
||||
})
|
109
assets/package-lock.json
generated
Normal file
109
assets/package-lock.json
generated
Normal file
|
@ -0,0 +1,109 @@
|
|||
{
|
||||
"name": "assets",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"trix": "^2.1.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/typography": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz",
|
||||
"integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash.castarray": "^4.4.0",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"postcss-selector-parser": "6.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"cssesc": "bin/cssesc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz",
|
||||
"integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash.castarray": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
|
||||
"integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "6.0.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
||||
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.8.tgz",
|
||||
"integrity": "sha512-Me7N5CKR+D2A1xdWA5t5+kjjT7bwnxZOE6/yDI/ixJdJokszsn2n++mdU5yJwrsTpqFX2B9ZNMBJDwcqk9C9lw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/trix": {
|
||||
"version": "2.1.12",
|
||||
"resolved": "https://registry.npmjs.org/trix/-/trix-2.1.12.tgz",
|
||||
"integrity": "sha512-0hQvJdy257XuzRdCzSQ/QvcqyTp+8ixMxVLWxSbWvEzD2kgKFlcrMjgWZbtVkJENaod+jm2sBTOWAZVNWK+DMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
6
assets/package.json
Normal file
6
assets/package.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"trix": "^2.1.12"
|
||||
}
|
||||
}
|
|
@ -20,6 +20,7 @@ module.exports = {
|
|||
},
|
||||
plugins: [
|
||||
require("@tailwindcss/forms"),
|
||||
require("@tailwindcss/typography"),
|
||||
// Allows prefixing tailwind classes with LiveView classes to add rules
|
||||
// only when LiveView classes are applied, for example:
|
||||
//
|
||||
|
|
|
@ -27,7 +27,8 @@ config :cms,
|
|||
config :esbuild,
|
||||
version: "0.17.11",
|
||||
cms: [
|
||||
args: ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
|
||||
args:
|
||||
~w(js/app.js js/editor.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
|
||||
cd: Path.expand("../assets", __DIR__),
|
||||
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
|
||||
]
|
||||
|
|
25
lib/cms/posts.ex
Normal file
25
lib/cms/posts.ex
Normal file
|
@ -0,0 +1,25 @@
|
|||
defmodule CMS.Posts do
|
||||
@moduledoc false
|
||||
alias CMS.Posts.Post
|
||||
alias CMS.Repo
|
||||
|
||||
def create_post(attrs) do
|
||||
%Post{}
|
||||
|> Post.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def update_post(post, attrs) do
|
||||
post
|
||||
|> Post.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def get_post!(id) do
|
||||
Repo.get!(Post, id)
|
||||
end
|
||||
|
||||
def list_posts do
|
||||
Repo.all(Post)
|
||||
end
|
||||
end
|
20
lib/cms/posts/post.ex
Normal file
20
lib/cms/posts/post.ex
Normal file
|
@ -0,0 +1,20 @@
|
|||
defmodule CMS.Posts.Post do
|
||||
@moduledoc false
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset, warn: false
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
schema "posts" do
|
||||
field :title, :string
|
||||
field :contents, :string
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
def changeset(%__MODULE__{} = post, attrs \\ %{}) do
|
||||
post
|
||||
|> cast(attrs, [:title, :contents])
|
||||
|> validate_required([:contents])
|
||||
end
|
||||
end
|
|
@ -4,6 +4,28 @@ defmodule CMSWeb.CoreComponents do
|
|||
"""
|
||||
use Phoenix.Component
|
||||
|
||||
attr :class, :string, default: nil
|
||||
|
||||
attr :global, :global,
|
||||
include: ~w[navigate patch href replace method csrf_token download hreflang referrerpolicy rel target type]
|
||||
|
||||
slot :inner_block
|
||||
|
||||
def a(assigns) do
|
||||
~H"""
|
||||
<.link class={["hover:underline", @class]} {@global}>{render_slot(@inner_block)}</.link>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :field, Phoenix.HTML.FormField, required: true
|
||||
attr :global, :global, include: ~w[required placeholder type]
|
||||
|
||||
def input(assigns) do
|
||||
~H"""
|
||||
<input id={@field.id} name={@field.name} value={@field.value} {@global} />
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a [Heroicon](https://heroicons.com).
|
||||
|
||||
|
|
|
@ -1,32 +1,36 @@
|
|||
<div :if={@admin?} class="flex flex-row justify-between py-1 px-3 mb-2 border-b border-slate-100">
|
||||
<div
|
||||
:if={@admin?}
|
||||
class="sticky top-0 bg-white z-50 flex flex-row justify-between py-1 px-3 md:mb-2 border-b border-slate-100"
|
||||
>
|
||||
<section class="flex flex-row gap-x-2">
|
||||
<div class="pr-2 border-r border-slate-100">
|
||||
<.link navigate={~p"/admin"} class="font-bold">admin mode</.link>
|
||||
<.a navigate={~p"/admin"} class="font-bold">admin mode</.a>
|
||||
</div>
|
||||
<nav>
|
||||
<ul class="flex flex-row">
|
||||
<.link href="#" class="hover:underline">new post</.link>
|
||||
<.a href={~p"/admin/posts/new"}>new post</.a>
|
||||
</ul>
|
||||
</nav>
|
||||
</section>
|
||||
|
||||
<section class="flex flex-row">
|
||||
<.link href={~p"/admin/session/destroy?return_to=#{@current_path}"} class="hover:underline">
|
||||
<.a href={~p"/admin/session/destroy?return_to=#{@current_path}"}>
|
||||
sign out
|
||||
</.link>
|
||||
</.a>
|
||||
</section>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row mx-auto max-w-4xl">
|
||||
<section class="flex flex-col p-2 gap-y-1 border-slate-100 border-b md:border-b-0">
|
||||
<.link href={~p"/"} class="font-bold hover:underline">sloanelybutsurely.com</.link>
|
||||
<.a href={~p"/"} class="font-bold">sloanelybutsurely.com</.a>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><.link href={~p"/writing"} class="hover:underline">writing</.link></li>
|
||||
<li><.link href={~p"/microblog"} class="hover:underline">microblog</.link></li>
|
||||
<li>
|
||||
<.a href={~p"/posts"}>writing</.a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</section>
|
||||
<main class="p-2">
|
||||
<main class="p-2 w-full">
|
||||
{@inner_content}
|
||||
</main>
|
||||
</div>
|
||||
|
@ -34,5 +38,5 @@
|
|||
:if={not @admin?}
|
||||
class="fixed right-0 bottom-0 p-2 text-transparent underline hover:text-current"
|
||||
>
|
||||
<.link href={~p"/sign-in?return_to=#{@current_path}"}>sign in</.link>
|
||||
<.a href={~p"/sign-in?return_to=#{@current_path}"}>sign in</.a>
|
||||
</div>
|
||||
|
|
21
lib/cms_web/controllers/post_controller.ex
Normal file
21
lib/cms_web/controllers/post_controller.ex
Normal file
|
@ -0,0 +1,21 @@
|
|||
defmodule CMSWeb.PostController do
|
||||
use CMSWeb, :controller
|
||||
|
||||
alias CMS.Posts
|
||||
|
||||
def index(conn, _params) do
|
||||
posts = Posts.list_posts()
|
||||
|
||||
conn
|
||||
|> assign(:posts, posts)
|
||||
|> render(:index)
|
||||
end
|
||||
|
||||
def show(conn, %{"post_id" => post_id}) do
|
||||
post = Posts.get_post!(post_id)
|
||||
|
||||
conn
|
||||
|> assign(:post, post)
|
||||
|> render(:show)
|
||||
end
|
||||
end
|
6
lib/cms_web/controllers/post_html.ex
Normal file
6
lib/cms_web/controllers/post_html.ex
Normal file
|
@ -0,0 +1,6 @@
|
|||
defmodule CMSWeb.PostHTML do
|
||||
@moduledoc false
|
||||
use CMSWeb, :html
|
||||
|
||||
embed_templates "post_html/*"
|
||||
end
|
6
lib/cms_web/controllers/post_html/index.html.heex
Normal file
6
lib/cms_web/controllers/post_html/index.html.heex
Normal file
|
@ -0,0 +1,6 @@
|
|||
<%= for post <- @posts do %>
|
||||
<article id={"post-#{post.id}"}>
|
||||
<h2><.link href={~p"/posts/#{post}"}>{post.title}</.link></h2>
|
||||
<p>{post.contents}</p>
|
||||
</article>
|
||||
<% end %>
|
7
lib/cms_web/controllers/post_html/show.html.heex
Normal file
7
lib/cms_web/controllers/post_html/show.html.heex
Normal file
|
@ -0,0 +1,7 @@
|
|||
<article>
|
||||
<header class="flex flex-row justify-between">
|
||||
<h1 :if={@post.title} class="font-bold text-xl mb-3">{@post.title}</h1>
|
||||
<.link :if={@admin?} href={~p"/admin/posts/#{@post}"}>edit</.link>
|
||||
</header>
|
||||
<section class="prose">{raw(@post.contents)}</section>
|
||||
</article>
|
|
@ -4,7 +4,12 @@ defmodule CMSWeb.AdminLoginLive do
|
|||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
socket = assign(socket, :form, to_form(%{"password" => "", "return_to" => params["return_to"]}))
|
||||
socket =
|
||||
assign(
|
||||
socket,
|
||||
form: to_form(%{"password" => "", "return_to" => params["return_to"]}),
|
||||
return_to: params["return_to"]
|
||||
)
|
||||
|
||||
{:ok, socket, layout: false}
|
||||
end
|
||||
|
@ -30,10 +35,17 @@ defmodule CMSWeb.AdminLoginLive do
|
|||
/>
|
||||
<div class="flex flex-col items-end">
|
||||
<button type="submit" class="font-bold hover:underline">sign in</button>
|
||||
<.link href={~p"/"} class="hover:underline">cancel</.link>
|
||||
<.a href={cancel_href(@return_to)}>
|
||||
cancel
|
||||
</.a>
|
||||
</div>
|
||||
</.form>
|
||||
</main>
|
||||
"""
|
||||
end
|
||||
|
||||
defp cancel_href("/admin"), do: ~p"/"
|
||||
defp cancel_href("/admin/" <> _), do: ~p"/"
|
||||
defp cancel_href(nil), do: ~p"/"
|
||||
defp cancel_href(return_to), do: return_to
|
||||
end
|
||||
|
|
69
lib/cms_web/live/post_live.ex
Normal file
69
lib/cms_web/live/post_live.ex
Normal file
|
@ -0,0 +1,69 @@
|
|||
defmodule CMSWeb.PostLive do
|
||||
@moduledoc false
|
||||
use CMSWeb, :live_view
|
||||
|
||||
alias CMS.Posts
|
||||
alias CMS.Posts.Post
|
||||
|
||||
@impl true
|
||||
def handle_params(_params, _uri, %{assigns: %{live_action: :new}} = socket) do
|
||||
post = %Post{}
|
||||
changeset = Post.changeset(post)
|
||||
|
||||
socket = assign(socket, post: post, form: to_form(changeset))
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_params(%{"post_id" => post_id}, _uri, %{assigns: %{live_action: :edit}} = socket) do
|
||||
post = Posts.get_post!(post_id)
|
||||
|
||||
changeset = Post.changeset(post)
|
||||
|
||||
socket = assign(socket, post: post, form: to_form(changeset))
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save_post", %{"post" => attrs}, %{assigns: %{live_action: :new}} = socket) do
|
||||
socket =
|
||||
case Posts.create_post(attrs) do
|
||||
{:ok, post} -> push_navigate(socket, to: ~p"/admin/posts/#{post}")
|
||||
{:error, changeset} -> assign(socket, form: to_form(changeset))
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("save_post", %{"post" => attrs}, %{assigns: %{post: post, live_action: :edit}} = socket) do
|
||||
socket =
|
||||
case Posts.update_post(post, attrs) do
|
||||
{:ok, post} ->
|
||||
assign(socket, post: post, form: post |> Post.changeset() |> to_form())
|
||||
|
||||
{:error, changeset} ->
|
||||
assign(socket, form: to_form(changeset))
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.form for={@form} class="flex flex-col gap-y-2" phx-submit="save_post">
|
||||
<.input type="hidden" field={@form[:contents]} />
|
||||
<.input class="border-gray-400 rounded" field={@form[:title]} />
|
||||
<div id="editor" phx-update="ignore">
|
||||
<trix-editor input={@form[:contents].id} class="prose prose-gray max-w-full"></trix-editor>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="self-end">save</button>
|
||||
</.form>
|
||||
<link rel="stylesheet" type="text/css" href="https://unpkg.com/trix@2.0.8/dist/trix.css" />
|
||||
<script defer phx-track-static type="text/javascript" src={~p"/assets/editor.js"}>
|
||||
</script>
|
||||
"""
|
||||
end
|
||||
end
|
|
@ -32,8 +32,9 @@ defmodule CMSWeb.Router do
|
|||
pipe_through :supports_admin_action
|
||||
|
||||
get "/", PageController, :home
|
||||
get "/writing", PageController, :writing
|
||||
get "/microblog", PageController, :microblog
|
||||
|
||||
get "/posts", PostController, :index
|
||||
get "/posts/:post_id", PostController, :show
|
||||
|
||||
live "/sign-in", AdminLoginLive
|
||||
post "/admin/session/create", AdminSessionController, :create
|
||||
|
@ -45,6 +46,9 @@ defmodule CMSWeb.Router do
|
|||
pipe_through :requires_admin
|
||||
|
||||
live "/", AdminLive
|
||||
|
||||
live "/posts/new", PostLive, :new
|
||||
live "/posts/:post_id", PostLive, :edit
|
||||
end
|
||||
end
|
||||
|
||||
|
|
13
priv/repo/migrations/20250222164951_add_posts_table.exs
Normal file
13
priv/repo/migrations/20250222164951_add_posts_table.exs
Normal file
|
@ -0,0 +1,13 @@
|
|||
defmodule CMS.Repo.Migrations.AddPostsTable do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:posts, primary_key: false) do
|
||||
add :id, :uuid, primary_key: true
|
||||
add :title, :text
|
||||
add :contents, :text, null: false, default: ""
|
||||
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue