initial commit

This commit is contained in:
Daniel Flanagan 2020-07-14 16:47:54 -05:00
커밋 458f9e65ef
로그인 계정: lytedev
GPG 키 ID: 5B2020A0F9921EF4
26개의 변경된 파일1557개의 추가작업 그리고 0개의 파일을 삭제

1
.gitignore vendored Normal file
파일 보기

@ -0,0 +1 @@
resources/

29
config.yaml Normal file
파일 보기

@ -0,0 +1,29 @@
baseURL: https://lyte.dev
languageCode: en-us
title: lyte.dev
theme: lyte
pygmentsCodeFences: true
pygmentsCodeFencesGuessSyntax: true
pygmentsUseClasses: true
markup:
goldmark:
renderer:
unsafe: true
# permalinks:
# post: /blog/:title
menu:
main:
- identifier: about
name: about
url: /about
weight: 10
- identifier: blog
name: blog
url: /blog
weight: 20
- identifier: contact
name: contact
url: /contact
weight: 30

17
content/404.md Normal file
파일 보기

@ -0,0 +1,17 @@
---
image: /img/mountain-lake.jpg
imageOverlayColor: "#000"
imageOverlayOpacity: 0.5
heroBackgroundColor: "#840"
title: 404 Not Found
description: "Don't you just hate that?"
---
Looks like you're lost or found a deleted page. Enjoy the picture of the mountains above for just a moment...
Now scoot! Back to whatever you were doing!
Maybe start at [the beginning][home]?
[home]: /

45
content/about.md Normal file
파일 보기

@ -0,0 +1,45 @@
---
date: 2017-02-22T14:43:02-06:00
image: /img/space.jpg
imageOverlayColor: "#000"
imageOverlayOpacity: 0.6
heroBackgroundColor: "#0af"
title: About
description: "A little about the man behind this website."
---
My name is Daniel Flanagan. I was born in Virginia (but my family didn't stay
long) and now live in Kansas City, Missouri. My family still lives in the area
and I have 6 awesome little brothers. Yes, my parents had seven boys and are
fantastic. I'm married and have a son of my own, who is an absolute *joy*!
I love my family quite a lot.
I'm a systems engineer/software developer and an elder-in-training for my <a
href="https://kcrising.church" target="_blank">small church</a> where I help run
the tech and audio-visual systems. My faith is the framework around which I've
built my life and it plays a pivotal role in all aspects of my life.
I'm a big proponent of self-discipline and working from home. My favorite video
game has to be *Super Smash Bros. Melee*. I listen to a lot of metal and
electronic music. My favorite drink is *Red Bull Yellow Edition* and I prefer
tea over coffee. I run GNU+Linux on all my machines. My keyboard is a custom
Iris board that was a blast to put together.
My wife, Valerie, is absolutely the most beautiful and lovely woman in the
entire world. She's a Physical Therapist Assistant by vocation and somewhat of
a fitness and health nut, which has yet to rub off on me (darn!). Now she
raises our son Oliver and savors every second. We've been married since late
2016 and we fall more in love every day. We currently live in a cozy little
apartment which she somehow manages to keep clean, organized, and
well-decorated while managing a growing baby boy.
I grew up not around the dining table, but around the keyboard or latest
Nintendo console; video games were bonding and family time. I discovered
programming through my dad who showed me `QBASIC` after I persisted in asking
how video games were made. Within a few weeks I had made a simple snake game and
he then bought me a book titled *C# for the Absolute Beginner* and that was it.
I was hooked.
Now we're here!
[daniel-and-valerie-pic]: /img/daniel-and-valerie.jpg

5
content/blog/_index.md Normal file
파일 보기

@ -0,0 +1,5 @@
---
title: Blog
---
### Latest Posts

파일 보기

