commit 458f9e65ef6ca251e1c2edf53012aaec87f8ad16 Author: Daniel Flanagan Date: Tue Jul 14 16:47:54 2020 -0500 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb76b31 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +resources/ diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..a7874cc --- /dev/null +++ b/config.yaml @@ -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 diff --git a/content/404.md b/content/404.md new file mode 100644 index 0000000..8e7b913 --- /dev/null +++ b/content/404.md @@ -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]: / diff --git a/content/about.md b/content/about.md new file mode 100644 index 0000000..0ef71d1 --- /dev/null +++ b/content/about.md @@ -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 small church 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 diff --git a/content/blog/_index.md b/content/blog/_index.md new file mode 100644 index 0000000..96c97d4 --- /dev/null +++ b/content/blog/_index.md @@ -0,0 +1,5 @@ +--- +title: Blog +--- + +### Latest Posts diff --git a/content/blog/ecto-reflection-for-simple-admin-crud-forms.md b/content/blog/ecto-reflection-for-simple-admin-crud-forms.md new file mode 100644 index 0000000..a4c988d --- /dev/null +++ b/content/blog/ecto-reflection-for-simple-admin-crud-forms.md @@ -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! + + + +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 diff --git a/content/blog/elm-form-fields-abstractions.md b/content/blog/elm-form-fields-abstractions.md new file mode 100644 index 0000000..c499913 --- /dev/null +++ b/content/blog/elm-form-fields-abstractions.md @@ -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! + + + +# TL;DR + +Check it out in context on +Ellie, +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 +Ellie, +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 diff --git a/content/blog/mirroring-gitea-to-other-repository-management-services.md b/content/blog/mirroring-gitea-to-other-repository-management-services.md new file mode 100644 index 0000000..e217d60 --- /dev/null +++ b/content/blog/mirroring-gitea-to-other-repository-management-services.md @@ -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]. + + + +# 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 diff --git a/content/blog/weechat-matrix-encryption-guide.md b/content/blog/weechat-matrix-encryption-guide.md new file mode 100644 index 0000000..31dbb7f --- /dev/null +++ b/content/blog/weechat-matrix-encryption-guide.md @@ -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. + + + +# 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 +
+
+

{{ .Title }}

