Initial commit
This commit is contained in:
62
app/action.rb
Normal file
62
app/action.rb
Normal file
@@ -0,0 +1,62 @@
|
||||
# auto_register: false
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "hanami/action"
|
||||
require "httparty"
|
||||
|
||||
module Adamantium
|
||||
class Action < Hanami::Action
|
||||
include Deps["logger", "settings", not_found_view: "views.not_found"]
|
||||
|
||||
handle_exception ROM::TupleCountMismatchError => :not_found
|
||||
|
||||
def authenticate!(req, res)
|
||||
if Hanami.env == :development || Hanami.env == :test
|
||||
req.env[:scopes] = verify_token(nil)
|
||||
return true
|
||||
end
|
||||
|
||||
# Pull out and verify the authorization header or access_token
|
||||
if req.env["HTTP_AUTHORIZATION"]
|
||||
header = req.env["HTTP_AUTHORIZATION"].match(/Bearer (.*)$/)
|
||||
access_token = header[1] unless header.nil?
|
||||
elsif req.params["access_token"]
|
||||
access_token = req.params["access_token"]
|
||||
else
|
||||
logger.error "Received request without a token"
|
||||
halt 401
|
||||
end
|
||||
|
||||
req.env[:access_token] = access_token
|
||||
|
||||
# Verify the token and extract scopes
|
||||
req.env[:scopes] = verify_token(access_token)
|
||||
end
|
||||
|
||||
def not_found(_req, res, _exception)
|
||||
res.render not_found_view
|
||||
end
|
||||
|
||||
def verify_scope(req:, scope:)
|
||||
req.env[:scopes].include? scope
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def verify_token(access_token)
|
||||
return %i[create update delete undelete media] if Hanami.env == :development || Hanami.env == :test
|
||||
|
||||
resp = HTTParty.get(settings.micropub_token_endpoint, {
|
||||
headers: {
|
||||
"Accept" => "application/x-www-form-urlencoded",
|
||||
"Authorization" => "Bearer #{access_token}"
|
||||
}
|
||||
})
|
||||
decoded_response = URI.decode_www_form(resp.body).to_h.transform_keys(&:to_sym)
|
||||
|
||||
halt 401 unless (decoded_response.include? :scope) && (decoded_response.include? :me)
|
||||
|
||||
decoded_response[:scope].gsub(/post/, "create").split.map(&:to_sym)
|
||||
end
|
||||
end
|
||||
end
|
0
app/actions/.keep
Normal file
0
app/actions/.keep
Normal file
13
app/actions/bookmarks/index.rb
Normal file
13
app/actions/bookmarks/index.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
module Adamantium
|
||||
module Actions
|
||||
module Bookmarks
|
||||
class Index < Action
|
||||
include Deps["views.bookmarks.index"]
|
||||
|
||||
def handle(req, res)
|
||||
res.render index, query: req.params[:q]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
12
app/actions/bookmarks/show.rb
Normal file
12
app/actions/bookmarks/show.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
module Adamantium
|
||||
module Actions
|
||||
module Bookmarks
|
||||
class Show < Action
|
||||
include Deps["views.bookmarks.show"]
|
||||
def handle(req, res)
|
||||
res.render show, slug: req.params[:slug]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
14
app/actions/feeds/rss.rb
Normal file
14
app/actions/feeds/rss.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
module Adamantium
|
||||
module Actions
|
||||
module Feeds
|
||||
class Rss < Action
|
||||
include Deps["views.feeds.rss"]
|
||||
|
||||
def handle(req, res)
|
||||
res.content_type = "application/rss+xml"
|
||||
res.render rss, format: :xml
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
13
app/actions/key/show.rb
Normal file
13
app/actions/key/show.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
module Adamantium
|
||||
module Actions
|
||||
module Key
|
||||
class Show < Action
|
||||
include Deps["settings"]
|
||||
def handle(req, res)
|
||||
res.content_type = "text/plain"
|
||||
res.body = settings.micropub_pub_key
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
28
app/actions/media/create.rb
Normal file
28
app/actions/media/create.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
module Adamantium
|
||||
module Actions
|
||||
module Media
|
||||
class Create < Action
|
||||
before :authenticate
|
||||
|
||||
include Deps["commands.media.upload"]
|
||||
|
||||
def handle(req, res)
|
||||
data = req.params[:file]
|
||||
|
||||
halt 401 if verify_scope(req: req, scope: :media)
|
||||
|
||||
upload(file: data) do |m|
|
||||
m.failure do |v|
|
||||
res.status = 422
|
||||
end
|
||||
|
||||
m.success do |v|
|
||||
res.status = 201
|
||||
res.body = v
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
15
app/actions/pages/show.rb
Normal file
15
app/actions/pages/show.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
module Adamantium
|
||||
module Actions
|
||||
module Pages
|
||||
class Show < Action
|
||||
include Deps["views.pages.show"]
|
||||
|
||||
def handle(req, res)
|
||||
slug = req.params[:slug]
|
||||
|
||||
res.render show, slug: slug
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
66
app/actions/posts/handle.rb
Normal file
66
app/actions/posts/handle.rb
Normal file
@@ -0,0 +1,66 @@
|
||||
require "pry"
|
||||
|
||||
module Adamantium
|
||||
module Actions
|
||||
module Posts
|
||||
class Handle < Action
|
||||
before :authenticate!
|
||||
|
||||
include Deps[
|
||||
"settings",
|
||||
post_param_parser: "param_parser.micropub_post",
|
||||
create_resolver: "commands.posts.creation_resolver",
|
||||
delete_post: "commands.posts.delete",
|
||||
undelete_post: "commands.posts.undelete",
|
||||
update_post: "commands.posts.update"
|
||||
]
|
||||
|
||||
def handle(req, res)
|
||||
req_entity = post_param_parser.call(params: req.params.to_h)
|
||||
action = req.params[:action]
|
||||
|
||||
if action
|
||||
operation, permission_check = resolve_operation(action)
|
||||
|
||||
if permission_check.call(req)
|
||||
operation.call(params: req.params.to_h)
|
||||
res.status = 200
|
||||
else
|
||||
res.status = 401
|
||||
end
|
||||
elsif req_entity
|
||||
halt 401 unless verify_scope(req: req, scope: :create)
|
||||
|
||||
command, contract = create_resolver.call(entry_type: req_entity).values_at(:command, :validation)
|
||||
|
||||
validation = contract.call(req_entity.to_h)
|
||||
if validation.success?
|
||||
post = command.call(validation.to_h)
|
||||
|
||||
res.status = 201
|
||||
res.headers.merge!(
|
||||
"Location" => "#{settings.micropub_site_url}/#{post.post_type}/#{post.slug}"
|
||||
)
|
||||
else
|
||||
res.body = {error: validation.errors.to_h}.to_json
|
||||
res.status = 422
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolve_operation(action)
|
||||
case action
|
||||
when "delete"
|
||||
[delete_post, ->(req) { verify_scope(req: req, scope: :delete) }]
|
||||
when "undelete"
|
||||
[undelete_post, ->(req) { verify_scope(req: req, scope: :undelete) }]
|
||||
when "update"
|
||||
[update_post, ->(req) { verify_scope(req: req, scope: :update) }]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
12
app/actions/posts/index.rb
Normal file
12
app/actions/posts/index.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
module Adamantium
|
||||
module Actions
|
||||
module Posts
|
||||
class Index < Action
|
||||
include Deps["views.posts.index"]
|
||||
def handle(req, res)
|
||||
res.render index
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
15
app/actions/posts/show.rb
Normal file
15
app/actions/posts/show.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
module Adamantium
|
||||
module Actions
|
||||
module Posts
|
||||
class Show < Action
|
||||
include Deps["views.posts.show"]
|
||||
|
||||
def handle(req, res)
|
||||
slug = req.params[:slug]
|
||||
|
||||
res.render show, slug: slug
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
31
app/actions/site/config.rb
Normal file
31
app/actions/site/config.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
module Adamantium
|
||||
module Actions
|
||||
module Site
|
||||
class Config < Action
|
||||
include Deps["settings", "views.site.home"]
|
||||
|
||||
def handle(req, res)
|
||||
if req.params[:q] == "config"
|
||||
res.status = 200
|
||||
res.content_type = "Application/JSON"
|
||||
res.body = {
|
||||
"media-endpoint" => settings.micropub_media_endpoint,
|
||||
"destination" => [
|
||||
{uid: settings.micropub_site_id, name: settings.micropub_site_name}
|
||||
],
|
||||
"post-types" => [
|
||||
{type: "note", name: "Note", properties: %w[content category]},
|
||||
{type: "article", name: "Article", properties: %w[name content category]},
|
||||
{type: "photo", name: "Photo", properties: %w[name content category]},
|
||||
{type: "bookmark", name: "Bookmark", properties: %w[name content category]}
|
||||
],
|
||||
"syndicate-to" => []
|
||||
}.to_json
|
||||
else
|
||||
res.render home
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
12
app/actions/site/home.rb
Normal file
12
app/actions/site/home.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
module Adamantium
|
||||
module Actions
|
||||
module Site
|
||||
class Home < Action
|
||||
include Deps["views.site.home"]
|
||||
def handle(req, res)
|
||||
res.render home
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
15
app/actions/tags/show.rb
Normal file
15
app/actions/tags/show.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
module Adamantium
|
||||
module Actions
|
||||
module Tags
|
||||
class Show < Action
|
||||
include Deps["views.tags.show"]
|
||||
|
||||
def handle(req, res)
|
||||
slug = req.params[:slug]
|
||||
|
||||
res.render show, slug: slug
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
27
app/assets/index.css
Normal file
27
app/assets/index.css
Normal file
@@ -0,0 +1,27 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@tailwind typography;
|
||||
|
||||
@font-face {
|
||||
font-family: "Rubik";
|
||||
src: url("/assets/Rubik-VariableFont_wght.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "JetBrainsMono";
|
||||
src: url("/assets/JetBrainsMono-VariableFont_wght.ttf") format("truetype");
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: "Rubik", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.gist tr {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.gist span {
|
||||
font-family: "JetBrainsMono", Monaco, monospace;
|
||||
}
|
||||
|
32
app/commands/media/upload.rb
Normal file
32
app/commands/media/upload.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Adamantium
|
||||
module Commands
|
||||
module Media
|
||||
class Upload < Command
|
||||
include Deps["settings"]
|
||||
|
||||
def call(file:)
|
||||
pathname = Time.now.strftime("%m-%Y")
|
||||
|
||||
filename = file[:filename].split("/").last
|
||||
|
||||
dirname = File.join("public", "media", pathname)
|
||||
|
||||
unless File.directory?(dirname)
|
||||
FileUtils.mkdir_p(dirname)
|
||||
end
|
||||
|
||||
begin
|
||||
File.write(File.join(dirname, filename), file[:tempfile].read)
|
||||
rescue Errno::ENOENT, NoMethodError => e
|
||||
return Failure(e.message)
|
||||
end
|
||||
|
||||
upload_path = File.join(settings.micropub_site_url, "/media/", "/#{pathname}/", filename).to_s
|
||||
Success(upload_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
12
app/commands/posts/create_bookmark.rb
Normal file
12
app/commands/posts/create_bookmark.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
module Adamantium
|
||||
module Commands
|
||||
module Posts
|
||||
class CreateBookmark < Command
|
||||
include Deps["repos.post_repo"]
|
||||
def call(bookmark)
|
||||
post_repo.create(bookmark)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
18
app/commands/posts/create_entry.rb
Normal file
18
app/commands/posts/create_entry.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
module Adamantium
|
||||
module Commands
|
||||
module Posts
|
||||
class CreateEntry < Command
|
||||
include Deps["repos.post_repo",
|
||||
"post_utilities.slugify",
|
||||
renderer: "renderers.markdown"
|
||||
]
|
||||
def call(post)
|
||||
attrs = post.to_h
|
||||
attrs[:content] = renderer.call(content: attrs[:content])
|
||||
|
||||
post_repo.create(attrs)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
23
app/commands/posts/creation_resolver.rb
Normal file
23
app/commands/posts/creation_resolver.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
module Adamantium
|
||||
module Commands
|
||||
module Posts
|
||||
class CreationResolver
|
||||
include Deps[
|
||||
"validation.posts.post_contract",
|
||||
"validation.posts.bookmark_contract",
|
||||
"commands.posts.create_entry",
|
||||
"commands.posts.create_bookmark"
|
||||
]
|
||||
|
||||
def call(entry_type:)
|
||||
case entry_type
|
||||
in Entities::BookmarkRequest
|
||||
{command: create_bookmark, validation: bookmark_contract}
|
||||
else
|
||||
{command: create_entry, validation: post_contract}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
14
app/commands/posts/delete.rb
Normal file
14
app/commands/posts/delete.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
module Adamantium
|
||||
module Commands
|
||||
module Posts
|
||||
class Delete < Command
|
||||
include Deps["repos.post_repo"]
|
||||
def call(params:)
|
||||
slug = URI(params[:url]).path.split("/").last
|
||||
|
||||
post_repo.delete!(slug)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
14
app/commands/posts/undelete.rb
Normal file
14
app/commands/posts/undelete.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
module Adamantium
|
||||
module Commands
|
||||
module Posts
|
||||
class Undelete < Command
|
||||
include Deps["repos.post_repo"]
|
||||
def call(params:)
|
||||
slug = URI(params[:url]).path.split("/").last
|
||||
|
||||
post_repo.restore!(slug)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
13
app/commands/posts/update.rb
Normal file
13
app/commands/posts/update.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
module Adamantium
|
||||
module Commands
|
||||
module Posts
|
||||
class Update < Command
|
||||
def call(params)
|
||||
slug = URI(params[:url]).path.split("/").last
|
||||
|
||||
post_repo.update(slug, params)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
2
app/content/home.md
Normal file
2
app/content/home.md
Normal file
@@ -0,0 +1,2 @@
|
||||
Hi! 👋 I'm Daniel, a software engineer living in Canberra, Australia.
|
||||
|
17
app/content/pages/about.md
Normal file
17
app/content/pages/about.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# About
|
||||
|
||||
Hi, I'm _Daniel_.
|
||||
|
||||
I've been in the software / web industry for around 10 years. I'm currently a technical lead at [Culture Amp](https://cultureamp.com), where I lead a small team working in a Rails monolith and across many microservices. Previously I worked with the wonderful humans of [Icelab](https://icelab.com.au) on a wide range of interesting and valuable projects.
|
||||
|
||||
I currently live in Canberra with my partner and [our dogs](https://instagram.com/barkly_and_crumpet).
|
||||
|
||||
In my spare time I like to tinker on various Ruby projects (including the software that powers this blog), make things with [Processing](https://processing.org), explore the various walking tracks around our house and potter around in the garden.
|
||||
|
||||
### Contact me
|
||||
|
||||
- [Email](mailto:hello@dnitza.com)
|
||||
- [Mastodon](https://social.dnitza.com/@daniel)
|
||||
- [Github](https://github.com/dnitza)
|
||||
|
||||
|
1
app/content/pages/art-things.md
Normal file
1
app/content/pages/art-things.md
Normal file
@@ -0,0 +1 @@
|
||||
# Art things
|
15
app/decorators/bookmarks/decorator.rb
Normal file
15
app/decorators/bookmarks/decorator.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: false
|
||||
|
||||
# auto_register: false
|
||||
|
||||
module Adamantium
|
||||
module Decorators
|
||||
module Bookmarks
|
||||
class Decorator < SimpleDelegator
|
||||
def display_published_at
|
||||
published_at.strftime("%e %B, %Y")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
73
app/decorators/posts/decorator.rb
Normal file
73
app/decorators/posts/decorator.rb
Normal file
@@ -0,0 +1,73 @@
|
||||
# frozen_string_literal: false
|
||||
|
||||
# auto_register: false
|
||||
|
||||
require "rexml/parsers/pullparser"
|
||||
|
||||
module Adamantium
|
||||
module Decorators
|
||||
module Posts
|
||||
class Decorator < SimpleDelegator
|
||||
def prefix_emoji
|
||||
name ? "📝" : "📯"
|
||||
end
|
||||
|
||||
def display_title
|
||||
title = name || published_at.strftime("%D")
|
||||
"#{prefix_emoji} #{title}"
|
||||
end
|
||||
|
||||
def display_published_at
|
||||
published_at.strftime("%e %B, %Y")
|
||||
end
|
||||
|
||||
def machine_published_at
|
||||
published_at.rfc2822
|
||||
end
|
||||
|
||||
def excerpt
|
||||
truncate_html(content, 140, true)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def truncate_html(content, len = 30, at_end = nil)
|
||||
p = REXML::Parsers::PullParser.new(content)
|
||||
tags = []
|
||||
new_len = len
|
||||
results = ""
|
||||
while p.has_next? && new_len > 0
|
||||
p_e = p.pull
|
||||
case p_e.event_type
|
||||
when :start_element
|
||||
tags.push p_e[0]
|
||||
results << "<#{tags.last}#{attrs_to_s(p_e[1])}>"
|
||||
when :end_element
|
||||
results << "</#{tags.pop}>"
|
||||
when :text
|
||||
results << p_e[0][0..new_len]
|
||||
new_len -= p_e[0].length
|
||||
else
|
||||
results << "<!-- #{p_e.inspect} -->"
|
||||
end
|
||||
end
|
||||
if at_end
|
||||
results << "..."
|
||||
end
|
||||
tags.reverse_each do |tag|
|
||||
results << "</#{tag}>"
|
||||
end
|
||||
results
|
||||
end
|
||||
|
||||
def attrs_to_s(attrs)
|
||||
if attrs.empty?
|
||||
""
|
||||
else
|
||||
" " + attrs.to_a.map { |attr| %(#{attr[0]}="#{attr[1]}") }.join(" ")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
15
app/entities/bookmark_request.rb
Normal file
15
app/entities/bookmark_request.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
module Adamantium
|
||||
module Entities
|
||||
class BookmarkRequest < Dry::Struct
|
||||
attribute :h, Types::Coercible::String
|
||||
attribute :action, Types::Coercible::String.optional
|
||||
attribute :name, Types::Coercible::String
|
||||
attribute :content, Types::Coercible::String.optional
|
||||
attribute :url, Types::Coercible::String
|
||||
attribute :slug, Types::Coercible::String
|
||||
attribute :category, Types::Array.of(Types::Coercible::String)
|
||||
attribute :published_at, Types::Nominal::DateTime.optional
|
||||
attribute :post_type, Types::Coercible::String
|
||||
end
|
||||
end
|
||||
end
|
14
app/entities/post_request.rb
Normal file
14
app/entities/post_request.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
module Adamantium
|
||||
module Entities
|
||||
class PostRequest < Dry::Struct
|
||||
attribute :h, Types::Coercible::String
|
||||
attribute :action, Types::Coercible::String.optional
|
||||
attribute :name, Types::Coercible::String.optional
|
||||
attribute :content, Types::Coercible::String
|
||||
attribute :slug, Types::Coercible::String
|
||||
attribute :category, Types::Array.of(Types::Coercible::String)
|
||||
attribute :published_at, Types::Nominal::DateTime.optional
|
||||
attribute :post_type, Types::Coercible::String
|
||||
end
|
||||
end
|
||||
end
|
84
app/repos/post_repo.rb
Normal file
84
app/repos/post_repo.rb
Normal file
@@ -0,0 +1,84 @@
|
||||
module Adamantium
|
||||
module Repos
|
||||
class PostRepo < Adamantium::Repo[:posts]
|
||||
commands :update
|
||||
|
||||
def create(post_attrs)
|
||||
posts.transaction do
|
||||
new_post = posts.changeset(:create, post_attrs).commit
|
||||
|
||||
post_attrs[:category].each do |tag_name|
|
||||
next if tag_name == ""
|
||||
|
||||
tag = posts.tags.where(label: tag_name).one ||
|
||||
posts
|
||||
.tags
|
||||
.changeset(:create, {label: tag_name, slug: tag_name.downcase.strip.tr(" ", "-").gsub(/[^\w-]/, "")})
|
||||
.commit
|
||||
|
||||
posts.post_tags.changeset(:create, {
|
||||
post_id: new_post.id,
|
||||
tag_id: tag[:id]
|
||||
})
|
||||
.commit
|
||||
end
|
||||
|
||||
new_post
|
||||
end
|
||||
end
|
||||
|
||||
def post_listing(limit: nil)
|
||||
posts
|
||||
.where(post_type: "post")
|
||||
.published
|
||||
.combine(:tags)
|
||||
.order(Sequel.desc(:published_at))
|
||||
.limit(limit)
|
||||
.to_a
|
||||
end
|
||||
|
||||
def bookmark_listing(query: nil)
|
||||
base = posts
|
||||
.where(post_type: "bookmark")
|
||||
.published
|
||||
.combine(:tags)
|
||||
.order(Sequel.desc(:published_at))
|
||||
|
||||
query ? base.where(Sequel.ilike(:name, "%#{query}%")).to_a : base.to_a
|
||||
end
|
||||
|
||||
def for_rss
|
||||
posts
|
||||
.where(post_type: "post")
|
||||
.published
|
||||
.combine(:tags)
|
||||
.order(Sequel.desc(:published_at))
|
||||
.to_a
|
||||
end
|
||||
|
||||
def fetch!(slug)
|
||||
posts
|
||||
.published
|
||||
.combine(:tags)
|
||||
.where(slug: slug)
|
||||
.one!
|
||||
end
|
||||
|
||||
def slug_exists?(slug)
|
||||
!!posts
|
||||
.where(slug: slug)
|
||||
.one
|
||||
end
|
||||
|
||||
def delete!(slug)
|
||||
delete_post = posts.where(slug: slug).command(:update)
|
||||
delete_post.call(published_at: nil)
|
||||
end
|
||||
|
||||
def restore!(slug)
|
||||
delete_post = posts.where(slug: slug).command(:update)
|
||||
delete_post.call(published_at: Time.now)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
26
app/repos/post_tag_repo.rb
Normal file
26
app/repos/post_tag_repo.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
module Adamantium
|
||||
module Repos
|
||||
class PostTagRepo < Adamantium::Repo[:post_tags]
|
||||
def posts_tagged(tag:)
|
||||
tag_id = post_tags
|
||||
.tags
|
||||
.where(slug: tag)
|
||||
.one!
|
||||
.id
|
||||
|
||||
post_ids = post_tags
|
||||
.where(tag_id: tag_id)
|
||||
.to_a
|
||||
.map(&:post_id)
|
||||
|
||||
post_tags
|
||||
.posts
|
||||
.where(id: post_ids)
|
||||
.published
|
||||
.combine(:tags)
|
||||
.order(Sequel.desc(:published_at))
|
||||
.to_a
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
9
app/repos/tag_repo.rb
Normal file
9
app/repos/tag_repo.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
module Adamantium
|
||||
module Repos
|
||||
class TagRepo < Adamantium::Repo[:tags]
|
||||
def fetch!(slug)
|
||||
tags.where(slug: slug).one!
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
13
app/templates/bookmarks/index.html.slim
Normal file
13
app/templates/bookmarks/index.html.slim
Normal file
@@ -0,0 +1,13 @@
|
||||
div class="flex justify-between mb-12 prose dark:prose-invert max-w-prose mx-auto text-gray-800 dark:text-gray-200"
|
||||
div class="basis-1/5"
|
||||
h1 Bookmarks
|
||||
|
||||
form class="basis-2/5" method="GET" action="/bookmarks"
|
||||
input class="w-48 border-blue-400 border-2 rounded mr-2 px-2" id="seach" type="text" name="q" value=q
|
||||
button class="w-16 border-blue-400 border-2 rounded bg-blue-400 hover:bg-blue-800 hover:border-blue-800 hover:text-blue-100 px-1 text-gray-200" type="submit" Search
|
||||
|
||||
div class="mb-12 max-w-prose mx-auto"
|
||||
- bookmarks.each do |bookmark|
|
||||
== render :bookmark, bookmark: bookmark
|
||||
|
||||
div class="max-w-screen-md mx-auto border-t-4 border-solid border-gray-400 dark:border-gray-600"
|
17
app/templates/bookmarks/show.html.slim
Normal file
17
app/templates/bookmarks/show.html.slim
Normal file
@@ -0,0 +1,17 @@
|
||||
div class="mb-12 prose dark:prose-invert max-w-prose mx-auto text-gray-800 dark:text-gray-200"
|
||||
h1 = bookmark.name
|
||||
|
||||
div class="mb-12 prose max-w-prose mx-auto text-gray-800 dark:text-gray-200"
|
||||
a class="text-blue-600 no-underline hover:underline" href=bookmark.url
|
||||
p class="text-xl"
|
||||
= bookmark.url
|
||||
|
||||
== bookmark.content
|
||||
|
||||
div class="mb-8 max-w-screen-md mx-auto border-t-4 border-solid border-gray-400 dark:border-gray-600"
|
||||
|
||||
div class="mb-2 max-w-prose mx-auto text-gray-800 dark:text-gray-200 flex"
|
||||
= "Published #{bookmark.display_published_at}"
|
||||
span class="text-right flex-1"
|
||||
== render :tags, tags: bookmark.tags
|
||||
|
20
app/templates/feeds/rss.xml.builder
Normal file
20
app/templates/feeds/rss.xml.builder
Normal file
@@ -0,0 +1,20 @@
|
||||
xml.instruct!(:xml, version: "2.0", encoding: "utf-8")
|
||||
|
||||
xml.channel do |channel|
|
||||
channel.title "Daniel Nitsikopoulos"
|
||||
channel.description "The RSS feed for https://dnitza.com"
|
||||
channel.lastBuildDate Time.now.rfc2822
|
||||
channel.pubDate Time.now.rfc2822
|
||||
channel.ttl 1800
|
||||
|
||||
posts.each do |post|
|
||||
channel.item do |item|
|
||||
item.title post.display_title
|
||||
item.description do |desc|
|
||||
desc.cdata! post.content
|
||||
end
|
||||
item.guid(post.slug, isPermaLink: true)
|
||||
item.pubDate post.machine_published_at
|
||||
end
|
||||
end
|
||||
end
|
46
app/templates/layouts/app.html.slim
Normal file
46
app/templates/layouts/app.html.slim
Normal file
@@ -0,0 +1,46 @@
|
||||
html
|
||||
head
|
||||
meta charest="utf-8"
|
||||
|
||||
meta name="viewport" content="width=device-width, initial-scale=1.0"
|
||||
|
||||
title Daniel Nitsikopoulos
|
||||
|
||||
link rel="authorization_endpoint" href=Hanami.app.settings.micropub_authorization_endpoint
|
||||
link rel="token_endpoint" href=Hanami.app.settings.micropub_token_endpoint
|
||||
link rel="micropub" href="#{URI.join(Hanami.app.settings.micropub_site_url, "micropub")}"
|
||||
|
||||
link rel="stylesheet" href="/assets/index.css"
|
||||
|
||||
script data-domain="dnitza.com" src="https://stats.dnitza.com/js/script.js" defer="true"
|
||||
|
||||
/ script src="https://unpkg.com/htmx.org@1.8.4" integrity="sha384-wg5Y/JwF7VxGk4zLsJEcAojRtlVp1FKKdGy1qN+OMtdq72WRvX/EdRdqg/LOhYeV" crossorigin="anonymous"
|
||||
|
||||
- if Hanami.app.settings.micropub_pub_key
|
||||
link rel="pgpkey" href="/key"
|
||||
body class="bg-white dark:bg-black selection:bg-blue-100 selection:text-blue-900 dark:selection:bg-blue-600 dark:selection:text-blue-100"
|
||||
main class="pb-8 px-4 pt-4 md:pt-8"
|
||||
header class="mb-12 max-w-screen-md mx-auto"
|
||||
div class="flex items-center mb-8 md:mb-12 text-lg md:text-xl text-gray-400 dark:text-gray-600"
|
||||
div class="flex-none mx-auto md:flex-auto md:mx-0"
|
||||
div class="h-card flex items-center"
|
||||
img class="u-photo w-6 h6 md:w-10 md:h-10 rounded-full mr-1.5" src="/assets/memoji.png"
|
||||
a href="/"
|
||||
h1 class="p-bane uppercase text-sm md:text-sm text-gray-400 dark:text-gray-400" = Hanami.app.settings.site_name
|
||||
nav class="space-x-1 text-sm md:text-sm uppercase md:block"
|
||||
a class="p-1 rounded text-gray-400 hover:bg-red-100 hover:text-red-400 dark:hover:bg-red-200 #{link_active?('about') ? 'text-red-600 dark:text-red-400' : ''}" href="/about" About
|
||||
span class="text-gray-400 dark:text-gray-600"
|
||||
= "/"
|
||||
a class="p-1 rounded text-gray-400 hover:bg-blue-100 hover:text-blue-400 dark:hover:bg-blue-200" href="/posts" Writing
|
||||
span class="text-gray-400 dark:text-gray-600"
|
||||
= "/"
|
||||
a class="p-1 rounded text-gray-400 hover:bg-yellow-100 hover:text-yellow-600 dark:hover:bg-yellow-200 #{link_active?('bookmarks') ? 'text-yellow-600 dark:text-yellow-600' : ''}" href="/bookmarks" Bookmarks
|
||||
span class="text-gray-400 dark:text-gray-600"
|
||||
= "/"
|
||||
a class="p-1 rounded text-gray-400 hover:bg-orange-100 hover:text-orange-400 dark:hover:bg-orange-200" href="#{Hanami.app.settings.micropub_site_url}/feeds/rss" RSS
|
||||
// span class="text-gray-400 dark:text-gray-600"
|
||||
= "/"
|
||||
// a class="text-green-600 hover:text-gray-800 dark:hover:text-gray-200" href="/art-things" Art things
|
||||
== yield
|
||||
div class="px-4 max-w-screen-md mx-auto pb-10"
|
||||
p class="text-gray-200 dark:text-gray-600" © 2023 Daniel Nitsikopoulos. All rights reserved.
|
0
app/templates/layouts/app.xml.builder
Normal file
0
app/templates/layouts/app.xml.builder
Normal file
3
app/templates/not_found.html.slim
Normal file
3
app/templates/not_found.html.slim
Normal file
@@ -0,0 +1,3 @@
|
||||
div class="mb-12 prose dark:prose-invert max-w-prose mx-auto text-gray-800 dark:text-gray-200"
|
||||
h1 Not Found!
|
||||
|
4
app/templates/pages/show.html.slim
Normal file
4
app/templates/pages/show.html.slim
Normal file
@@ -0,0 +1,4 @@
|
||||
article class="mb-12 prose dark:prose-invert max-w-prose mx-auto text-gray-800 dark:text-gray-200 prose-em:font-bold prose-em:not-italic prose-em:bg-blue-600 prose-em:px-1 prose-a:text-blue-600 prose-a:p-0.5 prose-a:rounded-sm prose-a:no-underline hover:prose-a:underline prose-em:text-blue-100"
|
||||
== page_content
|
||||
|
||||
div class="max-w-screen-md mx-auto border-t-4 border-solid border-gray-400 dark:border-gray-600"
|
8
app/templates/posts/index.html.slim
Normal file
8
app/templates/posts/index.html.slim
Normal file
@@ -0,0 +1,8 @@
|
||||
div class="mb-12 prose dark:prose-invert max-w-prose mx-auto text-gray-800 dark:text-gray-200"
|
||||
h1 Writing
|
||||
|
||||
div class="mb-12 max-w-prose mx-auto"
|
||||
- posts.each do |post|
|
||||
== render :post, post: post
|
||||
|
||||
div class="max-w-screen-md mx-auto border-t-4 border-solid border-gray-400 dark:border-gray-600"
|
12
app/templates/posts/show.html.slim
Normal file
12
app/templates/posts/show.html.slim
Normal file
@@ -0,0 +1,12 @@
|
||||
div class="mb-12 prose dark:prose-invert max-w-prose mx-auto text-gray-800 dark:text-gray-200"
|
||||
h1 = post.display_title
|
||||
|
||||
article class="mb-12 prose dark:prose-invert max-w-prose mx-auto text-gray-800 dark:text-gray-200 prose-a:text-blue-600 prose-a:no-underline hover:prose-a:underline"
|
||||
== post.content
|
||||
|
||||
div class="mb-4 max-w-screen-md mx-auto border-t-4 border-solid border-gray-400 dark:border-gray-600"
|
||||
|
||||
div class="mb-2 max-w-prose mx-auto text-gray-600 dark:text-gray-200 flex"
|
||||
= "Published #{post.display_published_at}"
|
||||
span class="text-right flex-1"
|
||||
== render :tags, tags: post.tags
|
12
app/templates/shared/_bookmark.html.slim
Normal file
12
app/templates/shared/_bookmark.html.slim
Normal file
@@ -0,0 +1,12 @@
|
||||
div class="mb-8"
|
||||
h3 class="text-xl font-bold text-blue-600 hover:underline"
|
||||
a href="#{bookmark.url}"
|
||||
= "🔖 #{bookmark.name} "
|
||||
== render("link_arrow")
|
||||
p class="e-content leading-relaxed md:text-lg text-gray-800 dark:text-gray-200"
|
||||
= bookmark.content
|
||||
== render :tags, tags: bookmark.tags
|
||||
|
||||
p class="text-sm u-url text-blue-400 hover:text-blue-600 dark:hover:text-blue-200"
|
||||
a href="/bookmark/#{bookmark.slug}"
|
||||
= bookmark.display_published_at
|
4
app/templates/shared/_link_arrow.html.slim
Normal file
4
app/templates/shared/_link_arrow.html.slim
Normal file
@@ -0,0 +1,4 @@
|
||||
svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="inline w-2 h2 md:w-4 md:h-4 mt-0.5"
|
||||
g
|
||||
rect height="14.4434" opacity="0" width="14.4238" x="0" y="0"
|
||||
path d="M14.4238 10.8008L14.4141 0.976562C14.4141 0.419922 14.0527 0.0292969 13.4668 0.0292969L3.64258 0.0292969C3.0957 0.0292969 2.72461 0.449219 2.72461 0.917969C2.72461 1.38672 3.14453 1.78711 3.60352 1.78711L7.00195 1.78711L11.7676 1.63086L9.95117 3.22266L0.273438 12.9199C0.0976562 13.0957 0 13.3203 0 13.5352C0 14.0039 0.419922 14.4434 0.908203 14.4434C1.13281 14.4434 1.34766 14.3652 1.52344 14.1797L11.2207 4.49219L12.832 2.66602L12.6562 7.22656L12.6562 10.8398C12.6562 11.2988 13.0566 11.7285 13.5352 11.7285C14.0039 11.7285 14.4238 11.3281 14.4238 10.8008Z"
|
10
app/templates/shared/_post.html.slim
Normal file
10
app/templates/shared/_post.html.slim
Normal file
@@ -0,0 +1,10 @@
|
||||
div class="mb-8"
|
||||
h3 class="text-xl font-bold text-blue-600 hover:underline"
|
||||
a href="/post/#{post.slug}"
|
||||
= post.display_title
|
||||
div class="text-base text-gray-800 dark:text-gray-200"
|
||||
== post.excerpt
|
||||
== render :tags, tags: post.tags
|
||||
|
||||
p class="text-sm text-blue-400"
|
||||
= post.display_published_at
|
8
app/templates/shared/_tags.html.slim
Normal file
8
app/templates/shared/_tags.html.slim
Normal file
@@ -0,0 +1,8 @@
|
||||
- if tags.count > 0
|
||||
div class="mb-2"
|
||||
span class="text-sm text-gray-600 dark:text-gray-200"
|
||||
= "Tagged: "
|
||||
- tags.each do |tag|
|
||||
span
|
||||
a class="rounded p-1 mr-1 text-xsm u-url bg-yellow-200/60 hover:bg-yellow-200 dark:bg-yellow-400 dark:hover:bg-yellow-400/80 dark:text-yellow-800 dark:hover:text-yellow-100 text-gray-600" href="/tagged/#{tag.slug}"
|
||||
= tag.label
|
14
app/templates/site/home.html.slim
Normal file
14
app/templates/site/home.html.slim
Normal file
@@ -0,0 +1,14 @@
|
||||
div class="mb-12 max-w-prose mx-auto text-gray-800 dark:text-gray-200"
|
||||
== home_content
|
||||
|
||||
div class="mb-8 max-w-screen-md mx-auto border-t-4 border-solid border-gray-400 dark:border-gray-600"
|
||||
|
||||
div class="mb-4 flex max-w-prose mx-auto"
|
||||
h2 class="text-xl text-gray-600 dark:text-gray-200" Recent
|
||||
a class="text-right flex-1 text-blue-400" href="/posts" See all →
|
||||
|
||||
div class="mb-12 max-w-prose mx-auto"
|
||||
- posts.each do |post|
|
||||
== render :post, post: post
|
||||
|
||||
div class="max-w-screen-md mx-auto border-t-4 border-solid border-gray-400 dark:border-gray-600"
|
8
app/templates/tags/show.html.slim
Normal file
8
app/templates/tags/show.html.slim
Normal file
@@ -0,0 +1,8 @@
|
||||
div class="mb-12 prose dark:prose-invert max-w-prose mx-auto text-gray-800 dark:text-gray-200"
|
||||
h1 = "Tagged: \"#{tag.label}\""
|
||||
|
||||
div class="mb-12 max-w-prose mx-auto"
|
||||
- posts.each do |post|
|
||||
== render post.post_type.to_sym, post.post_type.to_sym => post
|
||||
|
||||
div class="max-w-screen-md mx-auto border-t-4 border-solid border-gray-400 dark:border-gray-600"
|
17
app/validation/posts/bookmark_contract.rb
Normal file
17
app/validation/posts/bookmark_contract.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
module Adamantium
|
||||
module Validation
|
||||
module Posts
|
||||
class BookmarkContract < Dry::Validation::Contract
|
||||
params do
|
||||
required(:name).filled(:string)
|
||||
required(:content).maybe(:string)
|
||||
required(:category).array(:string)
|
||||
required(:published_at).maybe(:time)
|
||||
required(:url).filled(:string)
|
||||
required(:slug).filled(:string)
|
||||
required(:post_type).value(included_in?: %w[bookmark])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
16
app/validation/posts/post_contract.rb
Normal file
16
app/validation/posts/post_contract.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
module Adamantium
|
||||
module Validation
|
||||
module Posts
|
||||
class PostContract < Dry::Validation::Contract
|
||||
params do
|
||||
required(:name).maybe(:string)
|
||||
required(:content).filled(:string)
|
||||
required(:category).array(:string)
|
||||
required(:published_at).maybe(:time)
|
||||
required(:slug).filled(:string)
|
||||
required(:post_type).value(included_in?: %w[post])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
19
app/views/bookmarks/index.rb
Normal file
19
app/views/bookmarks/index.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
module Adamantium
|
||||
module Views
|
||||
module Bookmarks
|
||||
class Index < Adamantium::View
|
||||
include Deps["repos.post_repo"]
|
||||
|
||||
expose :bookmarks do |query:|
|
||||
post_repo.bookmark_listing(query: query).map do |bookmark|
|
||||
Decorators::Bookmarks::Decorator.new bookmark
|
||||
end
|
||||
end
|
||||
|
||||
expose :q do |query:|
|
||||
query
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
13
app/views/bookmarks/show.rb
Normal file
13
app/views/bookmarks/show.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
module Adamantium
|
||||
module Views
|
||||
module Bookmarks
|
||||
class Show < Adamantium::View
|
||||
include Deps["repos.post_repo"]
|
||||
|
||||
expose :bookmark do |slug:|
|
||||
Decorators::Bookmarks::Decorator.new(post_repo.fetch!(slug))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
23
app/views/feeds/rss.rb
Normal file
23
app/views/feeds/rss.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
require "builder"
|
||||
|
||||
module Adamantium
|
||||
module Views
|
||||
module Feeds
|
||||
class Rss < Adamantium::View
|
||||
include Deps["repos.post_repo"]
|
||||
|
||||
expose :posts do
|
||||
post_repo.for_rss.map do |post|
|
||||
Decorators::Posts::Decorator.new post
|
||||
end
|
||||
end
|
||||
|
||||
expose :xml, decorate: false, layout: true
|
||||
|
||||
def xml
|
||||
Builder::XmlMarkup.new(indent: 2)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
6
app/views/not_found.rb
Normal file
6
app/views/not_found.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
module Adamantium
|
||||
module Views
|
||||
class NotFound < View
|
||||
end
|
||||
end
|
||||
end
|
17
app/views/pages/show.rb
Normal file
17
app/views/pages/show.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
module Adamantium
|
||||
module Views
|
||||
module Pages
|
||||
class Show < Adamantium::View
|
||||
include Deps[renderer: "renderers.markdown"]
|
||||
|
||||
expose :page_content do |slug:|
|
||||
markdown_content = File.read("app/content/pages/#{slug}.md")
|
||||
|
||||
renderer.call(content: markdown_content)
|
||||
rescue Errno::ENOENT
|
||||
renderer.call(content: "## Page not found")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
15
app/views/posts/index.rb
Normal file
15
app/views/posts/index.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
module Adamantium
|
||||
module Views
|
||||
module Posts
|
||||
class Index < Adamantium::View
|
||||
include Deps["repos.post_repo"]
|
||||
|
||||
expose :posts do
|
||||
post_repo.post_listing.map do |post|
|
||||
Decorators::Posts::Decorator.new(post)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
13
app/views/posts/show.rb
Normal file
13
app/views/posts/show.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
module Adamantium
|
||||
module Views
|
||||
module Posts
|
||||
class Show < Adamantium::View
|
||||
include Deps["repos.post_repo"]
|
||||
|
||||
expose :post do |slug:|
|
||||
Decorators::Posts::Decorator.new(post_repo.fetch!(slug))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
21
app/views/site/home.rb
Normal file
21
app/views/site/home.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
module Adamantium
|
||||
module Views
|
||||
module Site
|
||||
class Home < Adamantium::View
|
||||
include Deps["repos.post_repo", renderer: "renderers.markdown"]
|
||||
|
||||
expose :home_content do
|
||||
markdown_content = File.read("app/content/home.md")
|
||||
|
||||
renderer.call(content: markdown_content)
|
||||
end
|
||||
|
||||
expose :posts do
|
||||
post_repo.post_listing(limit: 10).map do |post|
|
||||
Decorators::Posts::Decorator.new(post)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
22
app/views/tags/show.rb
Normal file
22
app/views/tags/show.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
module Adamantium
|
||||
module Views
|
||||
module Tags
|
||||
class Show < Adamantium::View
|
||||
include Deps[
|
||||
"repos.post_tag_repo",
|
||||
"repos.tag_repo"
|
||||
]
|
||||
|
||||
expose :posts do |slug:|
|
||||
post_tag_repo.posts_tagged(tag: slug).map do |post|
|
||||
Decorators::Posts::Decorator.new(post)
|
||||
end
|
||||
end
|
||||
|
||||
expose :tag do |slug:|
|
||||
tag_repo.fetch!(slug)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
Reference in New Issue
Block a user