@ -0,0 +1,420 @@
---
image: "/img/teal-bubbles.jpg"
date: "2019-03-28T10:30:00-05:00"
imageOverlayColor: "#000"
imageOverlayOpacity: 0.5
heroBackgroundColor: "#333"
description: "Clean and functional Admin CRUD forms using the metadata already
in your schemas!"
title: Using Ecto Reflection for Simple Admin CRUD Forms in Elixir's Phoenix
draft: false
---
If you're working on a Phoenix project, you probably realized the client might
want to view (or even -- *gasp* -- edit!) their data in a pretty raw form.
Frameworks like [Django][django] provide this out of the box.
[Phoenix][phoenix], however, leaves this up to you!
<!--more-->
Sure, there are [`ex_admin`][ex_admin] and [Torch][torch], but `ex_admin` is
pretty much abandoned and Torch is more of a form generator. Personally, I'm not
really a fan of code generators. In my opinion, if something can generate code,
why doesn't it just offer whatever functionality is being provided by the
generated code?
I'll share what I did and hopefully this helps you!
# TL;DR
Leverage your Ecto schemas' [`__schema__/1`][__schema__] and
[`__schema__/2`][__schema__] functions to retrieve the fields and associations
metadata created when using the [`schema/2`][schema/2] macro. From there, you
can decide how to render an appropriate HTML field in a form. From there, you
can either use [`Ecto.Changeset.change/2`][Ecto.Changeset.change/2] to handle
the results of an admin user submitting those forms or implement some sort of
protocol that lets you specify an admin-specific changeset.
## Getting That Sweet, Sweet Metadata
My first issue was figuring out how to get the metadata that I knew Ecto already
had about my schemas. Y'know, which fields are which types, so that I could use
that metadata to render the appropriate form elements: a text box for
a `:string`, a checkbox for a `:boolean`, a [`multiple`
select][select_attributes] for a [`many_to_many/3`][many_to_many/3] association,
etc.
After asking in the ever-helpful [Elixir Slack][elixir-slack], somebody
mentioned that Ecto Schemas [supported reflection][__schema__] using
a `__schema__` method that could access what I was looking for.
```elixir
iex(1)> MyApp.Accounts.User.__schema__ :fields
[:id, :email, :full_name, :password_hash, :verified, :inserted_at, :updated_at]
iex(2)> MyApp.Accounts.User.__schema__ :associations
[:roles]
iex(3)> MyApp.Accounts.User.__schema__ :type, :id
:id
iex(4)> MyApp.Accounts.User.__schema__ :type, :email
:string
iex(5)> MyApp.Accounts.User.__schema__ :association, :roles
%Ecto.Association.ManyToMany{
cardinality: :many,
defaults: [],
field: :roles,
join_keys: [user_id: :id, role_id: :id],
join_through: MyApp.Accounts.UserRole,
on_cast: nil,
on_delete: :nothing,
on_replace: :raise,
owner: MyApp.Accounts.User,
owner_key: :id,
queryable: MyApp.Accounts.Role,
related: MyApp.Accounts.Role,
relationship: :child,
unique: false,
where: []
}
iex(6)> "siiiiiiiiiick"
...
```
Awesome! Using this, I can *definitely* construct a basic form!
So, we'll want an admin controller that knows how to add new schema entries
generically as well as edit and update existing ones:
```elixir
# admin_controller.ex
@models %{
"user" => MyApp.Accounts.User,
"role" => MyApp.Accounts.Role
# ... and any other possible schema you have!
}
def edit(conn, %{"schema" => schema, "pk" => pk}) do
schema_module = @models[schema]
model = schema_module.__schema__(:associations)
|> Enum.reduce(MyApp.Repo.get(m, pk), fn a, m ->
MyApp.Repo.preload(m, a)
end)
# TODO: load changeset from session?
opts = [
changeset: Ecto.Changeset.change(model, %{}),
schema_module: schema_module,
schema: schema,
pk: pk
]
render(conn, "edit.html", opts)
end
# create/2 works similarly and may be considered an exercise for the reader =)
def update(conn, %{"schema" => schema, "pk" => pk, "data" => data}) do
schema_module = @models[schema]
# TODO: be wary! this lets an admin change EVERYTHING!
# if you want to avoid this like I did, setup an AdminEditable protocol that
# requires your schema to implement an admin-specific changeset and use that
# instead
changeset = Ecto.Changeset.change(MyApp.Repo.get!(module, pk), data)
case MyApp.Repo.update(changeset) do
{:ok, updated_model} ->
conn
|> put_flash(:info, "#{String.capitalize(schema)} ID #{pk} updated!")
|> redirect(to: Routes.admin_path(conn, :edit, schema, pk))
{:error, changeset} ->
conn
|> put_flash(:failed, "#{String.capitalize(schema)} ID #{pk} failed to update!")
|> put_session(:changeset, changeset)
|> redirect(to: Routes.admin_path(conn, :edit, schema, pk))
end
end
```
Yep. That's a lot of code. The first chunk to define the `@models` module
attribute is very simple: map a string to a schema module. Easy. If we want, we
could have this map to a tuple like `"user" => {Module, "user", "users"}` so we
could control how they're displayed as well, passing this information to the
view.
The `edit/2` method takes a schema type (to map to a schema module via
`@models`) and a primary key so we can retrieve the actual entry for that
schema. Generally, this will be a UUID or an integer, but it could be something
more complex, in which case your `router.ex` will probably need unusual help to
accommodate this route.
Anyway, `edit/2` just grabs the schema module and loads the given entry, using
the reflection we saw earlier to go ahead and preload all the associations.
Neat! We also create a blank changeset and pass these important things on to our
view. `new/2` is left as an exercise all for you! But mostly because I haven't
written it yet myself! I'm also not retrieving the changeset from the session
here. I also leave that up to you.
`update/2` works similarly, grabbing the schema module, creating a changeset
from the given data (note the big warning!), and then attempting to update the
repo. Here, we have some reasonable error handling and proper Phoenix CRUD'ing
-- or at least... U'ing, but that doesn't sound as fun.
This gets us really close to being able to update arbitrary schema entries! We
just need to setup the view to know how to handle the view logic (duh).
First, though, let's go to our template and figure out how we want it to work,
then we know what helper functions we'll need in our view.
Oh, by the way, I'm using [Slime][slime] instead of [EEx][eex] for my
templates. If you haven't heard of it, you should check it out. It makes for
very clean template code.
```elixir
# edit.html.slime
h1
= "Edit #{String.capitalize(to_string(@schema))}"
= " ID #{@id}"
- action_path = Routes.admin_path(@conn, :update, @schema, @id)
= form_for @changeset, action_path, [as: :data], fn f ->
h2 Fields
= for field <- @schema_module.__schema__(:fields) do
.field
label
.label-text #{String.capitalize(to_string(field))}
= field(f, @schema_module, field)
h2 Associations
= for association <- @schema_module.__schema__(:associations) do
.field
label
.label-text #{String.capitalize(to_string(association))}
= association(f, @schema_module, association)
```
Wow, this is cool! Obviously, most of the magic is going to be in the view
functions we now have to implement, but this *one* view will theoretically
handle any basic schema! This is great!
If you're locking down which fields an admin can modify, you can do something
like this:
```elixir
# edit.html.slime
# for field in fields...
.field
label
.label-text #{String.capitalize(to_string(field))}
= if Enum.member?(@editable_fields, field) do
= field(f, @schema_module, field)
- else
input readonly="true" value=Map.get(@changeset.data, field)
```
Let's get to the nitty gritty and implement `field/3` and `association/3`:
```elixir
def field(form, schema_module, field, opts \\ []) do
case schema_module.__schema__(:schema, field) do
:boolean -> Phoenix.HTML.Form.checkbox(form, field, opts)
:integer -> Phoenix.HTML.Form.number_input(form, field, opts)
# ... I haven't implemented any other types, yet, but datetimes and other
# such fields shouldn't be too difficult!
# see: https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#functions
_ -> Phoenix.HTML.Form.text_input(form, field, opts)
end
end
def association(form, schema_module, association, opts \\ []) do
association = schema_module.__schema__(:association, association)
# make sure your schemas implement the `String.Chars` protocol so they can
# look nice in a select!
# obviously, if you have massive tables, `Repo.all/1` is a bad idea!
options = MyApp.Repo.all(association.queryable)
|> Enum.map(&{&1.id, to_string(&1)})
case association.cardinality do
:many ->
opts = Keyword.put_new(opts, :multiple, true)
Phoenix.HTML.Form.select(form, association, options, opts)
_ ->
Phoenix.HTML.Form.select(form, association, options, opts)
end
end
```
Holy cow! Not bad at all! Actually, it would be really easy to extend this and
even handle the wacky edge cases that may come down the line! As previously
mentioned implementing some kind of `AdminEditable` protocol would be ideal,
then you could attach whatever metadata you wanted in order for your admin CRUD
system to work exactly how you want.
I'm doing that since I don't actually want an admin to be able to change certain
things that the system is solely responsible for. This includes primary keys,
slugs, user-provided information, etc. Therefore this looks a bit different in
practice on my end.
```elixir
# accounts/user.ex
defimpl MyApp.AdminEditable, for: MyApp.Accounts.User do
@readable [:id, :email, :full_name, :inserted_at, :updated_at]
@editable [:verified]
def admin_readable_fields(_s), do: @readable ++ @editable
def admin_editable_fields(_s), do: @editable
# this controls what shows up on the index for a given schema
def admin_index_fields(s), do: admin_readable_fields(s)
end
```
You could then use these methods instead of schema reflection. You could also
handle associations separately as well.
```elixir
# accounts/user.ex
# defimpl MyApp.AdminEditable ...
@readable_associations []
@editable_associations [:roles]
def admin_readable_associations(_s), do:
@readable_associations ++ @editable_associations
def admin_editable_fields(_s), do: @editable_associations
```
You could use this *and* define the implementation for `Any` *and* use the
aforementioned schema reflection and get the best of both worlds!
Anyways, I have a billion ideas on how to extend this basic concept. Hopefully
you can implement your own admin interface without writing a form for every
single schema you have, too! Ahh, simplicity.
Here's all the code jumbled together (and perhaps slightly different):
## All Together Now!
```elixir
# router.ex
get("/edit/:schema/:pk", AdminController, :edit)
post("/new/:schema/:pk", AdminController, :create)
put("/update/:schema/:pk", AdminController, :update)
# admin_controller.ex
@models %{
"user" => MyApp.Accounts.User,
"role" => MyApp.Accounts.Role
# ... and any other possible schema you have!
}
def edit(conn, %{"schema" => schema, "pk" => pk}) do
schema_module = @models[schema]
model = schema_module.__schema__(:associations)
|> Enum.reduce(MyApp.Repo.get(m, pk), fn a, m ->
MyApp.Repo.preload(m, a)
end)
# TODO: load changeset from session?
opts = [
changeset: Ecto.Changeset.change(model, %{}),
schema_module: schema_module
]
render(conn, "edit.html", opts)
end
# create/2 works similarly and may be considered an exercise for the reader =)
def update(conn, %{"schema" => schema, "pk" => pk, "data" => data}) do
schema_module = @models[schema]
# TODO: be wary! this lets an admin change EVERYTHING!
# if you want to avoid this like I did, setup an AdminEditable protocol that
# requires your schema to implement an admin-specific changeset and use that
# instead
changeset = Ecto.Changeset.change(MyApp.Repo.get!(module, pk), data)
case MyApp.Repo.update(changeset) do
{:ok, updated_model} ->
conn
|> put_flash(:info, "#{String.capitalize(schema)} ID #{pk} updated!")
|> redirect(to: Routes.admin_path(conn, :edit, schema, pk))
{:error, changeset} ->
conn
|> put_flash(:failed, "#{String.capitalize(schema)} ID #{pk} failed to update!")
|> put_session(:changeset, changeset)
|> redirect(to: Routes.admin_path(conn, :edit, schema, pk))
end
end
# admin_view.ex
def field(form, changeset, schema_module, field, opts \\ []) do
# this is pretty much the result of the magic - render a partial, look at
# additional metadata, etc!
case schema_module.__schema__(:schema, field) do
:boolean -> Phoenix.HTML.Form.checkbox(form, field, opts)
_ -> Phoenix.HTML.Form.text_input(form, field, opts)
end
end
def association(form, changeset, schema_module, association, opts \\ []) do
# similar magic for associations! huzzah!
association = schema_module.__schema__(:association, association)
# make sure your schemas implement the `String.Chars` protocol!
options = MyApp.Repo.all(association.queryable)
|> Enum.map(&{&1.id, to_string(&1)})
case association.cardinality do
:many ->
Phoenix.HTML.Form.select(form, association, options, Keyword.put_new(opts, :multiple, true))
_ ->
Phoenix.HTML.Form.select(form, association, options, opts)
end
end
# new.html.slime is similar and also an exercise for the reader =)
# edit.html.slime
= form_for @changeset, Routes.admin_path(@conn, :update, @schema, @id), [as: :data], fn f ->
h2 Fields
= for field <- @schema_module.__schema__(:fields) do
.field
label
.label-text #{String.capitalize(to_string(field))}
= field(f, @changeset, @schema_module, field)
h2 Associations
= for association <- @schema_module.__schema__(:associations) do
.field
label
.label-text #{String.capitalize(to_string(association))}
= association(f, @changeset, @schema_module, association)
```
[django]: https://www.djangoproject.com/
[phoenix]: https://phoenixframework.org/
[ex_admin]: https://github.com/smpallen99/ex_admin
[torch]:https://github.com/danielberkompas/torch
[__schema__]: https://hexdocs.pm/ecto/3.0.7/Ecto.Schema.html#module-reflection
[schema/2]: https://hexdocs.pm/ecto/3.0.7/Ecto.Schema.html#schema/2
[Ecto.Changeset.change/2]: https://hexdocs.pm/ecto/3.0.7/Ecto.Changeset.html#change/2
[elixir-slack]: https://elixir-slackin.herokuapp.com/
[select_attributes]:https://developer.mozilla.org/en-US/docs/Web/HTML/Element/select#Attributes
[many_to_many/3]: https://hexdocs.pm/ecto/3.0.7/Ecto.Schema.html#many_to_many/3
[slime]: https://github.com/slime-lang/slime
[eex]: https://hexdocs.pm/eex/EEx.html

