<%= gettext("Hire a great Elixir/Erlang developer") %>
4 |
<%= raw gettext("Elixir/Erlang community is in a very vibrant moment. Start looking for your next hiring in the right place.") %>
5 |
6 |
<%= gettext("How does this work?") %>
7 |
8 |
<%= raw gettext("Once you submit your offer, we will review it (just to avoid SPAM or dead links) and, if everything is correct, we will publish it as soon as possible!") %>
9 |
10 |
<%= raw gettext("Publishing an offer on Elixir Jobs is free and will be also posted to our Twitter account, so feel free of %{follow_link} and retweet your offer when published!",
11 | follow_link: "#{gettext("follow us")}") %>
12 |
13 |
14 |
15 | <%= render "_form.html",
16 | changeset: @changeset,
17 | action: offer_path(@conn, :create),
18 | conn: @conn,
19 | css_class: "offer-new" %>
20 |
--------------------------------------------------------------------------------
/lib/elixir_jobs_web/helpers/view_helper.ex:
--------------------------------------------------------------------------------
1 | defmodule ElixirJobsWeb.ViewHelper do
2 | @moduledoc """
3 | Module with helpers commonly used in other views.
4 | """
5 |
6 | alias ElixirJobsWeb.DateHelper
7 |
8 | def class_with_error(form, field, base_class) do
9 | if error_on_field?(form, field) do
10 | "#{base_class} error"
11 | else
12 | base_class
13 | end
14 | end
15 |
16 | def error_on_field?(form, field) do
17 | form.errors
18 | |> Enum.map(fn {attr, _message} -> attr end)
19 | |> Enum.member?(field)
20 | end
21 |
22 | def do_strip_tags(text) do
23 | text
24 | |> HtmlSanitizeEx.strip_tags()
25 | |> Phoenix.HTML.raw()
26 | end
27 |
28 | ###
29 | # XML related functions
30 | ###
31 |
32 | def xml_strip_tags(text) do
33 | {:safe, text} = do_strip_tags(text)
34 | text
35 | end
36 |
37 | @doc "Returns a date formatted for RSS clients."
38 | def xml_readable_date(date) do
39 | DateHelper.strftime(date, "%e %b %Y %T %z")
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/lib/elixir_jobs_web/helpers/date_helper.ex:
--------------------------------------------------------------------------------
1 | defmodule ElixirJobsWeb.DateHelper do
2 | @moduledoc """
3 | Module with date-related calculation and helper functions.
4 | """
5 |
6 | def diff(date1, date2) do
7 | date1 = date1 |> castin()
8 | date2 = date2 |> castin()
9 |
10 | case Calendar.DateTime.diff(date1, date2) do
11 | {:ok, seconds, _, :before} -> -1 * seconds
12 | {:ok, seconds, _, _} -> seconds
13 | _ -> nil
14 | end
15 | end
16 |
17 | def strftime(date, format) do
18 | {:ok, string} =
19 | date
20 | |> castin()
21 | |> Calendar.Strftime.strftime(format)
22 |
23 | string
24 | end
25 |
26 | # Casts Ecto.DateTimes coming into this module
27 | defp castin(%DateTime{} = date) do
28 | date
29 | |> DateTime.to_naive()
30 | |> NaiveDateTime.to_erl()
31 | |> Calendar.DateTime.from_erl!("Etc/UTC")
32 | end
33 |
34 | defp castin(date) do
35 | date
36 | |> NaiveDateTime.to_erl()
37 | |> Calendar.DateTime.from_erl!("Etc/UTC")
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2017 Oscar de Arriba Gonzalez
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/test/support/factories/base.ex:
--------------------------------------------------------------------------------
1 | defmodule ElixirJobs.Factories.Base do
2 | @moduledoc """
3 | Base module for project's factories.
4 |
5 | Includes macros used for building structs and persist them during tests
6 | """
7 | defmacro __using__(factory) do
8 | quote do
9 | @behaviour ElixirJobs.Factories.Base.Behaviour
10 | @factory unquote(factory)
11 | @base_module unquote(__CALLER__.module)
12 |
13 | defmacro __using__(_) do
14 | quote do
15 | def build_factory(unquote(@factory)),
16 | do: unquote(@base_module).build_factory()
17 |
18 | def get_schema(unquote(@factory)),
19 | do: unquote(@base_module).get_schema()
20 |
21 | def get_changeset(attrs, unquote(@factory)),
22 | do: unquote(@base_module).get_changeset(attrs)
23 | end
24 | end
25 | end
26 | end
27 |
28 | defmodule Behaviour do
29 | @moduledoc false
30 |
31 | @callback build_factory() :: map()
32 | @callback get_schema() :: map()
33 | @callback get_changeset(map()) :: Ecto.Changeset.t()
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/test/elixir_jobs/accounts/services/authenticate_admin_test.exs:
--------------------------------------------------------------------------------
1 | defmodule ElixirJobs.Accounts.Services.AuthenticateAdminTest do
2 | use ElixirJobs.DataCase
3 |
4 | alias ElixirJobs.Accounts.Schemas.Admin
5 | alias ElixirJobs.Accounts.Services.AuthenticateAdmin
6 |
7 | describe "AuthenticateAdmin.call/2" do
8 | test "authenticate admin users" do
9 | admin = insert(:admin)
10 |
11 | {result, resource} = AuthenticateAdmin.call(admin.email, admin.password)
12 |
13 | assert result == :ok
14 | assert %Admin{} = resource
15 | assert resource.id == admin.id
16 | end
17 |
18 | test "returns error on wrong password" do
19 | admin = insert(:admin)
20 |
21 | {result, resource} = AuthenticateAdmin.call(admin.email, "wadus")
22 |
23 | assert result == :error
24 | assert resource == :wrong_credentials
25 | end
26 |
27 | test "returns error on wrong email" do
28 | {result, resource} = AuthenticateAdmin.call("invent@email.com", "wadus")
29 |
30 | assert result == :error
31 | assert resource == :wrong_credentials
32 | end
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/lib/elixir_jobs_web/templates/email/offer_created.html.eex:
--------------------------------------------------------------------------------
1 |
18 | <%= raw gettext("Subscribe to our Telegram channel to get last job offers on your phone!") %>
19 | <%= link raw(gettext("Join now! ")), to: "https://t.me/#{Telegram.get_channel()}", class: "button is-small is-info is-rounded", target: "_blank" %>
20 |
35 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # This file excludes paths from the Docker build context.
2 | #
3 | # By default, Docker's build context includes all files (and folders) in the
4 | # current directory. Even if a file isn't copied into the container it is still sent to
5 | # the Docker daemon.
6 | #
7 | # There are multiple reasons to exclude files from the build context:
8 | #
9 | # 1. Prevent nested folders from being copied into the container (ex: exclude
10 | # /assets/node_modules when copying /assets)
11 | # 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc)
12 | # 3. Avoid sending files containing sensitive information
13 | #
14 | # More information on using .dockerignore is available here:
15 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file
16 |
17 | .dockerignore
18 |
19 | # Ignore git, but keep git HEAD and refs to access current commit hash if needed:
20 | #
21 | # $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat
22 | # d0b8727759e1e0e7aa3d41707d12376e373d5ecc
23 | .git
24 | !.git/HEAD
25 | !.git/refs
26 |
27 | # Common development/test artifacts
28 | /cover/
29 | /doc/
30 | /test/
31 | /tmp/
32 | .elixir_ls
33 |
34 | # Mix artifacts
35 | /_build/
36 | /deps/
37 | *.ez
38 |
39 | # Generated on crash by the VM
40 | erl_crash.dump
41 |
42 | # Static artifacts - These should be fetched and built inside the Docker image
43 | /assets/node_modules/
44 | /priv/static/assets/
45 | /priv/static/cache_manifest.json
46 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Code of Conduct
2 |
3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities.
4 |
5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion.
6 |
7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct.
8 |
9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team.
10 |
11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers.
12 |
13 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/)
14 |
--------------------------------------------------------------------------------
/lib/elixir_jobs_web/helpers/error_helper.ex:
--------------------------------------------------------------------------------
1 | defmodule ElixirJobsWeb.ErrorHelper do
2 | @moduledoc """
3 | Conveniences for translating and building error messages.
4 | """
5 |
6 | use Phoenix.HTML
7 |
8 | @doc """
9 | Generates tag for inlined form input errors.
10 | """
11 | def error_tag(form, field) do
12 | Enum.map(Keyword.get_values(form.errors, field), fn error ->
13 | content_tag(:p, translate_error(error), class: "help is-danger")
14 | end)
15 | end
16 |
17 | @doc """
18 | Translates an error message using gettext.
19 | """
20 | def translate_error({msg, opts}) do
21 | # Because error messages were defined within Ecto, we must
22 | # call the Gettext module passing our Gettext backend. We
23 | # also use the "errors" domain as translations are placed
24 | # in the errors.po file.
25 | # Ecto will pass the :count keyword if the error message is
26 | # meant to be pluralized.
27 | # On your own code and templates, depending on whether you
28 | # need the message to be pluralized or not, this could be
29 | # written simply as:
30 | #
31 | # dngettext "errors", "1 file", "%{count} files", count
32 | # dgettext "errors", "is invalid"
33 | #
34 | if count = opts[:count] do
35 | Gettext.dngettext(ElixirJobsWeb.Gettext, "errors", msg, msg, count, opts)
36 | else
37 | Gettext.dgettext(ElixirJobsWeb.Gettext, "errors", msg, opts)
38 | end
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/lib/elixir_jobs_web/templates/sitemap/sitemap.xml.eex:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 | <%= offer_url(@conn, :index) %>
9 | daily
10 | 1.00
11 |
12 | <%= for filter <- ["onsite", "remote", "full_time", "part_time", "freelance"] do %>
13 |
14 | <%= offer_url(@conn, :index_filtered, filter) %>
15 | daily
16 | 0.95
17 |
18 | <% end %>
19 |
20 | <%= offer_url(@conn, :new) %>
21 | 0.80
22 |
23 |
24 | <%= page_url(@conn, :about) %>
25 | 0.80
26 |
27 | <%= for page <- (1..@total_pages) do %>
28 |
29 | <%= offer_url(@conn, :index, page: page) %>
30 | daily
31 | 0.70
32 |
33 | <% end %>
34 |
35 | <%= for offer <- @offers do %>
36 |
37 | <%= offer_url(@conn, :show, offer.slug) %>
38 | daily
39 | 0.90
40 |
41 | <% end %>
42 |
43 |
--------------------------------------------------------------------------------
/lib/elixir_jobs_web/templates/error/500.html.eex:
--------------------------------------------------------------------------------
1 | !DOCTYPE html>
2 |
3 |
4 | <%= render(ElixirJobsWeb.LayoutView, "shared/_head.html", conn: @conn) %>
5 |
6 |
7 |
8 | <%= render ElixirJobsWeb.LayoutView, "shared/_navbar.html", conn: @conn %>
9 |
10 |
11 |
12 |
13 |
14 |
15 |
<%= gettext("Whoops... This is an error!") %>
16 |
<%= gettext("We just have an error on our side") %>
17 |
18 |
19 | <%= gettext("We will check and fix it as soon as possible.") %>
20 |
21 |
22 | <%= gettext("You can check newest offers on our ") %>
23 | <%= link(gettext("home page"), to: offer_path(@conn, :index)) %>
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | <%= render ElixirJobsWeb.LayoutView, "shared/_footer.html", conn: @conn %>
32 |
33 |
34 | <%= render ElixirJobsWeb.LayoutView, "shared/_analytics.html", conn: @conn %>
35 | <%= render ElixirJobsWeb.LayoutView, "shared/_cookies.html", conn: @conn %>
36 |
37 |
38 |
--------------------------------------------------------------------------------
/lib/elixir_jobs_web/views/offer_view.ex:
--------------------------------------------------------------------------------
1 | defmodule ElixirJobsWeb.OfferView do
2 | use ElixirJobsWeb, :view
3 |
4 | alias ElixirJobs.Core
5 | alias ElixirJobs.Core.Schemas.Offer
6 |
7 | alias ElixirJobsWeb.HumanizeHelper
8 |
9 | @utm_params [
10 | {"utm_source", "elixirjobs.net"},
11 | {"utm_medium", "job_board"},
12 | {"utm_campaign", "elixirjobs.net"}
13 | ]
14 |
15 | def get_job_place_options(default) do
16 | Enum.reduce(Core.get_job_places(), [], fn option, acc ->
17 | select_option = [
18 | {HumanizeHelper.get_place_text(option, default), option}
19 | ]
20 |
21 | acc ++ select_option
22 | end)
23 | end
24 |
25 | def get_job_type_options(default) do
26 | Enum.reduce(Core.get_job_types(), [], fn option, acc ->
27 | select_option = [
28 | {HumanizeHelper.get_type_text(option, default), option}
29 | ]
30 |
31 | acc ++ select_option
32 | end)
33 | end
34 |
35 | def offer_url(%Offer{url: url}) do
36 | parsed_url = URI.parse(url)
37 |
38 | query = parsed_url.query || ""
39 |
40 | query_params =
41 | query
42 | |> URI.query_decoder()
43 | |> Enum.reject(fn
44 | {"utm_source", _} -> true
45 | {"utm_medium", _} -> true
46 | {"utm_campaign", _} -> true
47 | _ -> false
48 | end)
49 | |> Kernel.++(@utm_params)
50 | |> URI.encode_query()
51 |
52 | URI.to_string(%{parsed_url | query: query_params})
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/assets/css/components/_navbar.scss:
--------------------------------------------------------------------------------
1 | .navbar {
2 | -webkit-box-shadow: none;
3 | box-shadow: none;
4 | color: #fff;
5 |
6 | &.is-primary {
7 | background-color: $navbar_color;
8 | }
9 |
10 | &.is-transparent {
11 | background-color: transparent;
12 | }
13 |
14 | @media screen and (max-width: $desktop) {
15 | &.mobile-opened {
16 | background-color: $navbar_color;
17 |
18 | .navbar-menu {
19 | display: block;
20 | padding: 5px 10px;
21 | }
22 | }
23 | }
24 |
25 | .navbar-menu {
26 | background-color: $navbar_color;
27 |
28 | @media screen and (min-width: $desktop) {
29 | background-color: transparent;
30 | }
31 |
32 | a {
33 | text-transform: uppercase;
34 | color: $white;
35 | &:hover {
36 | color: $navbar_color;
37 | }
38 | }
39 | }
40 |
41 | .navbar-burger {
42 | height: 4.4rem;
43 | }
44 |
45 | .navbar-brand {
46 | color: #fff;
47 | font-family: sans-serif;
48 | font-size: 1.5rem;
49 | line-height: 32px;
50 | margin: 0 10px;
51 | font-weight: bold;
52 |
53 | .logo {
54 | margin-right: 5px;
55 | margin-top: -2px;
56 | }
57 |
58 | .navbar-item:hover {
59 | padding-bottom: 0.5rem;
60 | border-bottom: none;
61 | }
62 | }
63 |
64 | .navbar-item {
65 | line-height: 2.3;
66 |
67 | &:hover {
68 | padding-bottom: calc(0.5rem - 2px);
69 | border-bottom: 2px solid whitesmoke;
70 | }
71 | }
72 | }
73 |
74 | .navbar-spacing {
75 | margin-top: 70px
76 | }
77 |
--------------------------------------------------------------------------------
/assets/js/app.js:
--------------------------------------------------------------------------------
1 | import "phoenix_html"
2 |
3 | // Import local files
4 | //
5 | // Local files can be imported directly using relative
6 | // paths "./socket" or full ones "web/static/js/socket".
7 |
8 | // import socket from "./socket"
9 |
10 | import "jquery";
11 | import * as particles from "./app/particles.js";
12 | import * as navbar from "./app/navbar.js";
13 | import * as notifications from "./app/notifications.js";
14 |
15 | function navbarScroll() {
16 | var navbar = document.getElementsByClassName("navbar is-fixed-top")[0];
17 | var has_hero = document.getElementsByClassName("hero main").length > 0;
18 |
19 | if (!has_hero) {
20 | return true;
21 | }
22 |
23 | if (navbar && (document.body.scrollTop > 50 || document.documentElement.scrollTop > 50)) {
24 | navbar.classList.remove("is-transparent");
25 | } else {
26 | navbar.classList.add("is-transparent");
27 | }
28 | }
29 |
30 | document.addEventListener("DOMContentLoaded", function () {
31 | window.onscroll = navbarScroll;
32 |
33 | particles.initParticles();
34 | navbar.initNavbar();
35 | notifications.initNotifications();
36 | navbarScroll();
37 |
38 | $(".offer-new form button#preview").click(function (evt) {
39 | evt.preventDefault();
40 |
41 | var form = $(this).closest("form"),
42 | form_data = $(form).serialize(),
43 | $preview_div = $(".offer-new .offer-preview");
44 |
45 | $.post($(this).data("url"), form_data, function (res) {
46 | $preview_div.show();
47 | $preview_div.html(res);
48 |
49 | $('html, body').animate({
50 | scrollTop: $preview_div.offset().top
51 | }, 'slow');
52 | });
53 | });
54 | });
55 |
--------------------------------------------------------------------------------
/lib/elixir_jobs_web/twitter.ex:
--------------------------------------------------------------------------------
1 | defmodule ElixirJobsWeb.Twitter do
2 | @moduledoc """
3 | Twitter-related functions to ease the publishing of new offers in that social
4 | network.
5 | """
6 |
7 | alias ElixirJobs.Core.Schemas.Offer
8 | alias ElixirJobsWeb.Router.Helpers, as: RouterHelpers
9 |
10 | alias ElixirJobsWeb.HumanizeHelper
11 |
12 | @short_link_length 25
13 | @twitter_limit 140
14 | @tags [
15 | "job",
16 | "myelixirstatus",
17 | "elixirlang"
18 | ]
19 |
20 | def publish(%Plug.Conn{} = conn, %Offer{} = offer) do
21 | text = get_text(offer)
22 | tags = get_tags()
23 | url = get_url(conn, offer)
24 |
25 | status_length = String.length(text) + String.length(tags) + 3 + @short_link_length
26 |
27 | status =
28 | case status_length do
29 | n when n <= @twitter_limit ->
30 | Enum.join([text, tags, url], " ")
31 |
32 | n ->
33 | exceed = n - @twitter_limit
34 | max_text_length = String.length(text) - exceed
35 |
36 | short_text =
37 | text
38 | |> String.slice(0, max_text_length - 3)
39 | |> Kernel.<>("...")
40 |
41 | Enum.join([short_text, tags, url], " ")
42 | end
43 |
44 | ExTwitter.update(status)
45 | end
46 |
47 | defp get_text(%Offer{company: company, title: title, job_place: job_place}) do
48 | "#{title} @ #{company} / #{HumanizeHelper.human_get_place(job_place, "Unknown Place")}"
49 | end
50 |
51 | defp get_tags do
52 | Enum.map_join(@tags, " ", &"##{&1}")
53 | end
54 |
55 | defp get_url(%Plug.Conn{} = conn, %Offer{slug: slug}) do
56 | RouterHelpers.offer_url(conn, :show, slug)
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/test/support/data_case.ex:
--------------------------------------------------------------------------------
1 | defmodule ElixirJobs.DataCase do
2 | @moduledoc """
3 | This module defines the setup for tests requiring
4 | access to the application's data layer.
5 |
6 | You may define functions here to be used as helpers in
7 | your tests.
8 |
9 | Finally, if the test case interacts with the database,
10 | it cannot be async. For this reason, every test runs
11 | inside a transaction which is reset at the beginning
12 | of the test unless the test case is marked as async.
13 | """
14 |
15 | use ExUnit.CaseTemplate
16 |
17 | alias Ecto.Adapters.SQL.Sandbox, as: SQLSandbox
18 |
19 | using do
20 | quote do
21 | alias ElixirJobs.Repo
22 |
23 | import Ecto
24 | import Ecto.Changeset
25 | import Ecto.Query
26 | import ElixirJobs.DataCase
27 | import ElixirJobs.Factory
28 | end
29 | end
30 |
31 | setup tags do
32 | :ok = SQLSandbox.checkout(ElixirJobs.Repo)
33 |
34 | unless tags[:async] do
35 | SQLSandbox.mode(ElixirJobs.Repo, {:shared, self()})
36 | end
37 |
38 | :ok
39 | end
40 |
41 | @doc """
42 | A helper that transform changeset errors to a map of messages.
43 |
44 | assert {:error, changeset} = Accounts.create_user(%{password: "short"})
45 | assert "password is too short" in errors_on(changeset).password
46 | assert %{password: ["password is too short"]} = errors_on(changeset)
47 |
48 | """
49 | def errors_on(changeset) do
50 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
51 | Enum.reduce(opts, message, fn {key, value}, acc ->
52 | String.replace(acc, "%{#{key}}", to_string(value))
53 | end)
54 | end)
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Elixir Jobs
2 |
3 | > [!NOTE]
4 | > This repo will be in read-only mode starting September 1st, 2023
5 | > Thanks to everyone who contributed to ElixirJobs 💜
6 |
7 | Elixir Jobs is a job board based in Elixir + Phoenix.
8 |
9 | ## Technologies used
10 |
11 | - Erlang
12 | - Elixir
13 | - Phoenix
14 | - NodeJS
15 | - PostgreSQL
16 |
17 | Some of the versions are set on `.tool_versions` file, so you can use it with [asdf version manager](https://github.com/asdf-vm/asdf)
18 |
19 | ## Start the project
20 |
21 | The project should be installed and set up like any other elixir project.
22 |
23 | ```
24 | $ cd elixir_jobs
25 | $ mix deps.get
26 | $ mix ecto.create
27 | $ mix ecto.migrate
28 | ```
29 |
30 | You might encounter some errors about the secrets files. That's because you need to copy the template files under `./config` and personalise them with your local configuration.
31 |
32 | Also, assets now live on `./assets`, so NPM and brunch configurations are there.
33 |
34 | ### Seeds
35 |
36 | The project has the model of Administrators, which take care of approving the offers before showing them on the site.
37 |
38 | You can create a dummy administration user (credetials: dummy@user.com / 123456) using the seeds:
39 |
40 | ```
41 | $ mix run priv/repo/seeds.exs
42 | ```
43 |
44 | ## Contribute
45 |
46 | All contributions are welcome, and we really hope this repo will serve for beginners as well for more advanced developers.
47 |
48 | If you have any doubt, feel free to ask, but always respecting our [Code of Conduct](https://github.com/odarriba/elixir_jobs/blob/master/CODE_OF_CONDUCT.md).
49 |
50 | To contribute, create a fork of the repository, make your changes and create a PR. And remember, talking on PRs/issues is a must!
51 |
--------------------------------------------------------------------------------
/lib/elixir_jobs/core/fields/job_place.ex:
--------------------------------------------------------------------------------
1 | defmodule ElixirJobs.Core.Fields.JobPlace do
2 | @moduledoc """
3 | Field definition module to save in the database the type of an account
4 | """
5 |
6 | use Ecto.Type
7 |
8 | @values [
9 | :unknown,
10 | :onsite,
11 | :remote,
12 | :both
13 | ]
14 |
15 | def available_values, do: @values
16 |
17 | @doc false
18 | def type, do: :string
19 |
20 | @doc """
21 | Cast an job place from the value input to verify that it's a registered value.
22 |
23 | ## Examples
24 |
25 | iex> cast(:onsite)
26 | {:ok, :onsite}
27 |
28 | iex> cast("onsite")
29 | {:ok, :onsite}
30 |
31 | iex> cast(:wadus)
32 | :error
33 |
34 | """
35 | @spec cast(atom()) :: {:ok, atom()} | :error
36 | def cast(value) when value in @values, do: {:ok, value}
37 | def cast(value) when is_binary(value), do: load(value)
38 | def cast(_value), do: :error
39 |
40 | @doc """
41 | Load a job place value from the adapter to adapt it to the desired format in the app.
42 |
43 | ## Examples
44 |
45 | iex> load("onsite")
46 | {:ok, :onsite}
47 |
48 | iex> load("wadus")
49 | :error
50 |
51 | """
52 | @spec load(String.t()) :: {:ok, atom()} | :error
53 | def load(value) when is_binary(value) do
54 | @values
55 | |> Enum.find(fn k -> to_string(k) == value end)
56 | |> case do
57 | k when not is_nil(k) ->
58 | {:ok, k}
59 |
60 | _ ->
61 | :error
62 | end
63 | end
64 |
65 | def load(_), do: :error
66 |
67 | @doc """
68 | Translate the value in the app side to the database type.
69 |
70 | ## Examples
71 |
72 | iex> dump(:onsite)
73 | {:ok, "onsite"}
74 |
75 | iex> dump(:wadus)
76 | :error
77 |
78 | """
79 | @spec dump(atom()) :: {:ok, String.t()} | :error
80 | def dump(value) when value in @values, do: {:ok, to_string(value)}
81 | def dump(_), do: :error
82 | end
83 |
--------------------------------------------------------------------------------
/lib/elixir_jobs/core/fields/job_type.ex:
--------------------------------------------------------------------------------
1 | defmodule ElixirJobs.Core.Fields.JobType do
2 | @moduledoc """
3 | Field definition module to save in the database the type of an account
4 | """
5 |
6 | use Ecto.Type
7 |
8 | @values [
9 | :unknown,
10 | :full_time,
11 | :part_time,
12 | :freelance
13 | ]
14 |
15 | def available_values, do: @values
16 |
17 | @doc false
18 | def type, do: :string
19 |
20 | @doc """
21 | Cast an job type from the value input to verify that it's a registered value.
22 |
23 | ## Examples
24 |
25 | iex> cast(:full_time)
26 | {:ok, :full_time}
27 |
28 | iex> cast("full_time")
29 | {:ok, :full_time}
30 |
31 | iex> cast(:wadus)
32 | :error
33 |
34 | """
35 | @spec cast(atom()) :: {:ok, atom()} | :error
36 | def cast(value) when value in @values, do: {:ok, value}
37 | def cast(value) when is_binary(value), do: load(value)
38 | def cast(_value), do: :error
39 |
40 | @doc """
41 | Load a job type value from the adapter to adapt it to the desired format in the app.
42 |
43 | ## Examples
44 |
45 | iex> load("full_time")
46 | {:ok, :full_time}
47 |
48 | iex> load("wadus")
49 | :error
50 |
51 | """
52 | @spec load(String.t()) :: {:ok, atom()} | :error
53 | def load(value) when is_binary(value) do
54 | @values
55 | |> Enum.find(fn k -> to_string(k) == value end)
56 | |> case do
57 | k when not is_nil(k) ->
58 | {:ok, k}
59 |
60 | _ ->
61 | :error
62 | end
63 | end
64 |
65 | def load(_), do: :error
66 |
67 | @doc """
68 | Translate the value in the app side to the database type.
69 |
70 | ## Examples
71 |
72 | iex> dump(:full_time)
73 | {:ok, "full_time"}
74 |
75 | iex> dump(:wadus)
76 | :error
77 |
78 | """
79 | @spec dump(atom()) :: {:ok, String.t()} | :error
80 | def dump(value) when value in @values, do: {:ok, to_string(value)}
81 | def dump(_), do: :error
82 | end
83 |
--------------------------------------------------------------------------------
/lib/elixir_jobs_web/templates/admin/offer/index_published.html.eex:
--------------------------------------------------------------------------------
1 |
45 |
46 | <%= render("_pagination.html", conn: @conn, page_number: @page_number, total_pages: @total_pages, method: :index_unpublished) %>
47 |
--------------------------------------------------------------------------------
/lib/elixir_jobs/core/queries/offer.ex:
--------------------------------------------------------------------------------
1 | defmodule ElixirJobs.Core.Queries.Offer do
2 | @moduledoc """
3 | Module to build queries related to the Offer schema
4 | """
5 |
6 | import Ecto.Query, warn: false
7 |
8 | def build(query, opts) do
9 | Enum.reduce(opts, query, fn
10 | {:published, true}, q ->
11 | q
12 | |> published()
13 | |> order_published()
14 |
15 | {:published, false}, q ->
16 | unpublished(q)
17 |
18 | {:job_place, value}, q ->
19 | by_job_place(q, value)
20 |
21 | {:job_type, value}, q ->
22 | by_job_type(q, value)
23 |
24 | {:search_text, text}, q ->
25 | by_text(q, text)
26 |
27 | _, q ->
28 | q
29 | end)
30 | end
31 |
32 | def by_id(query, id) do
33 | from o in query, where: o.id == ^id
34 | end
35 |
36 | def by_slug(query, slug) do
37 | from o in query, where: o.slug == ^slug
38 | end
39 |
40 | def by_job_type(query, values) when is_list(values) do
41 | from o in query, where: o.job_type in ^values
42 | end
43 |
44 | def by_job_type(query, value) do
45 | from o in query, where: o.job_type == ^value
46 | end
47 |
48 | def by_job_place(query, values) when is_list(values) do
49 | from o in query, where: o.job_place in ^values
50 | end
51 |
52 | def by_job_place(query, value) do
53 | from o in query, where: o.job_place == ^value
54 | end
55 |
56 | def by_text(query, text) when is_binary(text) do
57 | text
58 | |> String.split(" ")
59 | |> Enum.map(&"%#{&1}%")
60 | |> Enum.reduce(query, fn keyword, q ->
61 | from o in q,
62 | where:
63 | ilike(o.title, ^keyword) or ilike(o.company, ^keyword) or ilike(o.summary, ^keyword) or
64 | ilike(o.location, ^keyword)
65 | end)
66 | end
67 |
68 | def published(query) do
69 | from o in query,
70 | where: not is_nil(o.published_at) and o.published_at <= ^DateTime.utc_now()
71 | end
72 |
73 | def unpublished(query) do
74 | from o in query, where: is_nil(o.published_at)
75 | end
76 |
77 | def order_published(query) do
78 | from o in query, order_by: [desc: o.published_at]
79 | end
80 | end
81 |
--------------------------------------------------------------------------------
/lib/elixir_jobs_web/helpers/humanize_helper.ex:
--------------------------------------------------------------------------------
1 | defmodule ElixirJobsWeb.HumanizeHelper do
2 | @moduledoc false
3 | use ElixirJobsWeb, :helper
4 |
5 | alias ElixirJobsWeb.DateHelper
6 |
7 | def human_get_place("onsite", default), do: get_place_text(:onsite, default)
8 | def human_get_place("remote", default), do: get_place_text(:remote, default)
9 | def human_get_place("both", default), do: get_place_text(:both, default)
10 | def human_get_place(option, default), do: get_place_text(option, default)
11 |
12 | def human_get_type("full_time", default), do: get_type_text(:full_time, default)
13 | def human_get_type("part_time", default), do: get_type_text(:part_time, default)
14 | def human_get_type("freelance", default), do: get_type_text(:freelance, default)
15 | def human_get_type(option, default), do: get_type_text(option, default)
16 |
17 | @doc "Returns a date formatted for humans."
18 | def readable_date(date, use_abbrevs? \\ true) do
19 | if use_abbrevs? && this_year?(date) do
20 | cond do
21 | today?(date) ->
22 | "Today"
23 |
24 | yesterday?(date) ->
25 | "Yesterday"
26 |
27 | true ->
28 | DateHelper.strftime(date, "%e %b")
29 | end
30 | else
31 | DateHelper.strftime(date, "%e %b %Y")
32 | end
33 | end
34 |
35 | def get_place_text(:onsite, _default), do: gettext("On site")
36 | def get_place_text(:remote, _default), do: gettext("Remote")
37 | def get_place_text(:both, _default), do: gettext("Onsite / Remote")
38 | def get_place_text(_, default), do: default
39 |
40 | def get_type_text(:full_time, _default), do: gettext("Full time")
41 | def get_type_text(:part_time, _default), do: gettext("Part time")
42 | def get_type_text(:freelance, _default), do: gettext("Freelance")
43 | def get_type_text(_, default), do: default
44 |
45 | ###
46 | # Private functions
47 | ###
48 |
49 | defp this_year?(date), do: date.year == DateTime.utc_now().year
50 |
51 | defp today?(date) do
52 | now = DateTime.utc_now()
53 | date.day == now.day && date.month == now.month && date.year == now.year
54 | end
55 |
56 | def yesterday?(date) do
57 | now = DateTime.utc_now()
58 | difference = DateTime.diff(now, date)
59 | difference < 2 * 24 * 60 * 60 && difference > 1 * 24 * 60 * 60
60 | end
61 | end
62 |
--------------------------------------------------------------------------------
/config/dev.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # For development, we disable any cache and enable
4 | # debugging and code reloading.
5 | #
6 | # The watchers configuration can be used to run external
7 | # watchers to your application. For example, we use it
8 | # with brunch.io to recompile .js and .css sources.
9 | config :elixir_jobs, ElixirJobsWeb.Endpoint,
10 | http: [port: 4000],
11 | debug_errors: true,
12 | code_reloader: true,
13 | check_origin: false,
14 | watchers: [npm: ["run", "watch", "--stdin", cd: Path.expand("../assets/", __DIR__)]]
15 |
16 | # ## SSL Support
17 | #
18 | # In order to use HTTPS in development, a self-signed
19 | # certificate can be generated by running the following
20 | # command from your terminal:
21 | #
22 | # openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 -subj "/C=US/ST=Denial/L=Springfield/O=Dis/CN=www.example.com" -keyout priv/server.key -out priv/server.pem
23 | #
24 | # The `http:` config above can be replaced with:
25 | #
26 | # https: [port: 4000, keyfile: "priv/server.key", certfile: "priv/server.pem"],
27 | #
28 | # If desired, both `http:` and `https:` keys can be
29 | # configured to run both http and https servers on
30 | # different ports.
31 |
32 | # Watch static and templates for browser reloading.
33 | config :elixir_jobs, ElixirJobsWeb.Endpoint,
34 | live_reload: [
35 | patterns: [
36 | ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$},
37 | ~r{priv/gettext/.*(po)$},
38 | ~r{lib/elixir_jobs_web/views/.*(ex)$},
39 | ~r{lib/elixir_jobs_web/templates/.*(eex)$}
40 | ]
41 | ]
42 |
43 | # Do not include metadata nor timestamps in development logs
44 | config :logger, :console, format: "[$level] $message\n"
45 |
46 | # Set a higher stacktrace during development. Avoid configuring such
47 | # in production as building large stacktraces may be expensive.
48 | config :phoenix, :stacktrace_depth, 20
49 |
50 | # BCrypt configuration
51 | config :bcrypt_elixir, :log_rounds, 10
52 |
53 | config :elixir_jobs, ElixirJobsWeb.Guardian,
54 | issuer: "Elixir Jobs",
55 | secret_key: "MY_D3V_K3Y"
56 |
57 | config :elixir_jobs, ElixirJobsWeb.Mailer, adapter: Bamboo.LocalAdapter
58 |
59 | config :elixir_jobs, :default_app_email, "no-reply@elixirjobs.net"
60 | config :elixir_jobs, :analytics_id, ""
61 | config :elixir_jobs, :telegram_channel, "elixir_jobs_st"
62 |
63 | # Import custom configuration
64 | import_config "dev.secret.exs"
65 |
--------------------------------------------------------------------------------
/assets/js/socket.js:
--------------------------------------------------------------------------------
1 | // NOTE: The contents of this file will only be executed if
2 | // you uncomment its entry in "assets/js/app.js".
3 |
4 | // To use Phoenix channels, the first step is to import Socket
5 | // and connect at the socket path in "lib/web/endpoint.ex":
6 | import {Socket} from "phoenix"
7 |
8 | let socket = new Socket("/socket", {params: {token: window.userToken}})
9 |
10 | // When you connect, you'll often need to authenticate the client.
11 | // For example, imagine you have an authentication plug, `MyAuth`,
12 | // which authenticates the session and assigns a `:current_user`.
13 | // If the current user exists you can assign the user's token in
14 | // the connection for use in the layout.
15 | //
16 | // In your "lib/web/router.ex":
17 | //
18 | // pipeline :browser do
19 | // ...
20 | // plug MyAuth
21 | // plug :put_user_token
22 | // end
23 | //
24 | // defp put_user_token(conn, _) do
25 | // if current_user = conn.assigns[:current_user] do
26 | // token = Phoenix.Token.sign(conn, "user socket", current_user.id)
27 | // assign(conn, :user_token, token)
28 | // else
29 | // conn
30 | // end
31 | // end
32 | //
33 | // Now you need to pass this token to JavaScript. You can do so
34 | // inside a script tag in "lib/web/templates/layout/app.html.eex":
35 | //
36 | //
37 | //
38 | // You will need to verify the user token in the "connect/2" function
39 | // in "lib/web/channels/user_socket.ex":
40 | //
41 | // def connect(%{"token" => token}, socket) do
42 | // # max_age: 1209600 is equivalent to two weeks in seconds
43 | // case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do
44 | // {:ok, user_id} ->
45 | // {:ok, assign(socket, :user, user_id)}
46 | // {:error, reason} ->
47 | // :error
48 | // end
49 | // end
50 | //
51 | // Finally, pass the token on connect as below. Or remove it
52 | // from connect if you don't care about authentication.
53 |
54 | socket.connect()
55 |
56 | // Now that you are connected, you can join channels with a topic:
57 | let channel = socket.channel("topic:subtopic", {})
58 | channel.join()
59 | .receive("ok", resp => { console.log("Joined successfully", resp) })
60 | .receive("error", resp => { console.log("Unable to join", resp) })
61 |
62 | export default socket
63 |
--------------------------------------------------------------------------------
/lib/elixir_jobs_web/templates/page/about.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
<%= raw gettext("What is Elixir Jobs?") %>
3 |
4 |
<%= raw gettext("Elixir Jobs is a site built to help developers to find their next Elixir job, and to help companies to reach the vibrant Elixir community.") %>
5 |
6 |
<%= raw gettext("Also, our aim is to gain visibility for the Elixir job market, showing potential offers to developers all around the world, and using the tools they like to use.") %>
7 |
8 |
<%= raw gettext("Why should I post my job offers here?") %>
9 |
10 |
<%= raw gettext("By posting your offer on our site, it will be distributed also using Telegram, RSS and Twitter (why not start %{following_us}?), so you will potentially reach more developers than using other ways. And more important: you will be reaching developers that want a new job.",
11 | following_us: "#{gettext("following us")}") %>
12 |
13 |
<%= raw gettext("Also, because you can post your job listing for free.") %>
14 |
15 |
<%= raw gettext("Get in touch") %>
16 |
17 |
<%= raw gettext("If you need to contact us regarding a job offer, for commercial enquiries or just to say something, you can send us an email to hi@elixirjobs.net") %>
18 |
19 |
<%= raw gettext("Sponsors") %>
20 |
21 |
<%= raw gettext("ElixirJobs runs smoothly thanks to our beloved sponsors, who contribute to the site allowing us to continually develop and maintain it.") %>
22 |
23 |
35 |
36 |
<%= raw gettext("If your company wants to become a sponsor of ElixirJobs, please send us an email to hi@elixirjobs.net") %>
37 |
38 |
--------------------------------------------------------------------------------
/.github/workflows/elixir.yml:
--------------------------------------------------------------------------------
1 | name: Elixir CI
2 |
3 | on: [push]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-22.04
8 |
9 | container:
10 | image: hexpm/elixir:1.15.4-erlang-26.0.2-alpine-3.18.2
11 |
12 | services:
13 | postgres:
14 | image: postgres
15 | ports:
16 | - 5432:5432
17 | env:
18 | POSTGRES_USER: elixir_jobs
19 | POSTGRES_PASSWORD: elixir_jobs
20 | POSTGRES_DB: elixir_jobs_test
21 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
22 |
23 | steps:
24 | - uses: actions/checkout@v1
25 | - name: Install Dependencies
26 | env:
27 | MIX_ENV: test
28 | POSTGRES_HOST: postgres
29 | POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }}
30 | POSTGRES_USERNAME: elixir_jobs
31 | POSTGRES_PASSWORD: elixir_jobs
32 | POSTGRES_DB: elixir_jobs_test
33 | run: |
34 | cp config/test.secret.ci.exs config/test.secret.exs
35 | mix local.rebar --force
36 | mix local.hex --force
37 | apk add --update-cache build-base
38 | mix deps.get
39 | - name: Compile
40 | env:
41 | MIX_ENV: test
42 | POSTGRES_HOST: postgres
43 | POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }}
44 | POSTGRES_USERNAME: elixir_jobs
45 | POSTGRES_PASSWORD: elixir_jobs
46 | POSTGRES_DB: elixir_jobs_test
47 | run: mix compile --warnings-as-errors
48 | - name: Run formatter
49 | env:
50 | MIX_ENV: test
51 | POSTGRES_HOST: postgres
52 | POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }}
53 | POSTGRES_USERNAME: elixir_jobs
54 | POSTGRES_PASSWORD: elixir_jobs
55 | POSTGRES_DB: elixir_jobs_test
56 | run: mix format --check-formatted
57 | - name: Run Credo
58 | env:
59 | MIX_ENV: test
60 | POSTGRES_HOST: postgres
61 | POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }}
62 | POSTGRES_USERNAME: elixir_jobs
63 | POSTGRES_PASSWORD: elixir_jobs
64 | POSTGRES_DB: elixir_jobs_test
65 | run: mix credo
66 | - name: Run Tests
67 | env:
68 | MIX_ENV: test
69 | POSTGRES_HOST: postgres
70 | POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }}
71 | POSTGRES_USERNAME: elixir_jobs
72 | POSTGRES_PASSWORD: elixir_jobs
73 | POSTGRES_DB: elixir_jobs_test
74 | run: mix test
75 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule ElixirJobs.Mixfile do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :elixir_jobs,
7 | version: "0.0.1",
8 | elixir: "~> 1.12",
9 | elixirc_paths: elixirc_paths(Mix.env()),
10 | compilers: [:phoenix] ++ Mix.compilers(),
11 | build_embedded: Mix.env() == :prod,
12 | start_permanent: Mix.env() == :prod,
13 | aliases: aliases(),
14 | deps: deps()
15 | ]
16 | end
17 |
18 | # Configuration for the OTP application.
19 | #
20 | # Type `mix help compile.app` for more information.
21 | def application do
22 | [
23 | mod: {ElixirJobs.Application, []},
24 | extra_applications: [:logger]
25 | ]
26 | end
27 |
28 | # Specifies which paths to compile per environment.
29 | defp elixirc_paths(:test), do: ["lib", "test/support"]
30 | defp elixirc_paths(_), do: ["lib"]
31 |
32 | # Specifies your project dependencies.
33 | #
34 | # Type `mix help deps` for examples and options.
35 | defp deps do
36 | [
37 | {:appsignal_phoenix, "~> 2.0"},
38 | {:bamboo_phoenix, "~> 1.0"},
39 | {:bamboo, "~> 2.0"},
40 | {:bcrypt_elixir, "~> 3.0"},
41 | {:calendar, "~> 1.0"},
42 | {:comeonin, "~> 5.3"},
43 | {:credo, "~> 1.5", only: [:dev, :test], runtime: false},
44 | {:ecto_sql, "~> 3.7"},
45 | {:extwitter, "~> 0.13.0"},
46 | {:faker, "~> 0.16", only: :test},
47 | {:gettext, "~> 0.20.0"},
48 | {:guardian, "~> 2.2"},
49 | {:html_sanitize_ex, "~> 1.4"},
50 | {:jason, "~> 1.2"},
51 | {:nadia, "~> 0.7"},
52 | {:oauther, "~> 1.1"},
53 | {:phoenix_ecto, "~> 4.4"},
54 | {:phoenix_html, "~> 3.0"},
55 | {:phoenix_live_reload, "~> 1.3", only: :dev},
56 | {:phoenix_pubsub, "~> 2.0"},
57 | {:phoenix, "~> 1.6.0"},
58 | {:plug_cowboy, "~> 2.1"},
59 | {:postgrex, ">= 0.0.0"},
60 | {:scrivener_ecto, "~> 2.7"},
61 | {:slugger, "~> 0.3.0"}
62 | ]
63 | end
64 |
65 | # Aliases are shortcuts or tasks specific to the current project.
66 | # For example, to create, migrate and run the seeds file at once:
67 | #
68 | # $ mix ecto.setup
69 | #
70 | # See the documentation for `Mix` for more info on aliases.
71 | defp aliases do
72 | [
73 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
74 | "ecto.reset": ["ecto.drop", "ecto.setup"],
75 | test: ["ecto.drop --quiet", "ecto.create --quiet", "ecto.migrate", "test"]
76 | ]
77 | end
78 | end
79 |
--------------------------------------------------------------------------------
/lib/elixir_jobs_web/router.ex:
--------------------------------------------------------------------------------
1 | defmodule ElixirJobsWeb.Router do
2 | use ElixirJobsWeb, :router
3 |
4 | pipeline :browser do
5 | plug :accepts, ["html"]
6 | plug :fetch_session
7 | plug :fetch_flash
8 | plug :protect_from_forgery
9 | plug :put_secure_browser_headers
10 |
11 | plug ElixirJobsWeb.Plugs.GuardianPipeline
12 | end
13 |
14 | pipeline :authentication_required do
15 | plug Guardian.Plug.EnsureAuthenticated
16 | end
17 |
18 | pipeline :api do
19 | plug :accepts, ["json"]
20 | end
21 |
22 | scope "/", ElixirJobsWeb do
23 | # Use the default browser stack
24 | pipe_through :browser
25 |
26 | get "/", OfferController, :index
27 | get "/about", PageController, :about
28 | get "/sponsors", PageController, :sponsors
29 | get "/rss", OfferController, :rss
30 | get "/sitemap.xml", SitemapController, :sitemap
31 | get "/page/:page", OfferController, :index, as: :offer_page
32 | get "/search", OfferController, :search
33 | get "/offers/filter/:filter", OfferController, :index_filtered
34 | get "/offers/place/:filter", OfferController, :index_filtered
35 | get "/offers/new", OfferController, :new
36 | post "/offers/new", OfferController, :create
37 | post "/offers/preview", OfferController, :preview
38 | put "/offers/preview", OfferController, :preview
39 | get "/offers/:slug", OfferController, :show
40 |
41 | get "/login", AuthController, :new
42 | post "/login", AuthController, :create
43 | end
44 |
45 | if Mix.env() == :dev do
46 | forward "/sent_emails", Bamboo.SentEmailViewerPlug
47 | end
48 |
49 | scope "/", ElixirJobsWeb do
50 | # Use the default browser stack
51 | pipe_through [:browser, :authentication_required]
52 |
53 | get "/logout", AuthController, :delete
54 |
55 | scope "/admin", Admin, as: :admin do
56 | get "/offers/published", OfferController, :index_published
57 | get "/offers/pending", OfferController, :index_unpublished
58 | get "/offers/:slug/publish", OfferController, :publish
59 | get "/offers/:slug/send_twitter", OfferController, :send_twitter
60 | get "/offers/:slug/send_telegram", OfferController, :send_telegram
61 | get "/offers/:slug/edit", OfferController, :edit
62 | put "/offers/:slug/edit", OfferController, :update
63 | delete "/offers/:slug", OfferController, :delete
64 | end
65 | end
66 |
67 | # Other scopes may use custom stacks.
68 | # scope "/api", ElixirJobsWeb do
69 | # pipe_through :api
70 | # end
71 | end
72 |
--------------------------------------------------------------------------------
/lib/elixir_jobs_web/templates/layout/shared/_head.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | <%= gettext("Elixir Jobs") %> - <%= SeoHelper.page_title(@conn) %>
5 |
6 |
7 |
8 | - <%= SeoHelper.page_title(@conn) %>" />
9 |
10 | " />
11 |
12 |
13 | - <%= SeoHelper.page_title(@conn) %>" />
14 |
15 | " />
16 | ">
17 | ">
18 |
19 |
20 | ">
21 | ">
22 | ">
23 | " color="#5c18bf">
24 |
25 |
26 |
27 |
28 | ">
29 |
30 |
--------------------------------------------------------------------------------
/priv/gettext/en/LC_MESSAGES/errors.po:
--------------------------------------------------------------------------------
1 | ## `msgid`s in this file come from POT (.pot) files.
2 | ##
3 | ## Do not add, change, or remove `msgid`s manually here as
4 | ## they're tied to the ones in the corresponding POT file
5 | ## (with the same domain).
6 | ##
7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge`
8 | ## to merge POT files into PO files.
9 | msgid ""
10 | msgstr ""
11 | "Language: en\n"
12 |
13 | ## From Ecto.Changeset.cast/4
14 | msgid "can't be blank"
15 | msgstr ""
16 |
17 | ## From Ecto.Changeset.unique_constraint/3
18 | msgid "has already been taken"
19 | msgstr ""
20 |
21 | ## From Ecto.Changeset.put_change/3
22 | msgid "is invalid"
23 | msgstr ""
24 |
25 | ## From Ecto.Changeset.validate_acceptance/3
26 | msgid "must be accepted"
27 | msgstr ""
28 |
29 | ## From Ecto.Changeset.validate_format/3
30 | msgid "has invalid format"
31 | msgstr ""
32 |
33 | ## From Ecto.Changeset.validate_subset/3
34 | msgid "has an invalid entry"
35 | msgstr ""
36 |
37 | ## From Ecto.Changeset.validate_exclusion/3
38 | msgid "is reserved"
39 | msgstr ""
40 |
41 | ## From Ecto.Changeset.validate_confirmation/3
42 | msgid "does not match confirmation"
43 | msgstr ""
44 |
45 | ## From Ecto.Changeset.no_assoc_constraint/3
46 | msgid "is still associated with this entry"
47 | msgstr ""
48 |
49 | msgid "are still associated with this entry"
50 | msgstr ""
51 |
52 | ## From Ecto.Changeset.validate_length/3
53 | msgid "should be %{count} character(s)"
54 | msgid_plural "should be %{count} character(s)"
55 | msgstr[0] ""
56 | msgstr[1] ""
57 |
58 | msgid "should have %{count} item(s)"
59 | msgid_plural "should have %{count} item(s)"
60 | msgstr[0] ""
61 | msgstr[1] ""
62 |
63 | msgid "should be at least %{count} character(s)"
64 | msgid_plural "should be at least %{count} character(s)"
65 | msgstr[0] ""
66 | msgstr[1] ""
67 |
68 | msgid "should have at least %{count} item(s)"
69 | msgid_plural "should have at least %{count} item(s)"
70 | msgstr[0] ""
71 | msgstr[1] ""
72 |
73 | msgid "should be at most %{count} character(s)"
74 | msgid_plural "should be at most %{count} character(s)"
75 | msgstr[0] ""
76 | msgstr[1] ""
77 |
78 | msgid "should have at most %{count} item(s)"
79 | msgid_plural "should have at most %{count} item(s)"
80 | msgstr[0] ""
81 | msgstr[1] ""
82 |
83 | ## From Ecto.Changeset.validate_number/3
84 | msgid "must be less than %{number}"
85 | msgstr ""
86 |
87 | msgid "must be greater than %{number}"
88 | msgstr ""
89 |
90 | msgid "must be less than or equal to %{number}"
91 | msgstr ""
92 |
93 | msgid "must be greater than or equal to %{number}"
94 | msgstr ""
95 |
96 | msgid "must be equal to %{number}"
97 | msgstr ""
98 |
--------------------------------------------------------------------------------
/priv/gettext/errors.pot:
--------------------------------------------------------------------------------
1 | ## This file is a PO Template file.
2 | ##
3 | ## `msgid`s here are often extracted from source code.
4 | ## Add new translations manually only if they're dynamic
5 | ## translations that can't be statically extracted.
6 | ##
7 | ## Run `mix gettext.extract` to bring this file up to
8 | ## date. Leave `msgstr`s empty as changing them here as no
9 | ## effect: edit them in PO (`.po`) files instead.
10 |
11 | ## From Ecto.Changeset.cast/4
12 | msgid "can't be blank"
13 | msgstr ""
14 |
15 | ## From Ecto.Changeset.unique_constraint/3
16 | msgid "has already been taken"
17 | msgstr ""
18 |
19 | ## From Ecto.Changeset.put_change/3
20 | msgid "is invalid"
21 | msgstr ""
22 |
23 | ## From Ecto.Changeset.validate_acceptance/3
24 | msgid "must be accepted"
25 | msgstr ""
26 |
27 | ## From Ecto.Changeset.validate_format/3
28 | msgid "has invalid format"
29 | msgstr ""
30 |
31 | ## From Ecto.Changeset.validate_subset/3
32 | msgid "has an invalid entry"
33 | msgstr ""
34 |
35 | ## From Ecto.Changeset.validate_exclusion/3
36 | msgid "is reserved"
37 | msgstr ""
38 |
39 | ## From Ecto.Changeset.validate_confirmation/3
40 | msgid "does not match confirmation"
41 | msgstr ""
42 |
43 | ## From Ecto.Changeset.no_assoc_constraint/3
44 | msgid "is still associated with this entry"
45 | msgstr ""
46 |
47 | msgid "are still associated with this entry"
48 | msgstr ""
49 |
50 | ## From Ecto.Changeset.validate_length/3
51 | msgid "should be %{count} character(s)"
52 | msgid_plural "should be %{count} character(s)"
53 | msgstr[0] ""
54 | msgstr[1] ""
55 |
56 | msgid "should have %{count} item(s)"
57 | msgid_plural "should have %{count} item(s)"
58 | msgstr[0] ""
59 | msgstr[1] ""
60 |
61 | msgid "should be at least %{count} character(s)"
62 | msgid_plural "should be at least %{count} character(s)"
63 | msgstr[0] ""
64 | msgstr[1] ""
65 |
66 | msgid "should have at least %{count} item(s)"
67 | msgid_plural "should have at least %{count} item(s)"
68 | msgstr[0] ""
69 | msgstr[1] ""
70 |
71 | msgid "should be at most %{count} character(s)"
72 | msgid_plural "should be at most %{count} character(s)"
73 | msgstr[0] ""
74 | msgstr[1] ""
75 |
76 | msgid "should have at most %{count} item(s)"
77 | msgid_plural "should have at most %{count} item(s)"
78 | msgstr[0] ""
79 | msgstr[1] ""
80 |
81 | ## From Ecto.Changeset.validate_number/3
82 | msgid "must be less than %{number}"
83 | msgstr ""
84 |
85 | msgid "must be greater than %{number}"
86 | msgstr ""
87 |
88 | msgid "must be less than or equal to %{number}"
89 | msgstr ""
90 |
91 | msgid "must be greater than or equal to %{number}"
92 | msgstr ""
93 |
94 | msgid "must be equal to %{number}"
95 | msgstr ""
96 |
--------------------------------------------------------------------------------
/lib/elixir_jobs/accounts/schemas/admin.ex:
--------------------------------------------------------------------------------
1 | defmodule ElixirJobs.Accounts.Schemas.Admin do
2 | @moduledoc """
3 | Admin schema
4 | """
5 |
6 | use Ecto.Schema
7 |
8 | import Ecto.Changeset
9 |
10 | @primary_key {:id, :binary_id, autogenerate: true}
11 | @foreign_key_type :binary_id
12 | schema "admins" do
13 | field :email, :string
14 | field :encrypted_password, :string
15 | field :name, :string
16 |
17 | field :password, :string, virtual: true
18 | field :password_confirmation, :string, virtual: true
19 |
20 | timestamps(type: :utc_datetime)
21 | end
22 |
23 | @doc false
24 | def changeset(admin, attrs) do
25 | admin
26 | |> cast(attrs, [:name, :email, :password, :password_confirmation])
27 | |> validate_required([:name, :email])
28 | |> validate_passwords()
29 | |> unique_constraint(:email)
30 | |> generate_passwords()
31 | end
32 |
33 | @doc """
34 | Function to check the `password` of a given `admin`.
35 | """
36 | def check_password(admin, password) do
37 | case Bcrypt.verify_pass(password, admin.encrypted_password) do
38 | true -> {:ok, admin}
39 | _ -> {:error, :wrong_credentials}
40 | end
41 | end
42 |
43 | @doc """
44 | Function to simulate checking a password to avoid time-based user discovery
45 | """
46 | def dummy_check_password do
47 | Bcrypt.no_user_verify()
48 | {:error, :wrong_credentials}
49 | end
50 |
51 | # Function to validate passwords only if they are changed or the admin is new.
52 | #
53 | defp validate_passwords(changeset) do
54 | current_password_hash = get_field(changeset, :encrypted_password)
55 | new_password = get_change(changeset, :password)
56 |
57 | case [current_password_hash, new_password] do
58 | [nil, _] ->
59 | changeset
60 | |> validate_required([:password, :password_confirmation])
61 | |> validate_confirmation(:password)
62 |
63 | [_, pass] when pass not in ["", nil] ->
64 | changeset
65 | |> validate_confirmation(:password, required: true)
66 |
67 | _ ->
68 | changeset
69 | end
70 | end
71 |
72 | # Function to generate password hash when creating/changing the password of an
73 | # admin account
74 | #
75 | defp generate_passwords(%Ecto.Changeset{errors: []} = changeset) do
76 | case get_field(changeset, :password) do
77 | password when password not in ["", nil] ->
78 | hash = Bcrypt.hash_pwd_salt(password)
79 | put_change(changeset, :encrypted_password, hash)
80 |
81 | _ ->
82 | changeset
83 | end
84 | end
85 |
86 | defp generate_passwords(changeset), do: changeset
87 | end
88 |
--------------------------------------------------------------------------------
/lib/elixir_jobs_web.ex:
--------------------------------------------------------------------------------
1 | defmodule ElixirJobsWeb do
2 | @moduledoc """
3 | The entrypoint for defining your web interface, such
4 | as controllers, views, channels and so on.
5 |
6 | This can be used in your application as:
7 |
8 | use ElixirJobsWeb, :controller
9 | use ElixirJobsWeb, :view
10 |
11 | The definitions below will be executed for every view,
12 | controller, etc, so keep them short and clean, focused
13 | on imports, uses and aliases.
14 |
15 | Do NOT define functions inside the quoted expressions
16 | below. Instead, define any helper function in modules
17 | and import those modules here.
18 | """
19 |
20 | def controller do
21 | quote do
22 | use Phoenix.Controller, namespace: ElixirJobsWeb
23 | import Plug.Conn
24 | import ElixirJobsWeb.Router.Helpers
25 | import ElixirJobsWeb.Gettext
26 |
27 | def user_logged_in?(conn), do: !is_nil(Map.get(conn.assigns, :current_user))
28 | end
29 | end
30 |
31 | def view do
32 | quote do
33 | use Phoenix.View,
34 | root: "lib/elixir_jobs_web/templates",
35 | pattern: "**/*",
36 | namespace: ElixirJobsWeb
37 |
38 | use Appsignal.Phoenix.View
39 |
40 | # Import convenience functions from controllers
41 | import Phoenix.Controller, only: [get_flash: 2, view_module: 1]
42 |
43 | # Use all HTML functionality (forms, tags, etc)
44 | use Phoenix.HTML
45 |
46 | import ElixirJobsWeb.Router.Helpers
47 | import ElixirJobsWeb.ErrorHelper
48 | import ElixirJobsWeb.ViewHelper
49 | import ElixirJobsWeb.Gettext
50 |
51 | alias ElixirJobsWeb.DateHelper
52 | alias ElixirJobsWeb.HumanizeHelper
53 |
54 | def user_logged_in?(conn), do: !is_nil(Map.get(conn.assigns, :current_user))
55 | end
56 | end
57 |
58 | def helper do
59 | quote do
60 | # Import convenience functions from controllers
61 | import Phoenix.Controller, only: [get_flash: 2, view_module: 1]
62 |
63 | # Use all HTML functionality (forms, tags, etc)
64 | use Phoenix.HTML
65 |
66 | import ElixirJobsWeb.Router.Helpers
67 | import ElixirJobsWeb.Gettext
68 | end
69 | end
70 |
71 | def router do
72 | quote do
73 | use Phoenix.Router
74 | import Plug.Conn
75 | import Phoenix.Controller
76 | end
77 | end
78 |
79 | def channel do
80 | quote do
81 | use Phoenix.Channel
82 | import ElixirJobsWeb.Gettext
83 | end
84 | end
85 |
86 | @doc """
87 | When used, dispatch to the appropriate controller/view/etc.
88 | """
89 | defmacro __using__(which) when is_atom(which) do
90 | apply(__MODULE__, which, [])
91 | end
92 | end
93 |
--------------------------------------------------------------------------------
/assets/webpack.config.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack');
2 | const path = require('path');
3 |
4 | const isProd = process.env.NODE_ENV === 'production';
5 | const isTest = process.env.NODE_ENV === 'test';
6 |
7 | const MiniCssExtractPlugin = require("mini-css-extract-plugin");
8 | const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
9 |
10 | const CopyWebpackPlugin = require('copy-webpack-plugin');
11 | const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
12 | const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
13 |
14 | const source_path = __dirname;
15 | const output_path = path.join(__dirname, '..', 'priv', 'static');
16 |
17 | const plugins = [
18 | new MiniCssExtractPlugin({
19 | filename: "css/[name].css"
20 | }),
21 | new CopyWebpackPlugin({
22 | patterns: [
23 | { from: path.join(source_path, 'static') }
24 | ]
25 | }),
26 | new webpack.ProvidePlugin({
27 | $: 'jquery',
28 | jQuery: 'jquery'
29 | })
30 | ];
31 |
32 | if (isTest) {
33 | plugins.push(
34 | new BundleAnalyzerPlugin()
35 | );
36 | }
37 |
38 | if (isProd) {
39 | plugins.push(
40 | new WebpackManifestPlugin({
41 | fileName: 'cache_manifest.json',
42 | basePath: source_path,
43 | publicPath: output_path
44 | })
45 | );
46 | };
47 |
48 | module.exports = {
49 | devtool: isProd ? false : 'eval-source-map',
50 | mode: isProd ? 'production' : 'development',
51 | performance: {
52 | hints: isTest ? 'warning' : false
53 | },
54 | plugins,
55 | context: source_path,
56 | entry: {
57 | app: [
58 | './css/app.scss',
59 | './js/app.js'
60 | ],
61 | },
62 |
63 | output: {
64 | path: output_path,
65 | filename: 'js/[name].js',
66 | chunkFilename: 'js/[name].js',
67 | publicPath: '/'
68 | },
69 |
70 | resolve: {
71 | modules: [
72 | 'deps',
73 | 'node_modules'
74 | ],
75 | extensions: ['.js', '.scss']
76 | },
77 |
78 | module: {
79 | rules: [{
80 | test: /\.js$/,
81 | exclude: /node_modules/,
82 | use: [
83 | 'babel-loader',
84 | ]
85 | },
86 | {
87 | test: /.s?css$/,
88 | use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"],
89 | },
90 | {
91 | test: /\.(woff2?|eot|ttf|otf|svg)(\?.*)?$/,
92 | loader: 'url-loader',
93 | options: {
94 | limit: 1000,
95 | name: 'fonts/[name].[hash:7].[ext]'
96 | }
97 | }]
98 | },
99 |
100 | optimization: {
101 | minimizer: [
102 | `...`,
103 | new CssMinimizerPlugin(),
104 | ],
105 | },
106 | };
107 |
--------------------------------------------------------------------------------
/lib/elixir_jobs/core/schemas/offer.ex:
--------------------------------------------------------------------------------
1 | defmodule ElixirJobs.Core.Schemas.Offer do
2 | @moduledoc """
3 | Offer schema.
4 | """
5 | use Ecto.Schema
6 | import Ecto.Changeset
7 |
8 | alias ElixirJobs.Core.Fields.JobPlace
9 | alias ElixirJobs.Core.Fields.JobType
10 |
11 | @primary_key {:id, :binary_id, autogenerate: true}
12 | @foreign_key_type :binary_id
13 |
14 | schema "offers" do
15 | field :title, :string
16 | field :company, :string
17 | field :location, :string
18 | field :url, :string
19 | field :slug, :string
20 | field :summary, :string
21 |
22 | field :job_place, JobPlace
23 | field :job_type, JobType
24 |
25 | field :contact_email, :string
26 |
27 | field :published_at, :utc_datetime
28 |
29 | timestamps(type: :utc_datetime)
30 | end
31 |
32 | @required_attrs [
33 | :title,
34 | :company,
35 | :contact_email,
36 | :location,
37 | :url,
38 | :job_place,
39 | :job_type,
40 | :summary
41 | ]
42 | @optional_attrs [:published_at, :slug]
43 |
44 | @email_regexp ~r/^[A-Za-z0-9._%+-+']+@[A-Za-z0-9.-]+\.[A-Za-z]+$/
45 | @url_regexp ~r/^\b((https?:\/\/?)[^\s()<>]+(?:\([\w\d]+\)|([^[:punct:]\s]|\/)))$/
46 |
47 | @doc false
48 | def changeset(offer, attrs) do
49 | offer
50 | |> cast(attrs, @required_attrs ++ @optional_attrs)
51 | |> validate_required(@required_attrs)
52 | |> validate_length(:title, min: 5, max: 50)
53 | |> validate_length(:company, min: 2, max: 30)
54 | |> validate_length(:summary, min: 10, max: 2000)
55 | |> validate_length(:location, min: 3, max: 50)
56 | |> validate_length(:url, min: 1, max: 255)
57 | |> validate_format(:url, @url_regexp)
58 | |> validate_length(:contact_email, min: 1, max: 255)
59 | |> validate_format(:contact_email, @email_regexp)
60 | |> validate_inclusion(:job_place, JobPlace.available_values())
61 | |> validate_inclusion(:job_type, JobType.available_values())
62 | |> unique_constraint(:slug)
63 | |> generate_slug()
64 | end
65 |
66 | defp generate_slug(changeset) do
67 | case get_field(changeset, :slug) do
68 | nil -> put_change(changeset, :slug, do_generate_slug(changeset))
69 | _ -> changeset
70 | end
71 | end
72 |
73 | defp do_generate_slug(changeset) do
74 | uid =
75 | Ecto.UUID.generate()
76 | |> to_string()
77 | |> String.split("-")
78 | |> List.first()
79 |
80 | title =
81 | changeset
82 | |> get_field(:title)
83 | |> Kernel.||("")
84 | |> Slugger.slugify_downcase()
85 |
86 | company =
87 | changeset
88 | |> get_field(:company)
89 | |> Kernel.||("")
90 | |> Slugger.slugify_downcase()
91 |
92 | "#{company}-#{title}-#{uid}"
93 | end
94 | end
95 |
--------------------------------------------------------------------------------
/assets/js/app/particles.js:
--------------------------------------------------------------------------------
1 | import "particles.js"
2 |
3 | function initParticles() {
4 | var has_hero = document.getElementsByClassName("hero main").length > 0;
5 |
6 | if (!has_hero) {
7 | return false;
8 | }
9 |
10 | particlesJS("particles", {
11 | "particles": {
12 | "number": {
13 | "value": 80,
14 | "density": {
15 | "enable": true,
16 | "value_area": 800
17 | }
18 | },
19 | "color": { "value": "#ffffff" },
20 | "shape": {
21 | "type": "circle",
22 | "stroke": {
23 | "width": 0,
24 | "color": "#000000"
25 | },
26 | "polygon": { "nb_sides": 5 },
27 | "image": {
28 | "src": "img/github.svg",
29 | "width": 100,
30 | "height": 100
31 | }
32 | },
33 | "opacity": {
34 | "value": 0.5,
35 | "random": false,
36 | "anim": {
37 | "enable": false,
38 | "speed": 1,
39 | "opacity_min": 0.1,
40 | "sync": false
41 | }
42 | },
43 | "size": {
44 | "value": 3,
45 | "random": true,
46 | "anim": {
47 | "enable": false,
48 | "speed": 40,
49 | "size_min": 0.1,
50 | "sync": false
51 | }
52 | },
53 | "line_linked": {
54 | "enable": true,
55 | "distance": 150,
56 | "color": "#ffffff",
57 | "opacity": 0.4,
58 | "width": 1
59 | },
60 | "move": {
61 | "enable": true,
62 | "speed": 0.7,
63 | "direction": "none",
64 | "random": false,
65 | "straight": false,
66 | "out_mode": "out",
67 | "bounce": false,
68 | "attract": {
69 | "enable": false,
70 | "rotateX": 600,
71 | "rotateY": 1200
72 | }
73 | }
74 | },
75 | "interactivity": {
76 | "detect_on": "canvas",
77 | "events": {
78 | "onhover": { "enable": false, "mode": "repulse" },
79 | "onclick": { "enable": false, "mode": "push" },
80 | "resize": true
81 | },
82 | "modes": {
83 | "grab": {
84 | "distance": 400,
85 | "line_linked": { "opacity": 1 }
86 | },
87 | "bubble": {
88 | "distance": 400,
89 | "size": 40,
90 | "duration": 2,
91 | "opacity": 8,
92 | "speed": 3
93 | },
94 | "repulse": { "distance": 200, "duration": 0.4 },
95 | "push": { "particles_nb": 4 },
96 | "remove": { "particles_nb": 2 }
97 | }
98 | },
99 | "retina_detect": true
100 | });
101 | }
102 |
103 | export { initParticles }
104 |
--------------------------------------------------------------------------------
/lib/elixir_jobs_web/templates/offer/show/_administration.html.eex:
--------------------------------------------------------------------------------
1 |
2 |