+ {{ range .Pages }} + {{ .Render "summary"}} + {{ end }} +
+
+``` + +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 + diff --git a/content/contact.md b/content/contact.md new file mode 100644 index 0000000..40ae3ea --- /dev/null +++ b/content/contact.md @@ -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 daniel@lyte.dev or use +the form below. + +

+

+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+

diff --git a/content/privacy.md b/content/privacy.md new file mode 100644 index 0000000..9bd1b09 --- /dev/null +++ b/content/privacy.md @@ -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 home page. diff --git a/content/thanks.md b/content/thanks.md new file mode 100644 index 0000000..b7366ab --- /dev/null +++ b/content/thanks.md @@ -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! + +

I usually reply within a day.

+ +

Back to Home

+

Or check out the blog!

diff --git a/layouts/blog/li.html b/layouts/blog/li.html new file mode 100644 index 0000000..b3b8a64 --- /dev/null +++ b/layouts/blog/li.html @@ -0,0 +1 @@ +
  • {{ .Title }}
  • diff --git a/layouts/index.html b/layouts/index.html new file mode 100644 index 0000000..c4067c4 --- /dev/null +++ b/layouts/index.html @@ -0,0 +1,35 @@ +{{ define "main" }} +

    +

    + Hi! I'm Daniel. +

    +

    + + + +

    + I live in Kansas City where I help run a small Christian church, raise a + family, and write software for Postmates. +

    + +

    + Occasionally, I write technical blog posts. You can also check out my work + on GitHub. +

    + +

    +

    + Latest Posts +

    +

    + +

    +

    +

    +{{ end }} diff --git a/static/icon.png b/static/icon.png new file mode 100644 index 0000000..ddf077c Binary files /dev/null and b/static/icon.png differ diff --git a/static/img/avatar.jpg b/static/img/avatar.jpg new file mode 100644 index 0000000..abf572d Binary files /dev/null and b/static/img/avatar.jpg differ diff --git a/themes/lyte/layouts/_default/baseof.html b/themes/lyte/layouts/_default/baseof.html new file mode 100644 index 0000000..1691e01 --- /dev/null +++ b/themes/lyte/layouts/_default/baseof.html @@ -0,0 +1,39 @@ + + + + {{ block "head-begin" . }}{{ end }} + + + {{ block "title" . }} + {{ .Site.Title }} + {{ end }} + + + {{ block "head-end" . }}{{ end }} + + +
    +
    + +

    lyte.dev

    +
    +
    + {{ $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")) }} + {{ .Name }} + {{ end }} +
    +
    + {{ block "body-begin" . }}{{ end }} +
    + {{ block "main" . }} + {{ .Content }} + {{ end }} +
    + {{ block "body-end" . }}{{ end }} + + diff --git a/themes/lyte/layouts/_default/list.html b/themes/lyte/layouts/_default/list.html new file mode 100644 index 0000000..a6812c9 --- /dev/null +++ b/themes/lyte/layouts/_default/list.html @@ -0,0 +1,12 @@ +{{ define "main" }} + {{ .Content }} + {{ range .Pages }} + {{ .Render "li" }} + {{ else }} +

    Looks like there's nothing here!... yet!

    + {{ end }} +{{ end }} + +{{ define "title" }} + {{ .Title }} - {{ .Site.Title }} +{{ end }} diff --git a/themes/lyte/layouts/_default/single.html b/themes/lyte/layouts/_default/single.html new file mode 100644 index 0000000..94de817 --- /dev/null +++ b/themes/lyte/layouts/_default/single.html @@ -0,0 +1,13 @@ +{{ define "title" }} + {{ .Title }} - {{ .Site.Title }} +{{ end }} + +{{ define "main" }} +

    +

    + {{ .Title }} +

    +

    + + {{ .Content }} +{{ end }} diff --git a/themes/lyte/static/font/iosevka/LICENSE.md b/themes/lyte/static/font/iosevka/LICENSE.md new file mode 100644 index 0000000..4c2bfbc --- /dev/null +++ b/themes/lyte/static/font/iosevka/LICENSE.md @@ -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. diff --git a/themes/lyte/static/font/iosevka/ss07-bold-italic.woff2 b/themes/lyte/static/font/iosevka/ss07-bold-italic.woff2 new file mode 100644 index 0000000..785f566 Binary files /dev/null and b/themes/lyte/static/font/iosevka/ss07-bold-italic.woff2 differ diff --git a/themes/lyte/static/font/iosevka/ss07-bold.woff2 b/themes/lyte/static/font/iosevka/ss07-bold.woff2 new file mode 100644 index 0000000..0f407dc Binary files /dev/null and b/themes/lyte/static/font/iosevka/ss07-bold.woff2 differ diff --git a/themes/lyte/static/font/iosevka/ss07-italic.woff2 b/themes/lyte/static/font/iosevka/ss07-italic.woff2 new file mode 100644 index 0000000..d433538 Binary files /dev/null and b/themes/lyte/static/font/iosevka/ss07-italic.woff2 differ diff --git a/themes/lyte/static/font/iosevka/ss07-regular.woff2 b/themes/lyte/static/font/iosevka/ss07-regular.woff2 new file mode 100644 index 0000000..2f50e70 Binary files /dev/null and b/themes/lyte/static/font/iosevka/ss07-regular.woff2 differ diff --git a/themes/lyte/static/global.js b/themes/lyte/static/global.js new file mode 100644 index 0000000..e69de29 diff --git a/themes/lyte/static/styles.css b/themes/lyte/static/styles.css new file mode 100644 index 0000000..99a17e3 --- /dev/null +++ b/themes/lyte/static/styles.css @@ -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; + } +}