파일 보기

@ -0,0 +1,260 @@
---
image: "/img/dried-lava.jpg"
date: "2019-03-20T20:24:54-05:00"
imageOverlayColor: "#000"
imageOverlayOpacity: 0.5
heroBackgroundColor: "#333"
description: "If you're using Elm, you're probably dealing with forms and
fields. Here's how I did!"
title: "Elm: Forms, Fields, and Abstractions"
draft: false
---
If you're using Elm, you're probably dealing with forms and fields. You're also
probably wanting to do functional programming "right" and compose smaller
functions together. Hopefully this helps!
<!--more-->
# TL;DR
Check it out in context on
<a href="https://ellie-app.com/53ypTnnFykXa1" target="_blank">Ellie</a>,
the Elm playground!
## How Did I Get Here?
To preface this, I'm going to be writing a *lot* of forms with a *lot* of
fields, all of which will pretty much work exactly the same, so creating a layer
of abstraction to boost my productivity down the line is going to be massively
important when it comes to changing things.
You may have even found or been told to use [this library][1]. If you're really
nuts, you may have even tried to use it before finding out that as soon as you
want to change the default renderings at all, you are [encouraged][2] to [copy
and paste this 700+ line file][3] and tweak accordingly, all the while being
told it's not really a big deal.
To be fair, technically this is appropriately "composable", as you are given the
minimum functioning piece so that you can bolt whatever you need onto it, but as
soon as a library or module isn't *easily* doing what I need it to do, instead
of copying code from it, I'm just going to roll my own, which is generally
a really good way to learn something anyways. So, while frustrated, I was also
eager to tackle the problem.
## Code Already!
Since the Elm compiler is so awesome, I've taken to what I'm calling
"compiler-driven development", which is where I write out what I expect to work
and then proceed to make it work.
As a simple test, I know I'm going to need fields that let a user input currency
and percentages. I know that the only real differences that need to be
abstracted away will be the type (currency, percentage, etc.), the getter they
can use to display their current value from my `Model`, and the `Msg`
constructor they will use to update that `Model` as the user fires `input`
events. There will also be tooltips, validation, and other such things, but for
the sake of simplicity, let's focus on these. So, my code will look something
like the following:
```elm
-- ... imports and module definitions ...
main =
Browser.element
{ init = init
, update = update
, subscriptions = \m -> Sub.none
, view = view
}
type alias Model =
{ amount : String
, ratePercent : String
}
init : () -> ( Model, Cmd msg )
init _ =
( { amount = "$1000.87" |> currency
, ratePercent = "3.427857%" |> percent
}
, Cmd.none
)
type Msg
= UpdateAmount String (Cmd Msg)
| UpdateRatePercent String (Cmd Msg)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
UpdateAmount s c -> ( { model | amount = s }, c )
UpdateRatePercent s c -> ( { model | ratePercent = s }, c )
view : Model -> Html Msg
view model =
Html.div []
[ form model
[ currencyField "Amount" .amount UpdateAmount
, percentField "Rate" .ratePercent UpdateRatePercent
]
```
I want this code to handle rendering my fields, formatting and un-formatting the
text input's value (more on this later), and firing update `Msg`s accordingly.
So we just need to implement the `form` function and the `currencyField` and
`percentField` functions.
Note that I'm storing my values as a string in this case, even though my intent
is for them to be numbers. I'm sure there is a cool Elm way to handle this using
the type system, but I haven't got that far. For the moment I'm just extracting
the value from them whenever the calculations need to be done (not shown here).
If and when that changes, perhaps I can make that another blog post explaining
that process as well!
Let's start with form, since it should be the simplest.
```elm
form : model -> List (model -> Html msg) -> Html msg
form model fields =
Html.form []
(List.map (\f -> f model) fields)
```
All the `form` function does is create an `Html.form` whose children are just
the result of mapping over the list of fields, calling "view-like" functions,
passing the model. Easy. Now your form function may need stuff like submit
buttons or other actions, but mine does not. Again, do what is necessary for
*your* application!
Now, with some forward thinking, I know both my `currencyField` and
`percentField` are going to do similar things with minor differences. Let's talk
about those for a moment.
My inputs need to show human-friendly values while idle, but once they're
focused, they should show a more "raw" value so that editing the numerical
values is easy and the user doesn't need to manage commas, units, or anything
else; they just enter a number and the app will show them a nicely-formatted
version of it. Most high-tech spreadsheets work similarly. I'll use `onFocus`
and `onBlur` events to accomplish this.
So let's do some more "compiler-driven development" (this will get a little
nuts):
```elm
currencyField : String -> (model -> String) -> (String -> Cmd msg -> msg) -> model -> Html msg
currencyField label getter updateMsg model =
formattedNumberField label (\m -> m |> getter |> currency) getter updateMsg model
percentField : String -> (model -> String) -> (String -> Cmd msg -> msg) -> model -> Html msg
percentField label getter updateMsg model =
formattedNumberField label (\m -> m |> getter |> percent) getter updateMsg model
```
Whew! I know these type signatures can look intimidating if you're new to Elm,
but here's the breakdown of the arguments:
1. The field's label (as we've seen in the call)
2. A getter, which, given a model, can provide the text that the input should
display (as we've seen in the call - [here's an explanation][4])
3. A `Msg` constructor that takes a `String` (which is passed from `onInput`)
and a `Cmd msg`, since in some cases we might wanna do that, but I won't get
into that here (as we've seen in the call)
4. The model
Okay! That all makes sense. Now what's up with `formattedNumberField`? That's
the next layer of the abstraction onion I'm building up. It's for any number
field that needs to unformat with `onFocus` and reformat with `onBlur`. Also,
we're going to ignore `currency` and `percent` (thought the full code will have
it) as that brings in a whole lot of code that's mostly unrelated to the core
concept I'm trying to convey here. Basically, they take a string like "4.8" and
convert that to "$4.80" or "4.80%".
Now we might also have a number field that doesn't necessarily care about
handling `onFocus` or `onBlur`, so we'll have another abstraction layer there
called `numberField`. Let's see the code:
```elm
type alias FieldConfig t model msg =
{ label : String
, value : model -> t
, onInput : t -> msg
, onFocus : model -> msg
, onBlur : model -> msg
, class : model -> String
}
numberField : FieldConfig String model msg -> model -> Html msg
numberField config model =
Html.div [ Html.Attributes.class "field" ]
[ Html.label []
[ Html.text config.label
]
, Html.input
[ Html.Attributes.value (config.value model)
, Html.Attributes.class (config.class model)
, Html.Attributes.pattern "[0-9\\.]"
, Html.Attributes.id
(config.label
|> String.replace " " "_"
)
, Html.Events.onInput config.onInput
, Html.Events.onFocus (config.onFocus model)
, Html.Events.onBlur (config.onBlur model)
]
[]
]
formattedNumberField : String -> (model -> String) -> (model -> String) -> (String -> Cmd msg -> msg) -> model -> Html msg
formattedNumberField label formatter getter updateMsg model =
numberField
-- FieldConfig
{ label = label
, value = getter
, onInput = \s -> updateMsg s Cmd.none
, onFocus = \m -> updateMsg (m |> getter |> unFormat) (selectElementId label)
, onBlur = \m -> updateMsg (formatter m) Cmd.none
, class = \m -> m |> getter |> unFormat |> String.toFloat |> numberFieldClass
}
model
```
Alrighty! Our biggest chunk. The first block is just a type that tells the
compiler what kind of things we're going to need to build a field. Most of them
are functions that take a model and result in either the type of the field,
a msg to update our model with, or some attribute used within the field for
UX-related things, such as validation.
The second chunk, `numberField` is perhaps the most familiar to newbies and
straightforward. It takes a `FieldConfig`, which we just talked about, and
a model and turns that into HTML that can trigger the messages as specified in
the config. At a glance, it looks pretty magical, but hopefully the next chunk
makes it clear.
`formattedNumberField` specifies the `FieldConfig` which houses all the magic.
It's pretty much a bunch of lambda functions that know how to take a model and
create a `Msg`, which runs through our `update` function, which is that really
important piece of [The Elm Architecture][5].
There are a lot of functions in there like `unFormat`, `numberFieldClass`, and
`selectElementId` that you can check out in the full example, but (again) aren't
really relevant to the core of what I'm talking about.
Hopefully this all makes sense. If it doesn't, check out the full example after
the jump for a working example that you can play with. If this is "Elm 101" to
you, awesome! It took me some serious mind-bending and frustration with Elm's
incredible type checking system not implicitly understanding what I'm trying to
do (computers, amirite?) before I made it to this point.
Now, I'm confident Elm is a tool that can do everything I need it to do. I look
forward to using it more in the future!
## Full Source
Check it out in context on
<a href="https://ellie-app.com/53ypTnnFykXa1" target="_blank">Ellie</a>,
the Elm playground!
[1]: https://github.com/hecrj/composable-form
[2]: https://github.com/hecrj/composable-form/blob/dd846d84de9df181a9c6e8803ba408e628ff9a93/src/Form/Base.elm#L26
[3]: https://github.com/hecrj/composable-form/blob/dd846d84de9df181a9c6e8803ba408e628ff9a93/src/Form.elm
[4]: https://elm-lang.org/docs/records#access

파일 보기

@ -0,0 +1,145 @@
---
image: "/img/code-mirror.jpg"
date: "2019-03-13T08:37:34-05:00"
imageOverlayColor: "#000"
imageOverlayOpacity: 0.7
heroBackgroundColor: "#333"
description: For those of us who want to self-host and display their work in a "socially expectable" manner.
title: Mirroring Gitea to Other Repository Management Services
draft: false
---
I have a [Gitea][gitea] instance I self-host at home. I keep most of my
repositories there, but I recognize that most other developers and potential
employers will want to see [my work *on* GitHub][me-on-github].
<!--more-->
# TL;DR
+ Setup an SSH key for your Gitea instance on the relevant external repositories
+ Leverage `post-receive` git hooks in Gitea to push changes to the relevant
external repositories while identifying with your new SSH key
Also, most of the magic is [here](#post-receive-script).
# Mirroring
In order to achieve this with minimal effort, I was going to need to utilize
mirroring. Mirroring is a popular git concept that allow a repository to
effectively exist in multiple places at once, though generally mirrors are
read-only.
Mirroring isn't exactly a specific procedure or command, it's simply the act of
reflecting all (or some specific subset) of commits that exist elsewhere,
generally in an automated fashion. Forks might be considered "mirrors" of
a repository at a given point in time.
GitLab, for example, [supports mirroring pretty seamlessly][0]. Gitea, however,
is pretty minimal, which is one of its perks to me. That does not, however, mean
that it is lacking in features.
Gitea supports a few [git hooks][1], which are a simple way to run a script when
something happens. As far as a repository manager is concerned, the only real
hooks that matter are the following (which Gitea supports):
+ `pre-receive`: Runs when a client pushes code to the repository. You can use
this to prevent, for example, code that fails linters, doesn't pass tests,
or even that can't be merged using a specific merge strategy.
+ `update`: Runs for each branch being updated when a client pushes code to the
repository. This is similar to pre-receive, but allows for more fine-grained
control. Maybe you want to only make the previous restrictions on your
`master` branch. This would be the way to do it.
+ `post-receive`: Runs after your `pre-receive` and `update` hooks have finished
when a client pushes code to the repository. This is what we'll be
leveraging to push code downstream!
With that lengthy introduction, let's dive in!
# Setup
Alrighty, this has a few simple steps, so let's outline what we need to do
first:
1. Setup SSH keys for Gitea and your other repository management services
1. Generate fresh keys (`ssh-keygen -f gitea` will generate a private key in
the `gitea` file and a public key in the `gitea.pub` file)
2. Add the public key (`gitea.pub`) to your "mirrors-to-be" repositories *with
write access*
+ **Note**: I recommend at the very least to create one Gitea key and add it
to the individual repositories, though individual keys for each repository
is tighter security in case your Gitea instance becomes compromised
+ **Note**: Your "mirrors-to-be" repositories must be blank or have related
histories!
2. Setup the `post-receive` hook on your Gitea repository to push using the
newly generated private key to the mirror(s)
I'm not going to explain much on how to add Deploy Keys for the various
repository management systems out there, so here's a link [explaining the process
for GitHub][2].
# Hookin' Around
Now we're all set for the magic! Also, for reference and sanity, I'm running
Gitea in Docker on an Arch Linux server with the following version (but this
should work pretty much regardless):
+ Gitea Version: `3b612ce built with go1.11.5 : bindata, sqlite,
sqlite_unlock_notify`
+ Git Version: `2.18.1`
Let's go ahead and open up our Gitea repository's index page.
![My dotfiles repository index](/img/scrots/gitea-mirroring/repo-index.png)
And head to the repository's "Settings" tab... (oh yes, you'll need to have the proper permissions on
the repository itself!)
![Click the "Settings" tab in the
top-right](/img/scrots/gitea-mirroring/repo-index-hl-settings.png)
And now to the "Git Hooks" tab...
![Click the "Git Hooks"
tab](/img/scrots/gitea-mirroring/repo-settings-hl-git-hooks.png)
Let's edit the "Post Receive" hook...
![Edit the "Post Receieve"
hook](/img/scrots/gitea-mirroring/repo-hooks-hl-post-receive-edit.png)
And you will be presented with a form where you can put any kind of script you
want! Remember the SSH keys you generated so long ago? We're going to need the
contents of the private key now. Here are the script contents you're going to
use, replacing the variables as necessary.
## Post-Receive Script
```bash
#!/usr/bin/env bash
downstream_repo="git@github.com:lytedev/dotfiles.git"
# if tmp worries you, put it somewhere else!
pkfile="/tmp/gitea_dotfiles_to_github_dotfiles_id_rsa"
if [ ! -e "$pkfile" ]; then # unindented block for heredoc's sake
cat > "$pkfile" << PRIVATEKEY
# ==> REMOVE THIS ENTIRE LINE & PASTE YOUR PRIVATE KEY HERE <==
PRIVATEKEY
fi
chmod 400 "$pkfile"
export GIT_SSH_COMMAND="ssh -oStrictHostKeyChecking=no -i \"$pkfile\""
# if you want strict host key checking, just add the host to the known_hosts for
# your Gitea server/user beforehand
git push --mirror "$downstream_repo"
```
Click "Update Hook" and you're all set! Now just push to the repo and watch it
magically become mirrored to the downstream repository!
[me-on-github]: https://github.com/lytedev
[gitea]: https://gitea.io/en-us/
[0]: https://docs.gitlab.com/ee/workflow/repository_mirroring.html
[1]: https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks
[2]: https://developer.github.com/v3/guides/managing-deploy-keys/#deploy-keys

파일 보기

@ -0,0 +1,148 @@
---
image: "/img/locks.jpg"
date: "2019-03-07T10:17:30-06:00"
imageOverlayColor: "#000"
imageOverlayOpacity: 0.8
heroBackgroundColor: "#000"
description: "Use an amazing terminal client with an amazing Chat framework!"
title: Weechat & Matrix Encryption Guide
draft: false
---
There's a new-fangled [Python WeeChat plugin][weechat-matrix] that supports
end-to-end encryption. This guide will walk you through what is currently
a semi-annoying setup, as the entire project is still under heavy development.
<!--more-->
# TL;DR
+ Setup [dependencies](#dependencies)
+ Run `git clone
https://git.faceless.lyte.dev/lytedev/weechat-matrix-encryption-guide.git
/tmp/wmeg && $EDITOR /tmp/wmeg/easy-script.bash && /tmp/wmeg/easy-script.bash`
+ [Configure](#configuration) as needed
## Python Versions
We need to establish which version of Python your WeeChat is using. You can find
this out in WeeChat with `/python version`. In my case, my `python` binary is
3.7.2 (`python -V`) while my WeeChat Python version is 2.7.15.
## Dependencies
There are a number of dependencies we can go ahead and start grabbing. The main
repository lists a number of them in the `README`, so we will grab those. We
also need to install `libolm` however you would do that for your environment.
```
sudo pip2 install pyOpenSSL typing webcolors future atomicwrites attrs logbook pygments
pacaur -S libolm # or for Ubuntu (and maybe Debian?): sudo apt-get install libolm-dev
```
Notice that we left out the [`matrix-nio`][matrix-nio] dependency. It's not in
PyPi, so we can't just `pip2 install matrix-nio` (yet!) and PyPi's `nio` package
is something probably unrelated, so we'll need to install it manually.
## Installing `matrix-nio`
Let's go ahead and clone down the repository and get ready to do some stuff:
```bash
git clone https://github.com/poljar/matrix-nio.git
cd matrix-nio
```
```go-html-template
<section id="main">
<div>
<h1 id="title">{{ .Title }}</h1>
{{ range .Pages }}
{{ .Render "summary"}}
{{ end }}
</div>
</section>
```
If you're looking around, documentation seems a bit sparse on how to do this,
but it has a mostly normal manual Python package installation workflow.
First, lets grab all the dependencies specific to the `matrix-nio` package:
```
sudo pip2 install -r ./rtd-requirements.txt
```
And now we expect to be able to install it:
```
sudo python2 ./setup.py install
```
But you'll see the install script pauses for a second before we get an odd
error:
```
Processing dependencies for matrix-nio==0.1
Searching for python-olm@ git+https://github.com/poljar/python-olm.git@master#egg=python-olm-0
Reading https://pypi.org/simple/python-olm/
Couldn't find index page for 'python-olm' (maybe misspelled?)
Scanning index of all packages (this may take a while)
Reading https://pypi.org/simple/
No local packages or working download links found for python-olm@ git+https://github.com/poljar/python-olm.git@master#egg=python-olm-0
error: Could not find suitable distribution for Requirement.parse('python-olm@ git+https://github.com/poljar/python-olm.git@master#egg=python-olm-0')
```
Out of the box, Python packages' `setup.py` scripts seem to not know how to
handle packages whose URL specifies to grab it via VCS, such as `git+`. So we'll
just help it out and grab it ourselves (instead of tinkering with anybody's
scripts):
```
sudo pip2 install -e git+https://github.com/poljar/python-olm.git@master#egg=python-olm-0
```
*Now* we should have everything we need to install the `matrix-nio` Python
package:
```
sudo python2 ./setup.py install
```
## Weechat Plugin Installation
Once we've done that, we should have all the dependencies for `weechat-matrix`,
so let's go ahead and clone that and install it!
```
git clone https://github.com/poljar/weechat-matrix.git
cd weechat-matrix
make install
```
Done!
## Configuration
The rest is up to you! You'll need to [configure your Matrix servers within
WeeChat][weechat-matrix-config] and then verify keys. Verifying keys isn't
a particularly clean process at the moment, but I expect it shall improve. For
now, I followed this basic process in WeeChat:
+ Open a split at your status window so you can see it and the encrypted channel
at the same time. (`/window splitv`)
+ Open the encrypted channel whose keys you need to verify.
+ List the unverified keys in the current channel. (`/olm info unverified`)
+ For each user with keys listed there, verify all of their listed keys via your
preferred method. Alternatively, you can do this on a per-device basis. See
`/help olm` for details.
+ Once all keys are verified, tell WeeChat you have done so. (`/olm verify
@username:homeserver.example.com`)
+ Repeat until there are no unverified keys remaining in the current channel and
repeat for each channel. Whew!
[weechat-matrix]: https://github.com/poljar/weechat-matrix
[weechat-matrix-config]: https://github.com/poljar/weechat-matrix#Configuration
[matrix-nio]: https://github.com/poljar/matrix-nio

34
content/contact.md Normal file
파일 보기

@ -0,0 +1,34 @@
---
date: 2017-02-22T14:43:02-06:00
image: /img/pen-journal.jpg
imageOverlayColor: "#000"
imageOverlayOpacity: 0.7
heroBackgroundColor: "#333"
title: Contact
description: "Need to get in touch?"
---
Email me at <a href="mailto:daniel@lyte.dev">daniel@lyte.dev</a> or use
the form below.
<p>
<form action="https://formspree.io/daniel@lyte.dev" method="POST">
<fieldset>
<label name="name">Full Name</label>
<input type="text" name="name" placeholder="Daniel Flanagan" />
</fieldset>
<fieldset>
<label name="_replyto">Email</label>
<input type="email" name="_replyto" placeholder="you@example.com" />
</fieldset>
<fieldset>
<label name="content">Message</label>
<textarea name="content" rows="4"></textarea>
</fieldset>
<fieldset>
<input class="button primary" type="submit" value="Send" />
</fieldset>
<input type="hidden" name="_next" value="/thanks" />
<input type="hidden" name="_subject" value="Contact Form Submission - lytedev" />
</form>
</p>

10
content/privacy.md Normal file
파일 보기

@ -0,0 +1,10 @@
---
title: Privacy Policy
---
I collect basic analytics to a system I run and control for insight into how
people use my website. That's it! No Google or Facebook here.
Thanks for reading. Thanks for being concerned about your privacy.
Head back to the <a href="/">home page</a>.

16
content/thanks.md Normal file
파일 보기

@ -0,0 +1,16 @@
---
date: 2017-02-22T14:43:02-06:00
image: https://images.unsplash.com/photo-1473186505569-9c61870c11f9?dpr=1&auto=format&fit=crop&w=1500&h=1000&q=80&cs=tinysrgb&crop=
imageOverlayColor: "#000"
imageOverlayOpacity: 0.7
heroBackgroundColor: "#333"
title: Thank You!
description: "I appreciate your participation!"
---
## Thank you so much for reaching out!
<p class="text-center">I usually reply within a day.</p>
<p class="text-center"><a href="/">Back to Home</a></p>
<p class="text-center"><a href="/blog">Or check out the blog!</a></p>

1
layouts/blog/li.html Normal file
파일 보기

@ -0,0 +1 @@
<li><a href="{{ .Permalink }}">{{ .Title }}</a></li>

35
layouts/index.html Normal file
파일 보기

@ -0,0 +1,35 @@
{{ define "main" }}
<p>
<h2>
Hi! I'm Daniel.
</h2>
</p>
<img class="rounded" style="width: 256px" src="/img/avatar.jpg" />
<p>
I live in Kansas City where I help run a small Christian church, raise a
family, and write software for Postmates.
</p>
<p>
Occasionally, I write technical blog posts. You can also check out my work
on <a target="_blank" href="https://github.com/lytedev">GitHub</a>.
</p>
<p>
<h3>
Latest Posts
</h3>
</p>
<p>
<ul>
{{ range (where .Site.RegularPages "Section" "blog") }}
{{ .Render "li" }}
{{ else }}
<p>Looks like there's nothing here!... yet!</p>
{{ end }}
</ul>
</p>
{{ end }}

BIN
static/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  크기: 19 KiB

BIN
static/img/avatar.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  크기: 56 KiB

파일 보기

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html>
<head>
{{ block "head-begin" . }}{{ end }}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ block "title" . }}
{{ .Site.Title }}
{{ end }}</title>
<link rel="shortcut icon" href="/icon.png" />
<link rel="stylesheet" href="/styles.css" />
{{ block "head-end" . }}{{ end }}
</head>
<body>
<header>
<section>
<a href="/"><img src="/icon.png" /></a>
<a href="/"><h1>lyte.dev</h1></a>
</section>
<section>
{{ $currentPage := . }}
{{ range .Site.Menus.main }}
{{ $active := or ($currentPage.IsMenuCurrent "main" .) ($currentPage.HasMenuCurrent "main" .) }}
{{ $active = or $active (eq .Name $currentPage.Title) }}
{{ $active = or $active (and (eq .Name "Blog") (eq $currentPage.Section "post")) }}
{{ $active = or $active (and (eq .Name "Tags") (eq $currentPage.Section "tags")) }}
<a href="{{ .URL }}">{{ .Name }}</a>
{{ end }}
</section>
</header>
{{ block "body-begin" . }}{{ end }}
<main>
{{ block "main" . }}
{{ .Content }}
{{ end }}
</main>
{{ block "body-end" . }}{{ end }}
</body>
</html>

파일 보기

@ -0,0 +1,12 @@
{{ define "main" }}
{{ .Content }}
{{ range .Pages }}
{{ .Render "li" }}
{{ else }}
<p class="text-center">Looks like there's nothing here!... yet!</p>
{{ end }}
{{ end }}
{{ define "title" }}
{{ .Title }} - {{ .Site.Title }}
{{ end }}

파일 보기

@ -0,0 +1,13 @@
{{ define "title" }}
{{ .Title }} - {{ .Site.Title }}
{{ end }}
{{ define "main" }}
<p>
<h2>
{{ .Title }}
</h2>
</p>
{{ .Content }}
{{ end }}

파일 보기

@ -0,0 +1,130 @@
The font is licensed under SIL OFL Version 1.1.
The support code is licensed under Berkeley Software Distribution license.
---
---
Copyright (c) 2015-2020 Belleve Invis (belleve@typeof.net).
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
* Neither the name of Belleve Invis nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL BELLEVE INVIS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-----------------------
---
Copyright 2015-2020, Belleve Invis (belleve@typeof.net).
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
--------------------------
SIL Open Font License v1.1
====================================================
Preamble
----------
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
Definitions
-------------
`"Font Software"` refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
`"Reserved Font Name"` refers to any names specified as such after the
copyright statement(s).
`"Original Version"` refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
`"Modified Version"` refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
`"Author"` refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
Permission & Conditions
------------------------
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1. Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2. Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3. No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5. The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
Termination
-----------
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Binary file not shown.

Binary file not shown.

파일 보기

파일 보기

@ -0,0 +1,197 @@
@font-face {
font-family: system-ui;
font-style: normal;
font-weight: 300;
src: local(".SFNS-Light"), local(".SFNSText-Light"), local(".HelveticaNeueDeskInterface-Light"), local(".LucidaGrandeUI"), local("Segoe UI Light"), local("Ubuntu Light"), local("Roboto-Light"), local("DroidSans"), local("Tahoma");
}
@font-face {
font-family: system-ui;
font-style: italic;
font-weight: 300;
src: local(".SFNS-LightItalic"), local(".SFNSText-LightItalic"), local(".HelveticaNeueDeskInterface-Italic"), local(".LucidaGrandeUI"), local("Segoe UI Light Italic"), local("Ubuntu Light Italic"), local("Roboto-LightItalic"), local("DroidSans"), local("Tahoma");
}
@font-face {
font-family: system-ui;
font-style: normal;
font-weight: 400;
src: local(".SFNS-Regular"), local(".SFNSText-Regular"), local(".HelveticaNeueDeskInterface-Regular"), local(".LucidaGrandeUI"), local("Segoe UI"), local("Ubuntu"), local("Roboto-Regular"), local("DroidSans"), local("Tahoma");
}
@font-face {
font-family: system-ui;
font-style: italic;
font-weight: 400;
src: local(".SFNS-Italic"), local(".SFNSText-Italic"), local(".HelveticaNeueDeskInterface-Italic"), local(".LucidaGrandeUI"), local("Segoe UI Italic"), local("Ubuntu Italic"), local("Roboto-Italic"), local("DroidSans"), local("Tahoma");
}
@font-face {
font-family: system-ui;
font-style: normal;
font-weight: 500;
src: local(".SFNS-Medium"), local(".SFNSText-Medium"), local(".HelveticaNeueDeskInterface-MediumP4"), local(".LucidaGrandeUI"), local("Segoe UI Semibold"), local("Ubuntu Medium"), local("Roboto-Medium"), local("DroidSans-Bold"), local("Tahoma Bold");
}
@font-face {
font-family: system-ui;
font-style: italic;
font-weight: 500;
src: local(".SFNS-MediumItalic"), local(".SFNSText-MediumItalic"), local(".HelveticaNeueDeskInterface-MediumItalicP4"), local(".LucidaGrandeUI"), local("Segoe UI Semibold Italic"), local("Ubuntu Medium Italic"), local("Roboto-MediumItalic"), local("DroidSans-Bold"), local("Tahoma Bold");
}
@font-face {
font-family: system-ui;
font-style: normal;
font-weight: 700;
src: local(".SFNS-Bold"), local(".SFNSText-Bold"), local(".HelveticaNeueDeskInterface-Bold"), local(".LucidaGrandeUI"), local("Segoe UI Bold"), local("Ubuntu Bold"), local("Roboto-Bold"), local("DroidSans-Bold"), local("Tahoma Bold");
}
@font-face {
font-family: system-ui;
font-style: italic;
font-weight: 700;
src: local(".SFNS-BoldItalic"), local(".SFNSText-BoldItalic"), local(".HelveticaNeueDeskInterface-BoldItalic"), local(".LucidaGrandeUI"), local("Segoe UI Bold Italic"), local("Ubuntu Bold Italic"), local("Roboto-BoldItalic"), local("DroidSans-Bold"), local("Tahoma Bold");
}
@font-face {
font-family: iosevka;
font-style: normal;
font-weight: 300;
src: url("/font/iosevka/ss07-regular.woff2");
}
@font-face {
font-family: iosevka;
font-style: italic;
font-weight: 300;
src: url("/font/iosevka/ss07-italic.woff2");
}
@font-face {
font-family: iosevka;
font-style: italic;
font-weight: 500;
src: url("/font/iosevka/ss07-bold-italic.woff2");
}
*,*::before,*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body > main {
padding: 0.5em;
}
body > main > form,
body > main > p {
max-width: 600px;
}
body > main > p ~ p {
margin-top: 1em;
}
body {
background-color: #111;
color: #fff;
font-family: iosevka, system-ui;
font-weight: 300;
font-size: 1rem;
}
body > header {
position: relative;
display: flex;
background-color: #191919;
box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.25);
margin-bottom: 0.5em;
}
body > header > section {
display: flex;
}
body > header > section > a {
line-height: 1.5em;
display: flex;
justify-content: center;
align-items: center;
padding: 0.25em 0.5em;
color: #fff;
text-decoration: none;
}
body > header > section > a:visited {
color: #fff;
}
body > header > section > a:hover {
background: rgba(255, 255, 255, 0.1);
}
body > header > section > a > img,
body > header > section > a > img {
max-width: 48px;
}
body > header > section > a > h1 {
font-weight: 300;
font-size: 1.5rem;
}
input, textarea {
background: #222;
border: 0;
font: inherit;
padding: 0.25em 0.5em;
color: inherit;
border-radius: 0.25em;
}
button, input[type=submit] {
background: #a1efe4;
color: #000;
}
ul, ol {
padding-left: 1.5em;
}
a {
color: #a1efe4;
}
a:visited {
color: #66d9ef;
}
.lead {
font-size: 1.5rem;
}
.rounded {
border-radius: 0.5em;
}
form > fieldset {
border: 0;
display: flex;
flex-direction: column;
}
form > fieldset ~ fieldset {
margin-top: 0.5em;
}
img, embed, frame, iframe {
max-width: 100vw;
}
@media (max-width: 600px) {
body > header {
flex-direction: column;
}
}