chore: Update framework version
This commit is contained in:
commit
5d3a8dbd16
212 changed files with 27958 additions and 21371 deletions
3
.dialyzer_ignore.exs
Normal file
3
.dialyzer_ignore.exs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
[
|
||||||
|
{"lib/i18n.ex:1:pattern_match_cov The pattern pattern <_@1, _@2, _@3> can never match the type, because it is covered by previous clauses."}
|
||||||
|
]
|
|
@ -2,3 +2,6 @@ cover
|
||||||
.elixir_ls
|
.elixir_ls
|
||||||
*.dump
|
*.dump
|
||||||
.journal-*
|
.journal-*
|
||||||
|
.git
|
||||||
|
|
||||||
|
.priv/mnesia*
|
||||||
|
|
10
.gitignore
vendored
10
.gitignore
vendored
|
@ -33,6 +33,9 @@ node_modules
|
||||||
# this depending on your deployment strategy.
|
# this depending on your deployment strategy.
|
||||||
/priv/static/
|
/priv/static/
|
||||||
|
|
||||||
|
# Temp files generated by tests
|
||||||
|
/apps/*/priv/test/
|
||||||
|
|
||||||
# Mnesia DBs
|
# Mnesia DBs
|
||||||
/apps/*/priv/mnesia*
|
/apps/*/priv/mnesia*
|
||||||
/priv/mnesia*
|
/priv/mnesia*
|
||||||
|
@ -44,3 +47,10 @@ config/*.secret.exs
|
||||||
|
|
||||||
# Temporary CI build file
|
# Temporary CI build file
|
||||||
build.env
|
build.env
|
||||||
|
|
||||||
|
# CI metrics file
|
||||||
|
metrics.txt
|
||||||
|
|
||||||
|
# Cypress Artifacts
|
||||||
|
|
||||||
|
/apps/*/cypress/videos/
|
||||||
|
|
|
@ -13,6 +13,9 @@ variables:
|
||||||
# fetch & clean the repo rather than completely cloning (faster)
|
# fetch & clean the repo rather than completely cloning (faster)
|
||||||
GIT_STRATEGY: fetch
|
GIT_STRATEGY: fetch
|
||||||
|
|
||||||
|
include:
|
||||||
|
- .gitlab/ci/.cypress.yml
|
||||||
|
|
||||||
# Test stage. Runs various tests and speculatively builds docker image in
|
# Test stage. Runs various tests and speculatively builds docker image in
|
||||||
# parallel, in case the build passes.
|
# parallel, in case the build passes.
|
||||||
.test_template: &test_template
|
.test_template: &test_template
|
||||||
|
@ -24,9 +27,11 @@ variables:
|
||||||
- deps/
|
- deps/
|
||||||
services:
|
services:
|
||||||
- name: postgres:12
|
- name: postgres:12
|
||||||
script: script/cibuild
|
script:
|
||||||
|
- apk add git
|
||||||
|
- script/cibuild
|
||||||
|
|
||||||
test:
|
test_1.10.4:
|
||||||
<<: *test_template
|
<<: *test_template
|
||||||
image: "elixir:1.10.4-alpine"
|
image: "elixir:1.10.4-alpine"
|
||||||
|
|
||||||
|
@ -34,9 +39,47 @@ test_1.11.4:
|
||||||
<<: *test_template
|
<<: *test_template
|
||||||
image: "elixir:1.11.4-alpine"
|
image: "elixir:1.11.4-alpine"
|
||||||
|
|
||||||
test_1.12.1:
|
test:
|
||||||
<<: *test_template
|
<<: *test_template
|
||||||
image: "elixir:1.12.1-alpine"
|
image: "elixir:1.12.1-alpine"
|
||||||
|
script:
|
||||||
|
- apk add git
|
||||||
|
- script/cibuild covered
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- cover/excoveralls.json
|
||||||
|
|
||||||
|
credo:
|
||||||
|
stage: test
|
||||||
|
image: "elixir:1.12.1-alpine"
|
||||||
|
cache:
|
||||||
|
key: "test_1.12.1"
|
||||||
|
paths:
|
||||||
|
- _build/
|
||||||
|
- deps/
|
||||||
|
script:
|
||||||
|
- apk add git
|
||||||
|
- mix local.hex --force
|
||||||
|
- mix local.rebar --force
|
||||||
|
- mix deps.get
|
||||||
|
- mix deps.compile
|
||||||
|
- mix credo --all
|
||||||
|
|
||||||
|
stylelint:
|
||||||
|
stage: test
|
||||||
|
image: "node:16"
|
||||||
|
script:
|
||||||
|
- cd apps/app/assets/
|
||||||
|
- npm install
|
||||||
|
- npx stylelint **/*.css
|
||||||
|
|
||||||
|
prettier:
|
||||||
|
stage: test
|
||||||
|
image: "node:16"
|
||||||
|
script:
|
||||||
|
- cd apps/app/assets/
|
||||||
|
- npm install
|
||||||
|
- npx prettier --check **/*.js
|
||||||
|
|
||||||
build_image_for_commit:
|
build_image_for_commit:
|
||||||
stage: test
|
stage: test
|
||||||
|
@ -56,10 +99,31 @@ build_image_for_commit:
|
||||||
script:
|
script:
|
||||||
- script/ci-docker-build
|
- script/ci-docker-build
|
||||||
|
|
||||||
|
report_coverage:
|
||||||
|
stage: deploy_tags
|
||||||
|
needs: ['test']
|
||||||
|
image: "elixir:1.12.1-alpine"
|
||||||
|
script:
|
||||||
|
- mix local.hex --force
|
||||||
|
- script/coverage-json-to-metrics
|
||||||
|
- script/coverage-json-to-cobertura
|
||||||
|
artifacts:
|
||||||
|
reports:
|
||||||
|
metrics: metrics.txt
|
||||||
|
cobertura: cover/cobertura.xml
|
||||||
|
|
||||||
# If tests pass, tag the commit and update package versions
|
# If tests pass, tag the commit and update package versions
|
||||||
deploy_to_tags:
|
deploy_to_tags:
|
||||||
stage: deploy_tags
|
stage: deploy_tags
|
||||||
needs: ['test', 'build_image_for_commit']
|
needs:
|
||||||
|
- test
|
||||||
|
- test_1.10.4
|
||||||
|
- test_1.11.4
|
||||||
|
- build_image_for_commit
|
||||||
|
- cypress
|
||||||
|
- credo
|
||||||
|
- prettier
|
||||||
|
- stylelint
|
||||||
image: "node:16"
|
image: "node:16"
|
||||||
only:
|
only:
|
||||||
- master
|
- master
|
||||||
|
@ -116,7 +180,7 @@ deploy_commit_image_to_tag:
|
||||||
|
|
||||||
.dependabot_gitlab: &dependabot_gitlab
|
.dependabot_gitlab: &dependabot_gitlab
|
||||||
image:
|
image:
|
||||||
name: docker.io/andrcuns/dependabot-gitlab:0.4.3
|
name: docker.io/andrcuns/dependabot-gitlab:0.9.7
|
||||||
entrypoint: [""]
|
entrypoint: [""]
|
||||||
variables:
|
variables:
|
||||||
GIT_STRATEGY: none
|
GIT_STRATEGY: none
|
||||||
|
@ -150,6 +214,7 @@ npm-assets:
|
||||||
- $PACKAGE_MANAGER_SET =~ /(\bnpm|yarn\b)/
|
- $PACKAGE_MANAGER_SET =~ /(\bnpm|yarn\b)/
|
||||||
|
|
||||||
mix-admin:
|
mix-admin:
|
||||||
|
timeout: 3 hours 30 minutes # hopefully temporary hack because hex runs slowly in docker right now
|
||||||
extends: .dependabot_gitlab
|
extends: .dependabot_gitlab
|
||||||
variables:
|
variables:
|
||||||
DIRECTORY: "/apps/admin"
|
DIRECTORY: "/apps/admin"
|
||||||
|
@ -159,6 +224,7 @@ mix-admin:
|
||||||
- $PACKAGE_MANAGER_SET =~ /\bmix\b/
|
- $PACKAGE_MANAGER_SET =~ /\bmix\b/
|
||||||
|
|
||||||
mix-app:
|
mix-app:
|
||||||
|
timeout: 3 hours 30 minutes # hopefully temporary hack because hex runs slowly in docker right now
|
||||||
extends: .dependabot_gitlab
|
extends: .dependabot_gitlab
|
||||||
variables:
|
variables:
|
||||||
DIRECTORY: "/apps/app"
|
DIRECTORY: "/apps/app"
|
||||||
|
@ -168,6 +234,7 @@ mix-app:
|
||||||
- $PACKAGE_MANAGER_SET =~ /\bmix\b/
|
- $PACKAGE_MANAGER_SET =~ /\bmix\b/
|
||||||
|
|
||||||
mix-core:
|
mix-core:
|
||||||
|
timeout: 3 hours 30 minutes # hopefully temporary hack because hex runs slowly in docker right now
|
||||||
extends: .dependabot_gitlab
|
extends: .dependabot_gitlab
|
||||||
variables:
|
variables:
|
||||||
DIRECTORY: "/apps/core"
|
DIRECTORY: "/apps/core"
|
||||||
|
@ -177,6 +244,7 @@ mix-core:
|
||||||
- $PACKAGE_MANAGER_SET =~ /\bmix\b/
|
- $PACKAGE_MANAGER_SET =~ /\bmix\b/
|
||||||
|
|
||||||
mix-content:
|
mix-content:
|
||||||
|
timeout: 3 hours 30 minutes # hopefully temporary hack because hex runs slowly in docker right now
|
||||||
extends: .dependabot_gitlab
|
extends: .dependabot_gitlab
|
||||||
variables:
|
variables:
|
||||||
DIRECTORY: "/apps/content"
|
DIRECTORY: "/apps/content"
|
||||||
|
|
24
.gitlab/ci/.cypress.yml
Normal file
24
.gitlab/ci/.cypress.yml
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
cypress:
|
||||||
|
stage: test
|
||||||
|
image: cypress/browsers:node14.17.0-chrome91-ff89
|
||||||
|
variables:
|
||||||
|
MIX_ENV: e2e
|
||||||
|
cache:
|
||||||
|
key: $CI_JOB_NAME
|
||||||
|
paths:
|
||||||
|
- _build/
|
||||||
|
- deps/
|
||||||
|
- node_modules/
|
||||||
|
- .npm/
|
||||||
|
artifacts:
|
||||||
|
when: always
|
||||||
|
paths:
|
||||||
|
- apps/*/cypress/videos/**/*.mp4
|
||||||
|
- apps/*/cypress/screenshots/**/*.png
|
||||||
|
expire_in: 1 day
|
||||||
|
services:
|
||||||
|
- name: postgres:12
|
||||||
|
before_script:
|
||||||
|
- script/cypress/prepare
|
||||||
|
script:
|
||||||
|
- npm run test:integration
|
|
@ -1,3 +1,3 @@
|
||||||
erlang 23.0.2
|
erlang 23.0.2
|
||||||
elixir 1.12.1
|
elixir 1.12.1
|
||||||
nodejs 14.5.0
|
nodejs 16.10.0
|
||||||
|
|
1
Brewfile
1
Brewfile
|
@ -10,7 +10,6 @@ brew "libyaml"
|
||||||
brew "openssl@1.1"
|
brew "openssl@1.1"
|
||||||
brew "readline"
|
brew "readline"
|
||||||
brew "unixodbc"
|
brew "unixodbc"
|
||||||
brew "asdf"
|
|
||||||
brew "curl"
|
brew "curl"
|
||||||
brew "fop"
|
brew "fop"
|
||||||
brew "gnupg"
|
brew "gnupg"
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
FROM elixir:1.12.2-alpine AS elixir-builder
|
FROM elixir:1.12.3-alpine AS elixir-builder
|
||||||
|
|
||||||
|
RUN apk add git
|
||||||
|
|
||||||
RUN mix local.hex --force \
|
RUN mix local.hex --force \
|
||||||
&& mix local.rebar --force
|
&& mix local.rebar --force
|
||||||
|
@ -16,6 +18,7 @@ WORKDIR /root/app
|
||||||
|
|
||||||
# We load these things one by one so that we can load the deps first and
|
# We load these things one by one so that we can load the deps first and
|
||||||
# cache those layers, before we do the app build itself
|
# cache those layers, before we do the app build itself
|
||||||
|
ADD ./priv ./priv
|
||||||
ADD ./mix.exs ./mix.lock ./
|
ADD ./mix.exs ./mix.lock ./
|
||||||
ADD ./config ./config
|
ADD ./config ./config
|
||||||
ADD ./apps/admin/mix.exs ./apps/admin/
|
ADD ./apps/admin/mix.exs ./apps/admin/
|
||||||
|
@ -27,7 +30,7 @@ RUN mix deps.get
|
||||||
|
|
||||||
# Leave off here so that we can built assets and compile the elixir app in parallel
|
# Leave off here so that we can built assets and compile the elixir app in parallel
|
||||||
|
|
||||||
FROM node:16.5.0 AS asset-builder
|
FROM node:16.10.0 AS asset-builder
|
||||||
|
|
||||||
# Build assets in a node container
|
# Build assets in a node container
|
||||||
ADD ./apps/app/assets/ /root/app/apps/app/assets/
|
ADD ./apps/app/assets/ /root/app/apps/app/assets/
|
||||||
|
|
3
apps/admin/cypress.json
Normal file
3
apps/admin/cypress.json
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"baseUrl": "http://localhost:4002"
|
||||||
|
}
|
5
apps/admin/cypress/integration/example_spec.js
Normal file
5
apps/admin/cypress/integration/example_spec.js
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
describe('My First Test', () => {
|
||||||
|
it('Does not do much!', () => {
|
||||||
|
expect(true).to.equal(true)
|
||||||
|
})
|
||||||
|
})
|
22
apps/admin/cypress/plugins/index.js
Normal file
22
apps/admin/cypress/plugins/index.js
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
/// <reference types="cypress" />
|
||||||
|
// ***********************************************************
|
||||||
|
// This example plugins/index.js can be used to load plugins
|
||||||
|
//
|
||||||
|
// You can change the location of this file or turn off loading
|
||||||
|
// the plugins file with the 'pluginsFile' configuration option.
|
||||||
|
//
|
||||||
|
// You can read more here:
|
||||||
|
// https://on.cypress.io/plugins-guide
|
||||||
|
// ***********************************************************
|
||||||
|
|
||||||
|
// This function is called when a project is opened or re-opened (e.g. due to
|
||||||
|
// the project's config changing)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Cypress.PluginConfig}
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
module.exports = (on, config) => {
|
||||||
|
// `on` is used to hook into various events Cypress emits
|
||||||
|
// `config` is the resolved Cypress config
|
||||||
|
}
|
36
apps/admin/cypress/support/commands.js
Normal file
36
apps/admin/cypress/support/commands.js
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
// ***********************************************
|
||||||
|
// This example commands.js shows you how to
|
||||||
|
// create various custom commands and overwrite
|
||||||
|
// existing commands.
|
||||||
|
//
|
||||||
|
// For more comprehensive examples of custom
|
||||||
|
// commands please read more here:
|
||||||
|
// https://on.cypress.io/custom-commands
|
||||||
|
// ***********************************************
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a parent command --
|
||||||
|
// Cypress.Commands.add('login', (email, password) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a child command --
|
||||||
|
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a dual command --
|
||||||
|
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This will overwrite an existing command --
|
||||||
|
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||||
|
|
||||||
|
Cypress.Commands.add("setupDB", (app, seedSet) => {
|
||||||
|
cy.request('POST', '/end-to-end/db/setup', {
|
||||||
|
app,
|
||||||
|
seed_set: seedSet,
|
||||||
|
}).as('setupDB')
|
||||||
|
})
|
||||||
|
|
||||||
|
Cypress.Commands.add("teardownDB", () => {
|
||||||
|
cy.request('POST', '/end-to-end/db/teardown').as('teardownDB')
|
||||||
|
})
|
29
apps/admin/cypress/support/index.js
Normal file
29
apps/admin/cypress/support/index.js
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
// ***********************************************************
|
||||||
|
// This example support/index.js is processed and
|
||||||
|
// loaded automatically before your test files.
|
||||||
|
//
|
||||||
|
// This is a great place to put global configuration and
|
||||||
|
// behavior that modifies Cypress.
|
||||||
|
//
|
||||||
|
// You can change the location of this file or turn off
|
||||||
|
// automatically serving support files with the
|
||||||
|
// 'supportFile' configuration option.
|
||||||
|
//
|
||||||
|
// You can read more here:
|
||||||
|
// https://on.cypress.io/configuration
|
||||||
|
// ***********************************************************
|
||||||
|
|
||||||
|
// Import commands.js using ES2015 syntax:
|
||||||
|
import './commands'
|
||||||
|
|
||||||
|
// Alternatively you can use CommonJS syntax:
|
||||||
|
// require('./commands')
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
// Make sure we don't have a rogue DB connection checked out
|
||||||
|
cy.teardownDB()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cy.teardownDB()
|
||||||
|
})
|
|
@ -2,6 +2,7 @@ defmodule Kaffy.ResourceQuery do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
import Ecto.Query.API, only: [field: 2]
|
||||||
|
|
||||||
def list_resource(conn, resource, params \\ %{}) do
|
def list_resource(conn, resource, params \\ %{}) do
|
||||||
per_page = Map.get(params, "limit", "100") |> String.to_integer()
|
per_page = Map.get(params, "limit", "100") |> String.to_integer()
|
||||||
|
|
|
@ -31,7 +31,7 @@ defmodule Kaffy.MixProject do
|
||||||
defp deps do
|
defp deps do
|
||||||
[
|
[
|
||||||
{:phoenix, "~> 1.4"},
|
{:phoenix, "~> 1.4"},
|
||||||
{:phoenix_html, "~> 2.11"},
|
{:phoenix_html, "~> 2.11 or ~> 3.0"},
|
||||||
{:ecto, "~> 3.0"},
|
{:ecto, "~> 3.0"},
|
||||||
{:ex_doc, ">= 0.0.0", only: :dev, runtime: false}
|
{:ex_doc, ">= 0.0.0", only: :dev, runtime: false}
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,4 +1,9 @@
|
||||||
defmodule Legendary.Admin.Routes do
|
defmodule Legendary.Admin.Routes do
|
||||||
|
@moduledoc """
|
||||||
|
Routes from the admin app. Use like:
|
||||||
|
|
||||||
|
use Legendary.Admin.Routes
|
||||||
|
"""
|
||||||
defmacro __using__(_opts \\ []) do
|
defmacro __using__(_opts \\ []) do
|
||||||
quote do
|
quote do
|
||||||
use Kaffy.Routes, scope: "/admin", pipe_through: [:require_admin]
|
use Kaffy.Routes, scope: "/admin", pipe_through: [:require_admin]
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
defmodule Legendary.Admin.Telemetry do
|
defmodule Legendary.Admin.Telemetry do
|
||||||
|
@moduledoc """
|
||||||
|
Collect metrics from the admin app.
|
||||||
|
"""
|
||||||
|
|
||||||
use Supervisor
|
use Supervisor
|
||||||
import Telemetry.Metrics
|
import Telemetry.Metrics
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
defmodule Legendary.Admin.Kaffy.Config do
|
defmodule Legendary.Admin.Kaffy.Config do
|
||||||
|
@moduledoc """
|
||||||
|
Pull in the resource list for the admin from the application config.
|
||||||
|
"""
|
||||||
|
|
||||||
def create_resources(_conn) do
|
def create_resources(_conn) do
|
||||||
config = Application.get_env(:admin, Legendary.Admin)
|
config = Application.get_env(:admin, Legendary.Admin)
|
||||||
|
|
||||||
|
|
|
@ -1,14 +1,22 @@
|
||||||
defmodule Legendary.Admin.Kaffy.EditorExtension do
|
defmodule Legendary.Admin.Kaffy.EditorExtension do
|
||||||
|
@moduledoc """
|
||||||
|
Bring in additional CSS and JS for the admin interface e.g. the
|
||||||
|
markdown editor library.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Phoenix.HTML.Tag, only: [tag: 2]
|
||||||
|
|
||||||
def stylesheets(_conn) do
|
def stylesheets(_conn) do
|
||||||
[
|
[
|
||||||
{:safe, ~s(<link rel="stylesheet" href="/css/content-editor.css" />)},
|
{:safe, ~s(<link rel="stylesheet" href="/css/admin.css" />)},
|
||||||
{:safe, ~s(<link rel="stylesheet" href="/css/app.css" />)},
|
{:safe, ~s(<link rel="stylesheet" href="/css/app.css" />)},
|
||||||
|
tag(:meta, property: "og:site_name", content: Legendary.I18n.t!("en", "site.title"))
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
def javascripts(_conn) do
|
def javascripts(_conn) do
|
||||||
[
|
[
|
||||||
{:safe, ~s(<script src="/js/content-editor.js"></script>)},
|
{:safe, ~s(<script src="/js/admin.js"></script>)},
|
||||||
{:safe, ~s(<script src="/js/app.js"></script>)},
|
{:safe, ~s(<script src="/js/app.js"></script>)},
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
defmodule Legendary.Admin.MixProject do
|
defmodule Legendary.Admin.MixProject do
|
||||||
use Mix.Project
|
use Mix.Project
|
||||||
|
|
||||||
@version "2.11.5"
|
@version "4.3.0"
|
||||||
|
|
||||||
def project do
|
def project do
|
||||||
[
|
[
|
||||||
|
@ -12,7 +12,7 @@ defmodule Legendary.Admin.MixProject do
|
||||||
config_path: "../../config/config.exs",
|
config_path: "../../config/config.exs",
|
||||||
deps_path: "../../deps",
|
deps_path: "../../deps",
|
||||||
lockfile: "../../mix.lock",
|
lockfile: "../../mix.lock",
|
||||||
elixir: "~> 1.7",
|
elixir: "~> 1.10",
|
||||||
elixirc_paths: elixirc_paths(Mix.env()),
|
elixirc_paths: elixirc_paths(Mix.env()),
|
||||||
compilers: [:phoenix, :gettext] ++ Mix.compilers(),
|
compilers: [:phoenix, :gettext] ++ Mix.compilers(),
|
||||||
start_permanent: Mix.env() == :prod,
|
start_permanent: Mix.env() == :prod,
|
||||||
|
@ -43,17 +43,17 @@ defmodule Legendary.Admin.MixProject do
|
||||||
defp deps do
|
defp deps do
|
||||||
[
|
[
|
||||||
{:core, in_umbrella: true},
|
{:core, in_umbrella: true},
|
||||||
{:ecto_sql, "~> 3.4"},
|
{:ecto_sql, "~> 3.7"},
|
||||||
{:excoveralls, "~> 0.10", only: [:dev, :test]},
|
{:excoveralls, "~> 0.10", only: [:dev, :test]},
|
||||||
{:kaffy, path: "kaffy"},
|
{:kaffy, path: "kaffy"},
|
||||||
{:phoenix, "~> 1.5.8"},
|
{:phoenix, "~> 1.6.0"},
|
||||||
{:phoenix_ecto, "~> 4.0"},
|
{:phoenix_ecto, "~> 4.4"},
|
||||||
{:phoenix_html, "~> 2.11"},
|
{:phoenix_html, "~> 3.0.4", override: true},
|
||||||
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
{:phoenix_live_reload, "~> 1.3", only: :dev},
|
||||||
{:phoenix_live_dashboard, "~> 0.4.0"},
|
{:phoenix_live_dashboard, "~> 0.5.0"},
|
||||||
{:postgrex, ">= 0.0.0"},
|
{:postgrex, ">= 0.0.0"},
|
||||||
{:telemetry_metrics, "~> 0.4"},
|
{:telemetry_metrics, "~> 0.4"},
|
||||||
{:telemetry_poller, "~> 0.4"},
|
{:telemetry_poller, "~> 1.0"},
|
||||||
{:gettext, "~> 0.11"},
|
{:gettext, "~> 0.11"},
|
||||||
{:jason, "~> 1.0"},
|
{:jason, "~> 1.0"},
|
||||||
{:plug_cowboy, "~> 2.0"}
|
{:plug_cowboy, "~> 2.0"}
|
||||||
|
|
0
apps/admin/test/seed_sets/.keep
Normal file
0
apps/admin/test/seed_sets/.keep
Normal file
|
@ -1,5 +1,3 @@
|
||||||
{
|
{
|
||||||
"presets": [
|
"presets": ["@babel/preset-env"]
|
||||||
"@babel/preset-env"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
node_modules
|
node_modules
|
||||||
|
css/phoenix.css
|
||||||
|
|
5
apps/app/assets/css/admin.css
Normal file
5
apps/app/assets/css/admin.css
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
@import "content-editor-overrides";
|
||||||
|
|
||||||
|
.social-media-preview-image.social-media-preview-image {
|
||||||
|
border-radius: 16px !important;
|
||||||
|
}
|
|
@ -1,11 +1,7 @@
|
||||||
@import "tailwindcss/base";
|
@import "tailwindcss/base";
|
||||||
|
|
||||||
@import "tailwindcss/components";
|
@import "tailwindcss/components";
|
||||||
|
|
||||||
@import "tailwindcss/utilities";
|
@import "tailwindcss/utilities";
|
||||||
|
|
||||||
@import "@fortawesome/fontawesome-free/css/all.css";
|
@import "@fortawesome/fontawesome-free/css/all.css";
|
||||||
|
|
||||||
@import "blog";
|
@import "blog";
|
||||||
@import "code";
|
@import "code";
|
||||||
|
|
||||||
|
@ -14,27 +10,28 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="checkbox"]::after {
|
input[type="checkbox"]::after {
|
||||||
content: "";
|
display: inline-flex;
|
||||||
color: currentColor;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: inline-flex;
|
color: currentColor;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
content: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="checkbox"]:checked::after {
|
input[type="checkbox"]:checked::after {
|
||||||
content: "✓";
|
content: "✓";
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden-options-toggle:checked+.hidden {
|
.hidden-options-toggle:checked + .hidden {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.password-revealer {
|
.password-revealer {
|
||||||
@apply absolute;
|
@apply absolute;
|
||||||
height: 45px;
|
|
||||||
width: 45px;
|
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
width: 45px;
|
||||||
|
height: 45px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -57,7 +57,7 @@
|
||||||
|
|
||||||
.hljs {
|
.hljs {
|
||||||
@apply mt-6 mb-12 border-l-4 border-indigo-900 font-mono;
|
@apply mt-6 mb-12 border-l-4 border-indigo-900 font-mono;
|
||||||
--background-color: theme("colors.indigo.100");
|
--background-color: theme("colors.indigo.100"); /* stylelint-disable-line custom-property-empty-line-before */
|
||||||
--attribute-color: theme("colors.orange.700");
|
--attribute-color: theme("colors.orange.700");
|
||||||
--section-color: theme("colors.teal.700");
|
--section-color: theme("colors.teal.700");
|
||||||
--string-color: theme("colors.green.700");
|
--string-color: theme("colors.green.700");
|
||||||
|
|
4
apps/app/assets/js/admin.js
Normal file
4
apps/app/assets/js/admin.js
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
import "../css/admin.css";
|
||||||
|
|
||||||
|
import "./admin/content-editor";
|
||||||
|
import "./admin/preview-image";
|
33
apps/app/assets/js/admin/content-editor.js
Normal file
33
apps/app/assets/js/admin/content-editor.js
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import { ready } from "../utils";
|
||||||
|
import SimpleMDE from "simplemde";
|
||||||
|
import "simplemde/dist/simplemde.min.css";
|
||||||
|
import "../../css/content-editor-overrides.css";
|
||||||
|
|
||||||
|
const requestPreview = (plainText, previewContainer) => {
|
||||||
|
let request = new XMLHttpRequest();
|
||||||
|
const postForm = previewContainer.closest("form");
|
||||||
|
let formData = new FormData(postForm);
|
||||||
|
|
||||||
|
formData.set("post[content]", plainText);
|
||||||
|
|
||||||
|
request.addEventListener("load", function (event) {
|
||||||
|
previewContainer.innerHTML = event.target.responseText;
|
||||||
|
});
|
||||||
|
|
||||||
|
request.open("POST", "/posts/preview", true);
|
||||||
|
|
||||||
|
request.send(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
ready(() => {
|
||||||
|
document.querySelectorAll("[data-simplemde]").forEach((el) => {
|
||||||
|
new SimpleMDE({
|
||||||
|
element: el,
|
||||||
|
previewRender: (plainText, previewContainer) => {
|
||||||
|
requestPreview(plainText, previewContainer);
|
||||||
|
|
||||||
|
return previewContainer.innerHTML;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
140
apps/app/assets/js/admin/preview-image.js
Normal file
140
apps/app/assets/js/admin/preview-image.js
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
import { ready } from "../utils";
|
||||||
|
import { fabric } from "fabric";
|
||||||
|
|
||||||
|
fabric.Object.prototype.objectCaching = false;
|
||||||
|
|
||||||
|
const textboxDefaults = {
|
||||||
|
lockMovementX: true,
|
||||||
|
lockMovementY: true,
|
||||||
|
lockScalingX: true,
|
||||||
|
lockScalingY: true,
|
||||||
|
lockSkewingX: true,
|
||||||
|
lockSkewingY: true,
|
||||||
|
lockRotation: true,
|
||||||
|
lockUniScaling: true,
|
||||||
|
hasControls: false,
|
||||||
|
selectable: true,
|
||||||
|
fontFamily:
|
||||||
|
"system-ui,-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji'",
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDataURL = (canvas) => {
|
||||||
|
canvas.renderAll();
|
||||||
|
const data = canvas.toDataURL({
|
||||||
|
enableRetinaScaling: true,
|
||||||
|
});
|
||||||
|
canvas.dataURLInput.value = data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTextboxValue = (canvas, textbox, value) => {
|
||||||
|
textbox.set({ text: value });
|
||||||
|
updateDataURL(canvas);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTextboxFromEvent = (canvas, textbox, { target }) => {
|
||||||
|
setTextboxValue(canvas, textbox, target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeLinkedTextbox = (canvas, selector, opts) => {
|
||||||
|
const box = new fabric.Textbox("", {
|
||||||
|
...textboxDefaults,
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
box.on("input", updateDataURL.bind(box, canvas));
|
||||||
|
|
||||||
|
var input = document.querySelector(selector);
|
||||||
|
|
||||||
|
canvas.add(box);
|
||||||
|
|
||||||
|
setTextboxValue(canvas, box, input.value);
|
||||||
|
input.addEventListener("input", setTextboxFromEvent.bind(input, canvas, box));
|
||||||
|
|
||||||
|
return box;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeStaticTextbox = (canvas, value, opts) => {
|
||||||
|
const box = new fabric.Textbox(value, {
|
||||||
|
...textboxDefaults,
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
box.on("input", updateDataURL.bind(box, canvas));
|
||||||
|
|
||||||
|
canvas.add(box);
|
||||||
|
|
||||||
|
return box;
|
||||||
|
};
|
||||||
|
|
||||||
|
const prepareCanvas = (input, canvasElement) => {
|
||||||
|
const inputContainer = input.parentElement;
|
||||||
|
|
||||||
|
input.setAttribute("type", "hidden");
|
||||||
|
|
||||||
|
canvasElement.setAttribute(
|
||||||
|
"class",
|
||||||
|
"social-media-preview-image rounded-lg border-2 border-gray-300"
|
||||||
|
);
|
||||||
|
canvasElement.setAttribute("width", 800);
|
||||||
|
canvasElement.setAttribute("height", 418);
|
||||||
|
inputContainer.appendChild(canvasElement);
|
||||||
|
|
||||||
|
const canvas = new fabric.Canvas(canvasElement);
|
||||||
|
canvas.dataURLInput = input;
|
||||||
|
|
||||||
|
return canvas;
|
||||||
|
};
|
||||||
|
|
||||||
|
ready(() => {
|
||||||
|
const input = document.querySelector(
|
||||||
|
"[name='post[social_media_preview_image]']"
|
||||||
|
);
|
||||||
|
const canvasElement = document.createElement("canvas");
|
||||||
|
const canvas = prepareCanvas(input, canvasElement);
|
||||||
|
|
||||||
|
fabric.Image.fromURL(
|
||||||
|
"/images/social-media-preview-background.png",
|
||||||
|
function (oImg) {
|
||||||
|
oImg.selectable = false;
|
||||||
|
canvas.add(oImg);
|
||||||
|
|
||||||
|
const title = makeLinkedTextbox(canvas, "[name='post[title]']", {
|
||||||
|
left: 80,
|
||||||
|
top: 80,
|
||||||
|
width: 640,
|
||||||
|
fontWeight: "bold",
|
||||||
|
fontSize: 36,
|
||||||
|
});
|
||||||
|
|
||||||
|
makeLinkedTextbox(canvas, "[name='post[excerpt]']", {
|
||||||
|
left: 80,
|
||||||
|
width: 560,
|
||||||
|
top: title.aCoords.bl.y + 20,
|
||||||
|
fill: "#4B5563",
|
||||||
|
fontSize: 18,
|
||||||
|
});
|
||||||
|
|
||||||
|
var name = document
|
||||||
|
.querySelector("[property='og:site_name']")
|
||||||
|
.getAttribute("content");
|
||||||
|
makeStaticTextbox(canvas, name, {
|
||||||
|
left: 80,
|
||||||
|
width: 560,
|
||||||
|
top: 48,
|
||||||
|
fill: "#F87171",
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "bold",
|
||||||
|
});
|
||||||
|
|
||||||
|
var rect = new fabric.Rect({
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
fill: "#F87171",
|
||||||
|
width: 14,
|
||||||
|
height: 418,
|
||||||
|
selectable: false,
|
||||||
|
});
|
||||||
|
canvas.add(rect);
|
||||||
|
|
||||||
|
updateDataURL(canvas);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
|
@ -1,7 +1,7 @@
|
||||||
// We need to import the CSS so that webpack will load it.
|
// We need to import the CSS so that webpack will load it.
|
||||||
// The MiniCssExtractPlugin is used to separate it out into
|
// The MiniCssExtractPlugin is used to separate it out into
|
||||||
// its own CSS file.
|
// its own CSS file.
|
||||||
import "../css/app.css"
|
import "../css/app.css";
|
||||||
|
|
||||||
// webpack automatically bundles all modules in your
|
// webpack automatically bundles all modules in your
|
||||||
// entry points. Those entry points can be configured
|
// entry points. Those entry points can be configured
|
||||||
|
@ -12,48 +12,46 @@ import "../css/app.css"
|
||||||
// import {Socket} from "phoenix"
|
// import {Socket} from "phoenix"
|
||||||
// import socket from "./socket"
|
// import socket from "./socket"
|
||||||
//
|
//
|
||||||
import "phoenix_html"
|
import "phoenix_html";
|
||||||
import "alpinejs"
|
import "alpinejs";
|
||||||
import "./live"
|
import "./live";
|
||||||
import { ready } from "./utils"
|
import { ready } from "./utils";
|
||||||
|
|
||||||
function togglePasswordFieldVisibility()
|
var bloop;
|
||||||
{
|
|
||||||
const passwordFields = document.querySelectorAll('[name="user[password]"]')
|
function togglePasswordFieldVisibility() {
|
||||||
|
const passwordFields = document.querySelectorAll('[name="user[password]"]');
|
||||||
passwordFields.forEach((el) => {
|
passwordFields.forEach((el) => {
|
||||||
if (el.type == 'password')
|
if (el.type == "password") {
|
||||||
{
|
el.type = "text";
|
||||||
el.type = 'text'
|
} else {
|
||||||
|
el.type = "password";
|
||||||
}
|
}
|
||||||
else
|
});
|
||||||
{
|
|
||||||
el.type = 'password'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleSidebar = (event) => {
|
const toggleSidebar = (event) => {
|
||||||
document.querySelectorAll('.sidebar').forEach((el) => {
|
document.querySelectorAll(".sidebar").forEach((el) => {
|
||||||
el.classList.toggle('visible')
|
el.classList.toggle("visible");
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
ready(() => {
|
ready(() => {
|
||||||
(document.getElementById('nav-toggle') ||{}).onclick = function(){
|
(document.getElementById("nav-toggle") || {}).onclick = function () {
|
||||||
document.getElementById("nav-content").classList.toggle("hidden");
|
document.getElementById("nav-content").classList.toggle("hidden");
|
||||||
}
|
};
|
||||||
|
|
||||||
document.querySelectorAll('.js-passwordRevealer').forEach((el) => {
|
document.querySelectorAll(".js-passwordRevealer").forEach((el) => {
|
||||||
el.addEventListener('click', togglePasswordFieldVisibility)
|
el.addEventListener("click", togglePasswordFieldVisibility);
|
||||||
})
|
});
|
||||||
|
|
||||||
document.querySelectorAll('.js-SidebarOpener').forEach((el) => {
|
document.querySelectorAll(".js-SidebarOpener").forEach((el) => {
|
||||||
el.addEventListener('click', toggleSidebar)
|
el.addEventListener("click", toggleSidebar);
|
||||||
})
|
});
|
||||||
|
|
||||||
document.querySelectorAll('.js-flash-closer').forEach((el) => {
|
document.querySelectorAll(".js-flash-closer").forEach((el) => {
|
||||||
el.addEventListener('click', () => {
|
el.addEventListener("click", () => {
|
||||||
el.closest('.js-flash').remove()
|
el.closest(".js-flash").remove();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
|
@ -1,34 +0,0 @@
|
||||||
import { ready } from "./utils"
|
|
||||||
import SimpleMDE from "simplemde"
|
|
||||||
import "simplemde/dist/simplemde.min.css"
|
|
||||||
import "../css/content-editor-overrides.css"
|
|
||||||
|
|
||||||
|
|
||||||
const requestPreview = (plainText, previewContainer) => {
|
|
||||||
let request = new XMLHttpRequest()
|
|
||||||
const postForm = previewContainer.closest('form')
|
|
||||||
let formData = new FormData(postForm)
|
|
||||||
|
|
||||||
formData.set('post[content]', plainText)
|
|
||||||
|
|
||||||
request.addEventListener('load', function(event) {
|
|
||||||
previewContainer.innerHTML = event.target.responseText
|
|
||||||
})
|
|
||||||
|
|
||||||
request.open('POST', '/pages/posts/preview', true)
|
|
||||||
|
|
||||||
request.send(formData)
|
|
||||||
}
|
|
||||||
|
|
||||||
ready(() => {
|
|
||||||
document.querySelectorAll('[data-simplemde]').forEach(el => {
|
|
||||||
new SimpleMDE({
|
|
||||||
element: el,
|
|
||||||
previewRender: (plainText, previewContainer) => {
|
|
||||||
requestPreview(plainText, previewContainer)
|
|
||||||
|
|
||||||
return previewContainer.innerHTML
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
|
@ -1,18 +1,25 @@
|
||||||
import {Socket} from "phoenix"
|
import { Socket } from "phoenix";
|
||||||
import LiveSocket from "phoenix_live_view"
|
import { LiveSocket } from "phoenix_live_view";
|
||||||
import topbar from "topbar"
|
import topbar from "topbar";
|
||||||
|
|
||||||
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
let csrfToken = document
|
||||||
|
.querySelector("meta[name='csrf-token']")
|
||||||
|
.getAttribute("content");
|
||||||
|
|
||||||
let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}})
|
let liveSocket = new LiveSocket("/live", Socket, {
|
||||||
|
params: { _csrf_token: csrfToken },
|
||||||
|
});
|
||||||
|
|
||||||
// Show progress bar on live navigation and form submits
|
// Show progress bar on live navigation and form submits
|
||||||
topbar.config({barColors: {0: "#3B82F6"}, shadowColor: "rgba(0, 0, 0, .3)"})
|
topbar.config({
|
||||||
window.addEventListener("phx:page-loading-start", info => topbar.show())
|
barColors: { 0: "#3B82F6" },
|
||||||
window.addEventListener("phx:page-loading-stop", info => topbar.hide())
|
shadowColor: "rgba(0, 0, 0, .3)",
|
||||||
|
});
|
||||||
|
window.addEventListener("phx:page-loading-start", (info) => topbar.show());
|
||||||
|
window.addEventListener("phx:page-loading-stop", (info) => topbar.hide());
|
||||||
|
|
||||||
// Connect if there are any LiveViews on the page
|
// Connect if there are any LiveViews on the page
|
||||||
liveSocket.connect()
|
liveSocket.connect();
|
||||||
|
|
||||||
// Expose liveSocket on window for web console debug logs and latency simulation:
|
// Expose liveSocket on window for web console debug logs and latency simulation:
|
||||||
// >> liveSocket.enableDebug()
|
// >> liveSocket.enableDebug()
|
||||||
|
@ -20,4 +27,4 @@ liveSocket.connect()
|
||||||
// The latency simulator is enabled for the duration of the browser session.
|
// The latency simulator is enabled for the duration of the browser session.
|
||||||
// Call disableLatencySim() to disable:
|
// Call disableLatencySim() to disable:
|
||||||
// >> liveSocket.disableLatencySim()
|
// >> liveSocket.disableLatencySim()
|
||||||
window.liveSocket = liveSocket
|
window.liveSocket = liveSocket;
|
||||||
|
|
|
@ -6,9 +6,9 @@
|
||||||
//
|
//
|
||||||
// Pass the token on params as below. Or remove it
|
// Pass the token on params as below. Or remove it
|
||||||
// from the params if you are not using authentication.
|
// from the params if you are not using authentication.
|
||||||
import {Socket} from "phoenix"
|
import { Socket } from "phoenix";
|
||||||
|
|
||||||
let socket = new Socket("/socket", {params: {token: window.userToken}})
|
let socket = new Socket("/socket", { params: { token: window.userToken } });
|
||||||
|
|
||||||
// When you connect, you'll often need to authenticate the client.
|
// When you connect, you'll often need to authenticate the client.
|
||||||
// For example, imagine you have an authentication plug, `MyAuth`,
|
// For example, imagine you have an authentication plug, `MyAuth`,
|
||||||
|
@ -52,12 +52,17 @@ let socket = new Socket("/socket", {params: {token: window.userToken}})
|
||||||
// end
|
// end
|
||||||
//
|
//
|
||||||
// Finally, connect to the socket:
|
// Finally, connect to the socket:
|
||||||
socket.connect()
|
socket.connect();
|
||||||
|
|
||||||
// Now that you are connected, you can join channels with a topic:
|
// Now that you are connected, you can join channels with a topic:
|
||||||
let channel = socket.channel("topic:subtopic", {})
|
let channel = socket.channel("topic:subtopic", {});
|
||||||
channel.join()
|
channel
|
||||||
.receive("ok", resp => { console.log("Joined successfully", resp) })
|
.join()
|
||||||
.receive("error", resp => { console.log("Unable to join", resp) })
|
.receive("ok", (resp) => {
|
||||||
|
console.log("Joined successfully", resp);
|
||||||
|
})
|
||||||
|
.receive("error", (resp) => {
|
||||||
|
console.log("Unable to join", resp);
|
||||||
|
});
|
||||||
|
|
||||||
export default socket
|
export default socket;
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
|
|
||||||
const ready = (fn) => {
|
const ready = (fn) => {
|
||||||
if (document.readyState != 'loading') {
|
if (document.readyState != "loading") {
|
||||||
fn()
|
fn();
|
||||||
} else {
|
} else {
|
||||||
document.addEventListener('DOMContentLoaded', fn)
|
document.addEventListener("DOMContentLoaded", fn);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export { ready }
|
export { ready };
|
||||||
|
|
29621
apps/app/assets/package-lock.json
generated
29621
apps/app/assets/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -5,20 +5,24 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"deploy": "webpack --mode production",
|
"deploy": "webpack --mode production",
|
||||||
"watch": "webpack --mode development --watch",
|
"watch": "webpack --mode development --watch",
|
||||||
"profile": "webpack --mode development --plugin webpack/lib/debug/ProfilingPlugin"
|
"profile": "webpack --mode development --plugin webpack/lib/debug/ProfilingPlugin",
|
||||||
|
"preinstall": "npx npm-force-resolutions"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^5.14.0",
|
"@fortawesome/fontawesome-free": "^5.14.0",
|
||||||
"alpinejs": "^2.8.1",
|
"alpinejs": "^2.8.1",
|
||||||
"autoprefixer": "^9.8.6",
|
"autoprefixer": "^9.8.6",
|
||||||
"csswring": "^7.0.0",
|
"fabric": "^4.6.0",
|
||||||
"glob": "^7.1.6",
|
"glob": "^7.1.6",
|
||||||
|
"npm-force-resolutions": "^0.0.10",
|
||||||
"phoenix": "file:/../../../deps/phoenix",
|
"phoenix": "file:/../../../deps/phoenix",
|
||||||
"phoenix_html": "file:/../../../deps/phoenix_html",
|
"phoenix_html": "file:/../../../deps/phoenix_html",
|
||||||
"phoenix_live_view": "file:../../../deps/phoenix_live_view",
|
"phoenix_live_view": "file:../../../deps/phoenix_live_view",
|
||||||
|
"postcss-clean": "^1.2.2",
|
||||||
"postcss-color-function": "^4.1.0",
|
"postcss-color-function": "^4.1.0",
|
||||||
"simplemde": "^1.11.2",
|
"simplemde": "^1.11.2",
|
||||||
"tailwindcss": "^1.7.3",
|
"squeak": "^1.3.0",
|
||||||
|
"tailwindcss": "^2.2.6",
|
||||||
"topbar": "^1.0.1"
|
"topbar": "^1.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -29,25 +33,28 @@
|
||||||
"css-loader": "^3.4.2",
|
"css-loader": "^3.4.2",
|
||||||
"css-minimizer-webpack-plugin": "^3.0.2",
|
"css-minimizer-webpack-plugin": "^3.0.2",
|
||||||
"file-loader": "^6.0.0",
|
"file-loader": "^6.0.0",
|
||||||
"image-webpack-loader": "^6.0.0",
|
"image-webpack-loader": "^7.0.1",
|
||||||
"less": "^3.11.3",
|
"less": "^3.11.3",
|
||||||
"less-loader": "^6.2.0",
|
"less-loader": "^6.2.0",
|
||||||
"mini-css-extract-plugin": "^1.6.2",
|
"mini-css-extract-plugin": "^1.6.2",
|
||||||
"postcss-css-variables": "^0.17.0",
|
"postcss-css-variables": "^0.17.0",
|
||||||
"postcss-import": "^12.0.1",
|
"postcss-import": "^12.0.1",
|
||||||
"postcss-loader": "^6.1.0",
|
"postcss-loader": "^6.1.0",
|
||||||
|
"prettier": "2.3.2",
|
||||||
"sass": "^1.35.1",
|
"sass": "^1.35.1",
|
||||||
"sass-loader": "^8.0.2",
|
"sass-loader": "^8.0.2",
|
||||||
"style-loader": "^1.2.1",
|
"style-loader": "^1.2.1",
|
||||||
"stylelint": "^13.13.1",
|
"stylelint": "^13.8.0",
|
||||||
"stylelint-config-standard": "^22.0.0",
|
"stylelint-config-standard": "^20.0.0",
|
||||||
"stylelint-order": "^4.1.0",
|
"stylelint-order": "^4.1.0",
|
||||||
"terser-webpack-plugin": "^2.3.2",
|
"terser-webpack-plugin": "^5.1.4",
|
||||||
"webpack": "^5.1.0",
|
"webpack": "^5.1.0",
|
||||||
"webpack-cli": "^4.7.2",
|
"webpack-cli": "^4.7.2",
|
||||||
"yargs-parser": "^20.2.9"
|
"yargs-parser": "^20.2.9"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"graceful-fs": "4.2.3"
|
"graceful-fs": "4.2.3",
|
||||||
|
"meow": "9.0.0",
|
||||||
|
"tempfile": "4.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,11 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: [
|
plugins: [
|
||||||
require('postcss-import')({
|
require("postcss-import")({
|
||||||
plugins: [
|
plugins: [require("stylelint")()],
|
||||||
require('stylelint')(),
|
|
||||||
]
|
|
||||||
}),
|
}),
|
||||||
require('tailwindcss'),
|
require("tailwindcss"),
|
||||||
require('autoprefixer'),
|
require("autoprefixer"),
|
||||||
require('csswring')(),
|
require("postcss-clean")(),
|
||||||
require('postcss-color-function')()
|
require("postcss-color-function")(),
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|
Binary file not shown.
After Width: | Height: | Size: 125 KiB |
|
@ -1,29 +1,163 @@
|
||||||
const yargsParser = require('yargs-parser');
|
const yargsParser = require("yargs-parser");
|
||||||
const cliArgs = yargsParser(process.argv);
|
const cliArgs = yargsParser(process.argv);
|
||||||
|
|
||||||
const mode = process.env.NODE_ENV || cliArgs.mode || 'development';
|
const mode = process.env.NODE_ENV || cliArgs.mode || "development";
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
future: {
|
|
||||||
removeDeprecatedGapUtilities: true,
|
|
||||||
purgeLayersByDefault: true,
|
|
||||||
},
|
|
||||||
purge: {
|
purge: {
|
||||||
enabled: mode == 'production',
|
enabled: mode == "production",
|
||||||
layers: ['base', 'components', 'utilities'],
|
layers: ["base", "components", "utilities"],
|
||||||
content: [
|
content: [
|
||||||
'../../../**/views/*.ex',
|
"../../../**/views/*.ex",
|
||||||
'../../../**/*.html.eex',
|
"../../../**/*.html.eex",
|
||||||
'../../../**/*.html.leex',
|
"../../../**/*.html.leex",
|
||||||
'../../../**/*.html.heex',
|
"../../../**/*.html.heex",
|
||||||
'./js/**/*.js'
|
"./js/**/*.js",
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
colors: {
|
||||||
|
transparent: "transparent",
|
||||||
|
current: "currentColor",
|
||||||
|
|
||||||
|
black: "#000",
|
||||||
|
white: "#fff",
|
||||||
|
|
||||||
|
gray: {
|
||||||
|
100: "#f7fafc",
|
||||||
|
200: "#edf2f7",
|
||||||
|
300: "#e2e8f0",
|
||||||
|
400: "#cbd5e0",
|
||||||
|
500: "#a0aec0",
|
||||||
|
600: "#718096",
|
||||||
|
700: "#4a5568",
|
||||||
|
800: "#2d3748",
|
||||||
|
900: "#1a202c",
|
||||||
|
},
|
||||||
|
red: {
|
||||||
|
100: "#fff5f5",
|
||||||
|
200: "#fed7d7",
|
||||||
|
300: "#feb2b2",
|
||||||
|
400: "#fc8181",
|
||||||
|
500: "#f56565",
|
||||||
|
600: "#e53e3e",
|
||||||
|
700: "#c53030",
|
||||||
|
800: "#9b2c2c",
|
||||||
|
900: "#742a2a",
|
||||||
|
},
|
||||||
|
orange: {
|
||||||
|
100: "#fffaf0",
|
||||||
|
200: "#feebc8",
|
||||||
|
300: "#fbd38d",
|
||||||
|
400: "#f6ad55",
|
||||||
|
500: "#ed8936",
|
||||||
|
600: "#dd6b20",
|
||||||
|
700: "#c05621",
|
||||||
|
800: "#9c4221",
|
||||||
|
900: "#7b341e",
|
||||||
|
},
|
||||||
|
yellow: {
|
||||||
|
100: "#fffff0",
|
||||||
|
200: "#fefcbf",
|
||||||
|
300: "#faf089",
|
||||||
|
400: "#f6e05e",
|
||||||
|
500: "#ecc94b",
|
||||||
|
600: "#d69e2e",
|
||||||
|
700: "#b7791f",
|
||||||
|
800: "#975a16",
|
||||||
|
900: "#744210",
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
100: "#f0fff4",
|
||||||
|
200: "#c6f6d5",
|
||||||
|
300: "#9ae6b4",
|
||||||
|
400: "#68d391",
|
||||||
|
500: "#48bb78",
|
||||||
|
600: "#38a169",
|
||||||
|
700: "#2f855a",
|
||||||
|
800: "#276749",
|
||||||
|
900: "#22543d",
|
||||||
|
},
|
||||||
|
teal: {
|
||||||
|
100: "#e6fffa",
|
||||||
|
200: "#b2f5ea",
|
||||||
|
300: "#81e6d9",
|
||||||
|
400: "#4fd1c5",
|
||||||
|
500: "#38b2ac",
|
||||||
|
600: "#319795",
|
||||||
|
700: "#2c7a7b",
|
||||||
|
800: "#285e61",
|
||||||
|
900: "#234e52",
|
||||||
|
},
|
||||||
|
blue: {
|
||||||
|
100: "#ebf8ff",
|
||||||
|
200: "#bee3f8",
|
||||||
|
300: "#90cdf4",
|
||||||
|
400: "#63b3ed",
|
||||||
|
500: "#4299e1",
|
||||||
|
600: "#3182ce",
|
||||||
|
700: "#2b6cb0",
|
||||||
|
800: "#2c5282",
|
||||||
|
900: "#2a4365",
|
||||||
|
},
|
||||||
|
indigo: {
|
||||||
|
100: "#ebf4ff",
|
||||||
|
200: "#c3dafe",
|
||||||
|
300: "#a3bffa",
|
||||||
|
400: "#7f9cf5",
|
||||||
|
500: "#667eea",
|
||||||
|
600: "#5a67d8",
|
||||||
|
700: "#4c51bf",
|
||||||
|
800: "#434190",
|
||||||
|
900: "#3c366b",
|
||||||
|
},
|
||||||
|
purple: {
|
||||||
|
100: "#faf5ff",
|
||||||
|
200: "#e9d8fd",
|
||||||
|
300: "#d6bcfa",
|
||||||
|
400: "#b794f4",
|
||||||
|
500: "#9f7aea",
|
||||||
|
600: "#805ad5",
|
||||||
|
700: "#6b46c1",
|
||||||
|
800: "#553c9a",
|
||||||
|
900: "#44337a",
|
||||||
|
},
|
||||||
|
pink: {
|
||||||
|
100: "#fff5f7",
|
||||||
|
200: "#fed7e2",
|
||||||
|
300: "#fbb6ce",
|
||||||
|
400: "#f687b3",
|
||||||
|
500: "#ed64a6",
|
||||||
|
600: "#d53f8c",
|
||||||
|
700: "#b83280",
|
||||||
|
800: "#97266d",
|
||||||
|
900: "#702459",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
xs: "0.75rem",
|
||||||
|
sm: "0.875rem",
|
||||||
|
base: "1rem",
|
||||||
|
lg: "1.125rem",
|
||||||
|
xl: "1.25rem",
|
||||||
|
"2xl": "1.5rem",
|
||||||
|
"3xl": "1.875rem",
|
||||||
|
"4xl": "2.25rem",
|
||||||
|
"5xl": "3rem",
|
||||||
|
"6xl": "4rem",
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
boxShadow: {
|
||||||
|
xs: "0 0 0 1px rgba(0, 0, 0, 0.05)",
|
||||||
|
outline: "0 0 0 3px rgba(66, 153, 225, 0.5)",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
backgroundColor: ['responsive', 'hover', 'focus', 'checked'],
|
backgroundColor: ["responsive", "hover", "focus", "checked"],
|
||||||
|
extend: {
|
||||||
|
fontWeight: ["hover", "focus"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
}
|
};
|
||||||
|
|
|
@ -1,31 +1,31 @@
|
||||||
const path = require('path');
|
const path = require("path");
|
||||||
const glob = require('glob');
|
const glob = require("glob");
|
||||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||||
const TerserPlugin = require('terser-webpack-plugin');
|
const TerserPlugin = require("terser-webpack-plugin");
|
||||||
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
|
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
|
||||||
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
||||||
|
|
||||||
const nodeModulesPath = path.resolve(__dirname, 'node_modules')
|
const nodeModulesPath = path.resolve(__dirname, "node_modules");
|
||||||
|
|
||||||
module.exports = (env, options) => {
|
module.exports = (env, options) => {
|
||||||
const devMode = options.mode !== 'production';
|
const devMode = options.mode !== "production";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
optimization: {
|
optimization: {
|
||||||
minimizer: [
|
minimizer: [
|
||||||
new TerserPlugin({ cache: true, parallel: true, sourceMap: devMode }),
|
new TerserPlugin({ parallel: true }),
|
||||||
new CssMinimizerPlugin(),
|
new CssMinimizerPlugin(),
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
mode: options.mode,
|
mode: options.mode,
|
||||||
devtool: devMode ? 'source-map' : undefined,
|
devtool: devMode ? "source-map" : undefined,
|
||||||
entry: {
|
entry: {
|
||||||
'app': glob.sync('./vendor/**/*.js').concat(['./js/app.js']),
|
app: glob.sync("./vendor/**/*.js").concat(["./js/app.js"]),
|
||||||
'content-editor': ['./js/content-editor.js'],
|
admin: ["./js/admin.js"],
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
filename: 'js/[name].js',
|
filename: "js/[name].js",
|
||||||
path: path.resolve(__dirname, '../priv/static/')
|
path: path.resolve(__dirname, "../priv/static/"),
|
||||||
},
|
},
|
||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
|
@ -33,9 +33,9 @@ module.exports = (env, options) => {
|
||||||
{
|
{
|
||||||
test: /\.(jpg|jpeg|gif|png)$/,
|
test: /\.(jpg|jpeg|gif|png)$/,
|
||||||
use: [
|
use: [
|
||||||
'file-loader',
|
"file-loader",
|
||||||
{
|
{
|
||||||
loader: 'image-webpack-loader',
|
loader: "image-webpack-loader",
|
||||||
options: {
|
options: {
|
||||||
disable: devMode,
|
disable: devMode,
|
||||||
},
|
},
|
||||||
|
@ -44,61 +44,108 @@ module.exports = (env, options) => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
test: /\.(woff2?|ttf|eot|svg)(\?[a-z0-9\=\.]+)?$/,
|
test: /\.(woff2?|ttf|eot|svg)(\?[a-z0-9\=\.]+)?$/,
|
||||||
loader: 'file-loader',
|
loader: "file-loader",
|
||||||
options: {
|
options: {
|
||||||
publicPath: '/fonts',
|
publicPath: "/fonts",
|
||||||
outputPath: (url, resourcePath, context) => {
|
outputPath: (url, resourcePath, context) => {
|
||||||
return `/fonts/${url}`;
|
return `/fonts/${url}`;
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
test: /\.js$/,
|
test: /\.js$/,
|
||||||
exclude: /node_modules/,
|
exclude: /node_modules/,
|
||||||
use: {
|
use: {
|
||||||
loader: 'babel-loader'
|
loader: "babel-loader",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
test: /\.css$/,
|
test: /\.css$/,
|
||||||
use: [
|
use: [
|
||||||
{loader: MiniCssExtractPlugin.loader},
|
{ loader: MiniCssExtractPlugin.loader },
|
||||||
{loader: 'css-loader', options: {sourceMap: true}},
|
{ loader: "css-loader", options: { sourceMap: true } },
|
||||||
{loader: 'postcss-loader', options: {sourceMap: true}},
|
{ loader: "postcss-loader", options: { sourceMap: true } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
new MiniCssExtractPlugin({
|
new MiniCssExtractPlugin({
|
||||||
filename: 'css/[name].css',
|
filename: "css/[name].css",
|
||||||
chunkFilename: '[id].css',
|
chunkFilename: "[id].css",
|
||||||
|
}),
|
||||||
|
new CopyWebpackPlugin({
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
from: path.resolve(__dirname, "static"),
|
||||||
|
to: path.resolve(__dirname, "../priv/static"),
|
||||||
|
},
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
new CopyWebpackPlugin({patterns: [
|
|
||||||
{
|
|
||||||
from: path.resolve(__dirname, 'static'),
|
|
||||||
to: path.resolve(__dirname, '../priv/static'),
|
|
||||||
},
|
|
||||||
]}),
|
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"../webfonts/fa-brands-400.eot": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.eot"),
|
"../webfonts/fa-brands-400.eot": path.resolve(
|
||||||
"../webfonts/fa-brands-400.woff2": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.woff2"),
|
__dirname,
|
||||||
"../webfonts/fa-brands-400.woff": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.woff"),
|
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.eot"
|
||||||
"../webfonts/fa-brands-400.ttf": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.ttf"),
|
),
|
||||||
"../webfonts/fa-brands-400.svg": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.svg"),
|
"../webfonts/fa-brands-400.woff2": path.resolve(
|
||||||
"../webfonts/fa-regular-400.eot": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.eot"),
|
__dirname,
|
||||||
"../webfonts/fa-regular-400.woff2": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.woff2"),
|
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.woff2"
|
||||||
"../webfonts/fa-regular-400.woff": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.woff"),
|
),
|
||||||
"../webfonts/fa-regular-400.ttf": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.ttf"),
|
"../webfonts/fa-brands-400.woff": path.resolve(
|
||||||
"../webfonts/fa-regular-400.svg": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.svg"),
|
__dirname,
|
||||||
"../webfonts/fa-solid-900.eot": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.eot"),
|
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.woff"
|
||||||
"../webfonts/fa-solid-900.woff2": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff2"),
|
),
|
||||||
"../webfonts/fa-solid-900.woff": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff"),
|
"../webfonts/fa-brands-400.ttf": path.resolve(
|
||||||
"../webfonts/fa-solid-900.ttf": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.ttf"),
|
__dirname,
|
||||||
"../webfonts/fa-solid-900.svg": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.svg"),
|
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.ttf"
|
||||||
}
|
),
|
||||||
|
"../webfonts/fa-brands-400.svg": path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.svg"
|
||||||
|
),
|
||||||
|
"../webfonts/fa-regular-400.eot": path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.eot"
|
||||||
|
),
|
||||||
|
"../webfonts/fa-regular-400.woff2": path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.woff2"
|
||||||
|
),
|
||||||
|
"../webfonts/fa-regular-400.woff": path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.woff"
|
||||||
|
),
|
||||||
|
"../webfonts/fa-regular-400.ttf": path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.ttf"
|
||||||
|
),
|
||||||
|
"../webfonts/fa-regular-400.svg": path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-regular-400.svg"
|
||||||
|
),
|
||||||
|
"../webfonts/fa-solid-900.eot": path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.eot"
|
||||||
|
),
|
||||||
|
"../webfonts/fa-solid-900.woff2": path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff2"
|
||||||
|
),
|
||||||
|
"../webfonts/fa-solid-900.woff": path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.woff"
|
||||||
|
),
|
||||||
|
"../webfonts/fa-solid-900.ttf": path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.ttf"
|
||||||
|
),
|
||||||
|
"../webfonts/fa-solid-900.svg": path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-solid-900.svg"
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
};
|
};
|
||||||
|
|
3
apps/app/cypress.json
Normal file
3
apps/app/cypress.json
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"baseUrl": "http://localhost:4002"
|
||||||
|
}
|
5
apps/app/cypress/fixtures/example.json
Normal file
5
apps/app/cypress/fixtures/example.json
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"name": "Using fixtures to represent data",
|
||||||
|
"email": "hello@cypress.io",
|
||||||
|
"body": "Fixtures are a great way to mock data for responses to routes"
|
||||||
|
}
|
9
apps/app/cypress/integration/blog_spec.js
Normal file
9
apps/app/cypress/integration/blog_spec.js
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
describe('Blog Page', () => {
|
||||||
|
it('shows posts', () => {
|
||||||
|
cy.setupDB("app", "blog")
|
||||||
|
|
||||||
|
cy.visit('/blog')
|
||||||
|
|
||||||
|
cy.get('article').should('exist')
|
||||||
|
})
|
||||||
|
})
|
22
apps/app/cypress/plugins/index.js
Normal file
22
apps/app/cypress/plugins/index.js
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
/// <reference types="cypress" />
|
||||||
|
// ***********************************************************
|
||||||
|
// This example plugins/index.js can be used to load plugins
|
||||||
|
//
|
||||||
|
// You can change the location of this file or turn off loading
|
||||||
|
// the plugins file with the 'pluginsFile' configuration option.
|
||||||
|
//
|
||||||
|
// You can read more here:
|
||||||
|
// https://on.cypress.io/plugins-guide
|
||||||
|
// ***********************************************************
|
||||||
|
|
||||||
|
// This function is called when a project is opened or re-opened (e.g. due to
|
||||||
|
// the project's config changing)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Cypress.PluginConfig}
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
module.exports = (on, config) => {
|
||||||
|
// `on` is used to hook into various events Cypress emits
|
||||||
|
// `config` is the resolved Cypress config
|
||||||
|
}
|
36
apps/app/cypress/support/commands.js
Normal file
36
apps/app/cypress/support/commands.js
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
// ***********************************************
|
||||||
|
// This example commands.js shows you how to
|
||||||
|
// create various custom commands and overwrite
|
||||||
|
// existing commands.
|
||||||
|
//
|
||||||
|
// For more comprehensive examples of custom
|
||||||
|
// commands please read more here:
|
||||||
|
// https://on.cypress.io/custom-commands
|
||||||
|
// ***********************************************
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a parent command --
|
||||||
|
// Cypress.Commands.add('login', (email, password) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a child command --
|
||||||
|
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a dual command --
|
||||||
|
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This will overwrite an existing command --
|
||||||
|
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||||
|
|
||||||
|
Cypress.Commands.add("setupDB", (app, seedSet) => {
|
||||||
|
cy.request('POST', '/end-to-end/db/setup', {
|
||||||
|
app,
|
||||||
|
seed_set: seedSet,
|
||||||
|
}).as('setupDB')
|
||||||
|
})
|
||||||
|
|
||||||
|
Cypress.Commands.add("teardownDB", () => {
|
||||||
|
cy.request('POST', '/end-to-end/db/teardown').as('teardownDB')
|
||||||
|
})
|
29
apps/app/cypress/support/index.js
Normal file
29
apps/app/cypress/support/index.js
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
// ***********************************************************
|
||||||
|
// This example support/index.js is processed and
|
||||||
|
// loaded automatically before your test files.
|
||||||
|
//
|
||||||
|
// This is a great place to put global configuration and
|
||||||
|
// behavior that modifies Cypress.
|
||||||
|
//
|
||||||
|
// You can change the location of this file or turn off
|
||||||
|
// automatically serving support files with the
|
||||||
|
// 'supportFile' configuration option.
|
||||||
|
//
|
||||||
|
// You can read more here:
|
||||||
|
// https://on.cypress.io/configuration
|
||||||
|
// ***********************************************************
|
||||||
|
|
||||||
|
// Import commands.js using ES2015 syntax:
|
||||||
|
import './commands'
|
||||||
|
|
||||||
|
// Alternatively you can use CommonJS syntax:
|
||||||
|
// require('./commands')
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
// Make sure we don't have a rogue DB connection checked out
|
||||||
|
cy.teardownDB()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cy.teardownDB()
|
||||||
|
})
|
|
@ -31,7 +31,7 @@ defmodule AppWeb.Endpoint do
|
||||||
at: "/",
|
at: "/",
|
||||||
from: :app,
|
from: :app,
|
||||||
gzip: false,
|
gzip: false,
|
||||||
only: ~w(css fonts images js favicon.ico robots.txt)
|
only: ~w(css fonts images js favicon.ico robots.txt public_uploads)
|
||||||
|
|
||||||
plug Plug.Static,
|
plug Plug.Static,
|
||||||
at: "/kaffy",
|
at: "/kaffy",
|
||||||
|
|
|
@ -4,19 +4,16 @@ defmodule AppWeb.LiveHelpers do
|
||||||
"""
|
"""
|
||||||
import Phoenix.LiveView
|
import Phoenix.LiveView
|
||||||
|
|
||||||
alias Legendary.Auth.User
|
|
||||||
alias Pow.Store.CredentialsCache
|
|
||||||
alias AppWeb.Pow.Routes
|
|
||||||
|
|
||||||
def assign_defaults(socket, session) do
|
def assign_defaults(socket, session) do
|
||||||
assign_new(socket, :current_user, fn -> get_user(socket, session) end)
|
assign_new(socket, :current_user, fn -> get_user(socket, session) end)
|
||||||
end
|
end
|
||||||
|
|
||||||
def require_auth(socket) do
|
def require_auth(socket) do
|
||||||
if socket.assigns.current_user do
|
case socket.assigns do
|
||||||
socket
|
%{current_user: user} when not is_nil(user) ->
|
||||||
else
|
socket
|
||||||
redirect(socket, to: Routes.after_sign_out_path(%Plug.Conn{}))
|
_ ->
|
||||||
|
redirect(socket, to: "/")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -51,6 +51,10 @@ defmodule AppWeb.Router do
|
||||||
forward "/sent_emails", Bamboo.SentEmailViewerPlug
|
forward "/sent_emails", Bamboo.SentEmailViewerPlug
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if Mix.env() in [:e2e, :test] do
|
||||||
|
forward("/end-to-end", Legendary.CoreWeb.Plug.TestEndToEnd, otp_app: :app)
|
||||||
|
end
|
||||||
|
|
||||||
scope "/" do
|
scope "/" do
|
||||||
pipe_through :browser
|
pipe_through :browser
|
||||||
|
|
||||||
|
@ -60,5 +64,6 @@ defmodule AppWeb.Router do
|
||||||
|
|
||||||
use Legendary.Core.Routes
|
use Legendary.Core.Routes
|
||||||
use Legendary.Admin.Routes
|
use Legendary.Admin.Routes
|
||||||
|
use Legendary.ObjectStorageWeb.Routes
|
||||||
use Legendary.Content.Routes
|
use Legendary.Content.Routes
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
defmodule AppWeb.Telemetry do
|
defmodule AppWeb.Telemetry do
|
||||||
|
@moduledoc """
|
||||||
|
Collect metrics on your app.
|
||||||
|
"""
|
||||||
|
|
||||||
use Supervisor
|
use Supervisor
|
||||||
import Telemetry.Metrics
|
import Telemetry.Metrics
|
||||||
|
|
||||||
|
|
26
apps/app/lib/app_web/templates/layout/_social.html.eex
Normal file
26
apps/app/lib/app_web/templates/layout/_social.html.eex
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
<%= live_title_tag title(@conn, assigns) %>
|
||||||
|
|
||||||
|
<%= tag :meta, itemprop: "name", content: title(@conn, assigns) %>
|
||||||
|
<%= tag :meta, itemprop: "description", content: excerpt(@conn, assigns) %>
|
||||||
|
<%= tag :meta, name: "description", content: excerpt(@conn, assigns) %>
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<%= tag :meta, name: "twitter:card", content: "summary_large_image" %>
|
||||||
|
<%= tag :meta, name: "twitter:title", content: title(@conn, assigns) %>
|
||||||
|
<%= tag :meta, name: "twitter:description", content: excerpt(@conn, assigns) %>
|
||||||
|
<!-- <meta name="twitter:site" content="@site_handle"> -->
|
||||||
|
<!-- <meta name="twitter:creator" content="@author_handle"> -->
|
||||||
|
|
||||||
|
<!-- Facebook / Open Graph -->
|
||||||
|
<%= tag :meta, property: "og:title", content: title(@conn, assigns) %>
|
||||||
|
<%= tag :meta, property: "og:type", content: "article" %>
|
||||||
|
<%= tag :meta, property: "og:description", content: excerpt(@conn, assigns) %>
|
||||||
|
<%= tag :meta, property: "og:site_name", content: Legendary.I18n.t!("en", "site.title") %>
|
||||||
|
<%= modified_tag(@conn, assigns) %>
|
||||||
|
<%= published_tag(@conn, assigns) %>
|
||||||
|
<!-- <meta property="article:section" content="Article Section" /> -->
|
||||||
|
<!-- <meta property="article:tag" content="Article Tag" /> -->
|
||||||
|
<!-- <meta property="fb:admins" content="Facebook numberic ID" /> -->
|
||||||
|
|
||||||
|
<!-- Preview Images -->
|
||||||
|
<%= preview_image_tags(@conn, assigns) %>
|
|
@ -5,7 +5,7 @@
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
|
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<%= csrf_meta_tag() %>
|
<%= csrf_meta_tag() %>
|
||||||
<%= live_title_tag title(@view_module, @view_template, assigns) %>
|
<%= render "_social.html", assigns %>
|
||||||
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
|
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
|
||||||
<script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
|
<script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
|
@ -8,10 +8,12 @@ defmodule AppWeb.ErrorHelpers do
|
||||||
@doc """
|
@doc """
|
||||||
Generates tag for inlined form input errors.
|
Generates tag for inlined form input errors.
|
||||||
"""
|
"""
|
||||||
def error_tag(form, field) do
|
def error_tag(form, field, opts \\ []) do
|
||||||
|
{extra_classes, _rest_opts} = Keyword.pop(opts, :class, "")
|
||||||
|
|
||||||
Enum.map(Keyword.get_values(form.errors, field), fn error ->
|
Enum.map(Keyword.get_values(form.errors, field), fn error ->
|
||||||
content_tag(:span, translate_error(error),
|
content_tag(:span, translate_error(error),
|
||||||
class: "invalid-feedback",
|
class: "invalid-feedback #{extra_classes}",
|
||||||
phx_feedback_for: input_id(form, field)
|
phx_feedback_for: input_id(form, field)
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
|
|
|
@ -1,15 +1,73 @@
|
||||||
defmodule AppWeb.LayoutView do
|
defmodule AppWeb.LayoutView do
|
||||||
use AppWeb, :view
|
use AppWeb, :view
|
||||||
|
|
||||||
|
alias Legendary.ContentWeb.Uploaders.SocialMediaPreview
|
||||||
|
|
||||||
|
def title(conn, assigns), do: title(view_module(conn), view_template(conn), assigns)
|
||||||
|
|
||||||
|
def title(view, template, %{post: post}), do: "#{post.title} | #{title(view, template, nil)}"
|
||||||
|
|
||||||
def title(_, _, _) do
|
def title(_, _, _) do
|
||||||
Legendary.I18n.t!("en", "site.title")
|
Legendary.I18n.t!("en", "site.title")
|
||||||
end
|
end
|
||||||
|
|
||||||
def excerpt(_, _, _) do
|
def excerpt(conn, assigns), do: excerpt(view_module(conn), view_template(conn), assigns)
|
||||||
Legendary.I18n.t!("en", "site.excerpt")
|
|
||||||
end
|
|
||||||
|
|
||||||
def feed_tag(_, _, _, _) do
|
def excerpt(_, _, %{post: post}) do
|
||||||
nil
|
post.excerpt
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def excerpt(_, _, _) do
|
||||||
|
Legendary.I18n.t!("en", "site.excerpt")
|
||||||
|
end
|
||||||
|
|
||||||
|
def feed_tag(conn, assigns), do: feed_tag(conn, view_module(conn), view_template(conn), assigns)
|
||||||
|
|
||||||
|
def feed_tag(_, _, _, _) do
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def modified_tag(conn, assigns), do: modified_tag(view_module(conn), view_template(conn), assigns)
|
||||||
|
|
||||||
|
def modified_tag(_, _, %{post: post}) do
|
||||||
|
content =
|
||||||
|
post.modified_gmt
|
||||||
|
|> DateTime.from_naive!("Etc/UTC")
|
||||||
|
|> DateTime.to_iso8601()
|
||||||
|
|
||||||
|
tag :meta, property: "article:modified_time", content: content
|
||||||
|
end
|
||||||
|
|
||||||
|
def modified_tag(_, _, _) do
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def published_tag(conn, assigns), do: modified_tag(view_module(conn), view_template(conn), assigns)
|
||||||
|
|
||||||
|
def published_tag(_, _, %{post: post}) do
|
||||||
|
content =
|
||||||
|
post.date_gmt
|
||||||
|
|> DateTime.from_naive!("Etc/UTC")
|
||||||
|
|> DateTime.to_iso8601()
|
||||||
|
|
||||||
|
tag :meta, property: "article:published_time", content: content
|
||||||
|
end
|
||||||
|
|
||||||
|
def published_tag(_, _, _) do
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def preview_image_tags(_conn, %{post: post}) do
|
||||||
|
url = SocialMediaPreview.url({"original.png", post}, :original)
|
||||||
|
|
||||||
|
[
|
||||||
|
tag(:meta, itemprop: "image", content: url),
|
||||||
|
tag(:meta, name: "twitter:image:src", content: url),
|
||||||
|
tag(:meta, property: "og:image", content: url)
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
def preview_image_tags(_, _) do
|
||||||
|
nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -9,7 +9,7 @@ defmodule App.MixProject do
|
||||||
config_path: "../../config/config.exs",
|
config_path: "../../config/config.exs",
|
||||||
deps_path: "../../deps",
|
deps_path: "../../deps",
|
||||||
lockfile: "../../mix.lock",
|
lockfile: "../../mix.lock",
|
||||||
elixir: "~> 1.7",
|
elixir: "~> 1.10",
|
||||||
elixirc_paths: elixirc_paths(Mix.env()),
|
elixirc_paths: elixirc_paths(Mix.env()),
|
||||||
compilers: [:phoenix, :gettext] ++ Mix.compilers(),
|
compilers: [:phoenix, :gettext] ++ Mix.compilers(),
|
||||||
start_permanent: Mix.env() == :prod,
|
start_permanent: Mix.env() == :prod,
|
||||||
|
@ -42,19 +42,20 @@ defmodule App.MixProject do
|
||||||
{:admin, in_umbrella: true},
|
{:admin, in_umbrella: true},
|
||||||
{:content, in_umbrella: true},
|
{:content, in_umbrella: true},
|
||||||
{:core, in_umbrella: true},
|
{:core, in_umbrella: true},
|
||||||
{:ecto_sql, "~> 3.4"},
|
{:object_storage, in_umbrella: true},
|
||||||
|
{:ecto_sql, "~> 3.7"},
|
||||||
{:excoveralls, "~> 0.10", only: [:dev, :test]},
|
{:excoveralls, "~> 0.10", only: [:dev, :test]},
|
||||||
{:floki, ">= 0.30.0"},
|
{:floki, ">= 0.30.0"},
|
||||||
{:oban, "~> 2.1"},
|
{:oban, "~> 2.9"},
|
||||||
{:phoenix, "~> 1.5.8"},
|
{:phoenix, "~> 1.6.0"},
|
||||||
{:phoenix_ecto, "~> 4.0"},
|
{:phoenix_ecto, "~> 4.4"},
|
||||||
{:phoenix_html, "~> 2.11"},
|
{:phoenix_html, "~> 3.0.4", override: true},
|
||||||
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
{:phoenix_live_reload, "~> 1.3", only: :dev},
|
||||||
{:phoenix_live_dashboard, "~> 0.4.0"},
|
{:phoenix_live_dashboard, "~> 0.5.0"},
|
||||||
{:phoenix_live_view, "~> 0.15.7", override: true},
|
{:phoenix_live_view, "~> 0.16.0", override: true},
|
||||||
{:postgrex, ">= 0.0.0"},
|
{:postgrex, ">= 0.0.0"},
|
||||||
{:telemetry_metrics, "~> 0.4"},
|
{:telemetry_metrics, "~> 0.4"},
|
||||||
{:telemetry_poller, "~> 0.4"},
|
{:telemetry_poller, "~> 1.0"},
|
||||||
{:gettext, "~> 0.11"},
|
{:gettext, "~> 0.11"},
|
||||||
{:jason, "~> 1.0"},
|
{:jason, "~> 1.0"},
|
||||||
{:plug_cowboy, "~> 2.0"}
|
{:plug_cowboy, "~> 2.0"}
|
||||||
|
|
29
apps/app/test/app_web/error_helpers_test.exs
Normal file
29
apps/app/test/app_web/error_helpers_test.exs
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
defmodule AppWeb.ErrorHelpersTest do
|
||||||
|
use AppWeb.ConnCase
|
||||||
|
|
||||||
|
import Phoenix.HTML, only: [safe_to_string: 1]
|
||||||
|
import Phoenix.HTML.Form, only: [form_for: 3]
|
||||||
|
|
||||||
|
import AppWeb.ErrorHelpers
|
||||||
|
|
||||||
|
def form do
|
||||||
|
:example
|
||||||
|
|> form_for(
|
||||||
|
"/example",
|
||||||
|
as: :test_params,
|
||||||
|
errors: [error_field: {"is an error", []}]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "error_tag/2" do
|
||||||
|
test "generates a span with an invalid-feedback class" do
|
||||||
|
[safe] = error_tag(form(), :error_field)
|
||||||
|
assert safe_to_string(safe) =~ "invalid-feedback"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "error_tag/3" do
|
||||||
|
[safe] = error_tag(form(), :error_field, class: "test-class")
|
||||||
|
assert safe_to_string(safe) =~ "test-class"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
46
apps/app/test/app_web/live_helpers_test.exs
Normal file
46
apps/app/test/app_web/live_helpers_test.exs
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
defmodule AppWeb.LiveHelpersText do
|
||||||
|
use AppWeb.ConnCase
|
||||||
|
|
||||||
|
import Mock
|
||||||
|
|
||||||
|
import AppWeb.LiveHelpers
|
||||||
|
|
||||||
|
describe "assign_defaults/2" do
|
||||||
|
test "sets current_user" do
|
||||||
|
{store, _store_config} = Pow.Plug.Base.store(Application.get_env(:core, :pow))
|
||||||
|
socket = %Phoenix.LiveView.Socket{endpoint: AppWeb.Endpoint}
|
||||||
|
|
||||||
|
with_mock Pow.Plug, [verify_token: fn (_, _, _, _) -> {:ok, "h3110"} end] do
|
||||||
|
with_mock store, [get: fn (_config, _token) -> {%{id: 1234}, nil} end] do
|
||||||
|
new_socket = assign_defaults(socket, %{"core_auth" => "h3ll0"})
|
||||||
|
assert %{assigns: %{current_user: %{id: 1234}}} = new_socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "require_auth/1" do
|
||||||
|
test "with user" do
|
||||||
|
user = %{id: 4567}
|
||||||
|
socket =
|
||||||
|
%Phoenix.LiveView.Socket{assigns: %{current_user: user}}
|
||||||
|
|> require_auth()
|
||||||
|
|
||||||
|
assert !socket.redirected
|
||||||
|
end
|
||||||
|
|
||||||
|
test "without user" do
|
||||||
|
socket = %Phoenix.LiveView.Socket{} |> require_auth()
|
||||||
|
|
||||||
|
assert socket.redirected
|
||||||
|
end
|
||||||
|
|
||||||
|
test "without nil user" do
|
||||||
|
socket =
|
||||||
|
%Phoenix.LiveView.Socket{assigns: %{current_user: nil}}
|
||||||
|
|> require_auth()
|
||||||
|
|
||||||
|
assert socket.redirected
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -2,6 +2,17 @@ defmodule App.LayoutViewTest do
|
||||||
use AppWeb.ConnCase, async: true
|
use AppWeb.ConnCase, async: true
|
||||||
|
|
||||||
import AppWeb.LayoutView
|
import AppWeb.LayoutView
|
||||||
|
import Phoenix.HTML, only: [safe_to_string: 1]
|
||||||
|
|
||||||
|
alias Legendary.Content.Post
|
||||||
|
|
||||||
|
@post %Post{
|
||||||
|
name: "test-slug",
|
||||||
|
title: "Test Post",
|
||||||
|
excerpt: "This is a test post.",
|
||||||
|
modified_gmt: ~N[2021-09-17T00:00:00],
|
||||||
|
date_gmt: ~N[2021-09-15T00:00:00]
|
||||||
|
}
|
||||||
|
|
||||||
describe "title/3" do
|
describe "title/3" do
|
||||||
def default_title do
|
def default_title do
|
||||||
|
@ -11,6 +22,10 @@ defmodule App.LayoutViewTest do
|
||||||
test "for nil" do
|
test "for nil" do
|
||||||
assert title(nil, nil, nil) =~ default_title()
|
assert title(nil, nil, nil) =~ default_title()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "for post" do
|
||||||
|
assert title(nil, nil, %{post: @post}) =~ "Test Post | #{default_title()}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "excerpt/3" do
|
describe "excerpt/3" do
|
||||||
|
@ -21,6 +36,10 @@ defmodule App.LayoutViewTest do
|
||||||
test "for nil" do
|
test "for nil" do
|
||||||
assert excerpt(nil, nil, nil) =~ default_excerpt()
|
assert excerpt(nil, nil, nil) =~ default_excerpt()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "for post" do
|
||||||
|
assert excerpt(nil, nil, %{post: @post}) =~ "This is a test post."
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "feed_tag/4" do
|
describe "feed_tag/4" do
|
||||||
|
@ -28,4 +47,39 @@ defmodule App.LayoutViewTest do
|
||||||
assert feed_tag(nil, nil, nil, nil) == nil
|
assert feed_tag(nil, nil, nil, nil) == nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "modified_tag/3" do
|
||||||
|
test "for a post" do
|
||||||
|
assert safe_to_string(modified_tag(nil, nil, %{post: @post})) =~ "2021-09-17"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "without a post" do
|
||||||
|
assert modified_tag(nil, nil, nil) == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "published_tag/3" do
|
||||||
|
test "for a post" do
|
||||||
|
assert safe_to_string(published_tag(nil, nil, %{post: @post})) =~ "2021-09-15"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "without a post" do
|
||||||
|
assert published_tag(nil, nil, nil) == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "preview_image_tags/2" do
|
||||||
|
test "for a post" do
|
||||||
|
markup =
|
||||||
|
preview_image_tags(nil, %{post: @post})
|
||||||
|
|> Enum.map(&safe_to_string/1)
|
||||||
|
|> Enum.join("")
|
||||||
|
|
||||||
|
assert markup =~ "/public_uploads/content/posts/preview_images/test-slug/original.png"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "without a post" do
|
||||||
|
assert preview_image_tags(nil, nil) == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
11
apps/app/test/seed_sets/blog.exs
Normal file
11
apps/app/test/seed_sets/blog.exs
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
alias Legendary.Content.Post
|
||||||
|
alias Legendary.Content.Repo
|
||||||
|
|
||||||
|
%Post{
|
||||||
|
title: "Public post",
|
||||||
|
name: "public-post",
|
||||||
|
status: "publish",
|
||||||
|
type: "post",
|
||||||
|
date: ~N[2020-01-01T00:00:00],
|
||||||
|
}
|
||||||
|
|> Repo.insert!()
|
3
apps/content/cypress.json
Normal file
3
apps/content/cypress.json
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"baseUrl": "http://localhost:4002"
|
||||||
|
}
|
5
apps/content/cypress/integration/example_spec.js
Normal file
5
apps/content/cypress/integration/example_spec.js
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
describe('My First Test', () => {
|
||||||
|
it('Does not do much!', () => {
|
||||||
|
expect(true).to.equal(true)
|
||||||
|
})
|
||||||
|
})
|
22
apps/content/cypress/plugins/index.js
Normal file
22
apps/content/cypress/plugins/index.js
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
/// <reference types="cypress" />
|
||||||
|
// ***********************************************************
|
||||||
|
// This example plugins/index.js can be used to load plugins
|
||||||
|
//
|
||||||
|
// You can change the location of this file or turn off loading
|
||||||
|
// the plugins file with the 'pluginsFile' configuration option.
|
||||||
|
//
|
||||||
|
// You can read more here:
|
||||||
|
// https://on.cypress.io/plugins-guide
|
||||||
|
// ***********************************************************
|
||||||
|
|
||||||
|
// This function is called when a project is opened or re-opened (e.g. due to
|
||||||
|
// the project's config changing)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Cypress.PluginConfig}
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
module.exports = (on, config) => {
|
||||||
|
// `on` is used to hook into various events Cypress emits
|
||||||
|
// `config` is the resolved Cypress config
|
||||||
|
}
|
36
apps/content/cypress/support/commands.js
Normal file
36
apps/content/cypress/support/commands.js
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
// ***********************************************
|
||||||
|
// This example commands.js shows you how to
|
||||||
|
// create various custom commands and overwrite
|
||||||
|
// existing commands.
|
||||||
|
//
|
||||||
|
// For more comprehensive examples of custom
|
||||||
|
// commands please read more here:
|
||||||
|
// https://on.cypress.io/custom-commands
|
||||||
|
// ***********************************************
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a parent command --
|
||||||
|
// Cypress.Commands.add('login', (email, password) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a child command --
|
||||||
|
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a dual command --
|
||||||
|
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This will overwrite an existing command --
|
||||||
|
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||||
|
|
||||||
|
Cypress.Commands.add("setupDB", (app, seedSet) => {
|
||||||
|
cy.request('POST', '/end-to-end/db/setup', {
|
||||||
|
app,
|
||||||
|
seed_set: seedSet,
|
||||||
|
}).as('setupDB')
|
||||||
|
})
|
||||||
|
|
||||||
|
Cypress.Commands.add("teardownDB", () => {
|
||||||
|
cy.request('POST', '/end-to-end/db/teardown').as('teardownDB')
|
||||||
|
})
|
29
apps/content/cypress/support/index.js
Normal file
29
apps/content/cypress/support/index.js
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
// ***********************************************************
|
||||||
|
// This example support/index.js is processed and
|
||||||
|
// loaded automatically before your test files.
|
||||||
|
//
|
||||||
|
// This is a great place to put global configuration and
|
||||||
|
// behavior that modifies Cypress.
|
||||||
|
//
|
||||||
|
// You can change the location of this file or turn off
|
||||||
|
// automatically serving support files with the
|
||||||
|
// 'supportFile' configuration option.
|
||||||
|
//
|
||||||
|
// You can read more here:
|
||||||
|
// https://on.cypress.io/configuration
|
||||||
|
// ***********************************************************
|
||||||
|
|
||||||
|
// Import commands.js using ES2015 syntax:
|
||||||
|
import './commands'
|
||||||
|
|
||||||
|
// Alternatively you can use CommonJS syntax:
|
||||||
|
// require('./commands')
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
// Make sure we don't have a rogue DB connection checked out
|
||||||
|
cy.teardownDB()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cy.teardownDB()
|
||||||
|
})
|
|
@ -7,12 +7,11 @@ defmodule Legendary.Content.Application do
|
||||||
# See https://hexdocs.pm/elixir/Application.html
|
# See https://hexdocs.pm/elixir/Application.html
|
||||||
# for more information on OTP Applications
|
# for more information on OTP Applications
|
||||||
def start(_type, _args) do
|
def start(_type, _args) do
|
||||||
import Supervisor.Spec
|
|
||||||
|
|
||||||
# Define workers and child supervisors to be supervised
|
# Define workers and child supervisors to be supervised
|
||||||
children = [
|
children = [
|
||||||
# Start the Ecto repository
|
# Start the Ecto repository
|
||||||
supervisor(Legendary.Content.Repo, []),
|
Legendary.Content.Repo,
|
||||||
# Start the endpoint when the application starts
|
# Start the endpoint when the application starts
|
||||||
# Start your own worker by calling: Legendary.Content.Worker.start_link(arg1, arg2, arg3)
|
# Start your own worker by calling: Legendary.Content.Worker.start_link(arg1, arg2, arg3)
|
||||||
# worker(Legendary.Content.Worker, [arg1, arg2, arg3]),
|
# worker(Legendary.Content.Worker, [arg1, arg2, arg3]),
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
defmodule Legendary.Content.CommentAdmin do
|
defmodule Legendary.Content.CommentAdmin do
|
||||||
|
@moduledoc """
|
||||||
|
Custom admin logic for blog post comments.
|
||||||
|
"""
|
||||||
|
|
||||||
def index(_) do
|
def index(_) do
|
||||||
[
|
[
|
||||||
id: nil,
|
id: nil,
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
defmodule Legendary.Content.MarkupField do
|
defmodule Legendary.Content.MarkupField do
|
||||||
|
@moduledoc """
|
||||||
|
Custom field type definition for markdown fields. Currently uses simplemde
|
||||||
|
to provide a markdown editing GUI.
|
||||||
|
"""
|
||||||
use Ecto.Type
|
use Ecto.Type
|
||||||
def type, do: :string
|
def type, do: :string
|
||||||
|
|
||||||
|
|
|
@ -116,15 +116,17 @@ defmodule Legendary.Content.Post do
|
||||||
|
|
||||||
def maybe_put_guid(changeset) do
|
def maybe_put_guid(changeset) do
|
||||||
import Legendary.Content.Router.Helpers, only: [url: 1, posts_url: 3]
|
import Legendary.Content.Router.Helpers, only: [url: 1, posts_url: 3]
|
||||||
slug = changeset |> get_field(:name)
|
guid = changeset |> get_field(:guid)
|
||||||
|
|
||||||
case slug do
|
case guid do
|
||||||
nil -> changeset
|
nil ->
|
||||||
_ ->
|
|
||||||
base = url(Legendary.CoreWeb.Endpoint)
|
base = url(Legendary.CoreWeb.Endpoint)
|
||||||
|
slug = changeset |> get_field(:name)
|
||||||
|
|
||||||
changeset
|
changeset
|
||||||
|> put_default(:guid, posts_url(URI.merge(base, "/pages"), :show, slug))
|
|> put_default(:guid, posts_url(URI.merge(base, "/pages"), :show, slug))
|
||||||
|
_ ->
|
||||||
|
changeset
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,4 +1,10 @@
|
||||||
defmodule Legendary.Content.PostAdmin do
|
defmodule Legendary.Content.PostAdmin do
|
||||||
|
@moduledoc """
|
||||||
|
Custom admin logic for content posts and pages.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias Legendary.Content.{Post, Posts.PreviewImages}
|
||||||
|
|
||||||
import Ecto.Query, only: [from: 2]
|
import Ecto.Query, only: [from: 2]
|
||||||
|
|
||||||
def singular_name(_) do
|
def singular_name(_) do
|
||||||
|
@ -10,11 +16,15 @@ defmodule Legendary.Content.PostAdmin do
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_changeset(schema, attrs) do
|
def create_changeset(schema, attrs) do
|
||||||
Legendary.Content.Post.changeset(schema, attrs)
|
schema
|
||||||
|
|> Post.changeset(attrs)
|
||||||
|
|> PreviewImages.handle_preview_image_upload(attrs)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_changeset(schema, attrs) do
|
def update_changeset(schema, attrs) do
|
||||||
Legendary.Content.Post.changeset(schema, attrs)
|
schema
|
||||||
|
|> Post.changeset(attrs)
|
||||||
|
|> PreviewImages.handle_preview_image_upload(attrs)
|
||||||
end
|
end
|
||||||
|
|
||||||
def index(_) do
|
def index(_) do
|
||||||
|
@ -54,6 +64,7 @@ defmodule Legendary.Content.PostAdmin do
|
||||||
comment_status: %{choices: [{"open", :open}, {"closed", :closed}]},
|
comment_status: %{choices: [{"open", :open}, {"closed", :closed}]},
|
||||||
ping_status: %{choices: [{"open", :open}, {"closed", :closed}]},
|
ping_status: %{choices: [{"open", :open}, {"closed", :closed}]},
|
||||||
menu_order: nil,
|
menu_order: nil,
|
||||||
|
social_media_preview_image: %{type: :hidden},
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
30
apps/content/lib/content/posts/preview_images.ex
Normal file
30
apps/content/lib/content/posts/preview_images.ex
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
defmodule Legendary.Content.Posts.PreviewImages do
|
||||||
|
@moduledoc """
|
||||||
|
Handles storing social media preview images which are submitted as data uris
|
||||||
|
in the social_media_preview_image field.
|
||||||
|
"""
|
||||||
|
alias Ecto.Changeset
|
||||||
|
alias Legendary.ContentWeb.Uploaders.SocialMediaPreview
|
||||||
|
alias Legendary.CoreWeb.Base64Uploads
|
||||||
|
|
||||||
|
def handle_preview_image_upload(changeset, attrs) do
|
||||||
|
upload =
|
||||||
|
case attrs do
|
||||||
|
%{"social_media_preview_image" => data} when is_binary(data) ->
|
||||||
|
Base64Uploads.data_uri_to_upload(data)
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
|
||||||
|
case upload do
|
||||||
|
nil ->
|
||||||
|
changeset
|
||||||
|
%Plug.Upload{} ->
|
||||||
|
name = Changeset.get_field(changeset, :name)
|
||||||
|
{:ok, _filename} = SocialMediaPreview.store({upload, %{name: name}})
|
||||||
|
changeset
|
||||||
|
:error ->
|
||||||
|
changeset
|
||||||
|
|> Changeset.add_error(:social_media_preview_image, "is malformed")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -21,6 +21,7 @@ defmodule Legendary.Content.Sitemaps do
|
||||||
generate()
|
generate()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec generate :: :ok
|
||||||
def generate do
|
def generate do
|
||||||
create do
|
create do
|
||||||
add "", priority: 0.5, changefreq: "hourly", expires: nil
|
add "", priority: 0.5, changefreq: "hourly", expires: nil
|
|
@ -4,7 +4,7 @@ defmodule Legendary.Content.TermRelationship do
|
||||||
"""
|
"""
|
||||||
use Ecto.Schema
|
use Ecto.Schema
|
||||||
import Ecto.Changeset
|
import Ecto.Changeset
|
||||||
alias Legendary.Content.{Post}
|
alias Legendary.Content.Post
|
||||||
|
|
||||||
@primary_key {:object_id, :integer, []}
|
@primary_key {:object_id, :integer, []}
|
||||||
@primary_key {:term_taxonomy_id, :integer, []}
|
@primary_key {:term_taxonomy_id, :integer, []}
|
||||||
|
|
|
@ -34,8 +34,6 @@ defmodule Legendary.Content do
|
||||||
namespace: Legendary.Content,
|
namespace: Legendary.Content,
|
||||||
pattern: "**/*"
|
pattern: "**/*"
|
||||||
|
|
||||||
use PhoenixHtmlSanitizer, :basic_html
|
|
||||||
|
|
||||||
# Import convenience functions from controllers
|
# Import convenience functions from controllers
|
||||||
import Phoenix.Controller,
|
import Phoenix.Controller,
|
||||||
only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1]
|
only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1]
|
||||||
|
|
|
@ -67,9 +67,7 @@ defmodule Legendary.Content.PostsController do
|
||||||
conn |> show_one(post, page_string)
|
conn |> show_one(post, page_string)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
def show(conn, %{"id" => id, "page" => page_string}) when is_list(id) do
|
def show(conn, %{"id" => id} = params) when is_list(id), do: show(conn, Map.merge(params, %{"id" => Enum.join(id, "/")}))
|
||||||
show(conn, %{"id" => Enum.join(id, "/"), "page" => page_string})
|
|
||||||
end
|
|
||||||
def show(conn, %{"id" => id}), do: show(conn, %{"id" => id, "page" => "1"})
|
def show(conn, %{"id" => id}), do: show(conn, %{"id" => id, "page" => "1"})
|
||||||
|
|
||||||
defp try_static_post(conn, id) do
|
defp try_static_post(conn, id) do
|
||||||
|
@ -87,11 +85,12 @@ defmodule Legendary.Content.PostsController do
|
||||||
end
|
end
|
||||||
|
|
||||||
# The static page we're looking for is missing, so this is just a 404
|
# The static page we're looking for is missing, so this is just a 404
|
||||||
|
# credo:disable-for-next-line
|
||||||
raise Phoenix.Router.NoRouteError.exception(conn: conn, router: router)
|
raise Phoenix.Router.NoRouteError.exception(conn: conn, router: router)
|
||||||
_ ->
|
_ ->
|
||||||
# We aren't missing the static page, we're missing a partial. This is probably
|
# We aren't missing the static page, we're missing a partial. This is probably
|
||||||
# a developer error, so bubble it up
|
# a developer error, so bubble it up
|
||||||
raise e
|
reraise e, System.stacktrace
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -113,10 +112,20 @@ defmodule Legendary.Content.PostsController do
|
||||||
{:ok, decoded} = post.content |> Base.decode64
|
{:ok, decoded} = post.content |> Base.decode64
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> put_resp_content_type(post.mime_type, "binary")
|
|> put_resp_content_type(post.mime_type, charset(post.mime_type))
|
||||||
|> send_resp(conn.status || 200, decoded)
|
|> send_resp(conn.status || 200, decoded)
|
||||||
_ ->
|
_ ->
|
||||||
render(conn, template, post: post, page: page, thumbs: thumbs)
|
render(conn, template, post: post, page: page, thumbs: thumbs)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp charset(mime_type) do
|
||||||
|
do_charset(String.split(mime_type, "/"))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_charset(["application", _]), do: "binary"
|
||||||
|
defp do_charset(["video", _]), do: "binary"
|
||||||
|
defp do_charset(["audio", _]), do: "binary"
|
||||||
|
defp do_charset(["image", _]), do: "binary"
|
||||||
|
defp do_charset(_), do: "utf-8"
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
defmodule Legendary.Content.Routes do
|
defmodule Legendary.Content.Routes do
|
||||||
|
@moduledoc """
|
||||||
|
Routes for the content engine, including blog posts, feeds, and pages.
|
||||||
|
"""
|
||||||
|
|
||||||
defmacro __using__(_opts \\ []) do
|
defmacro __using__(_opts \\ []) do
|
||||||
quote do
|
quote do
|
||||||
pipeline :feed do
|
pipeline :feed do
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||||
<channel>
|
<channel>
|
||||||
<title><%= title(@view_module, @view_template, assigns) %></title>
|
<title><%= title(@conn, assigns) %></title>
|
||||||
<description><%= excerpt(@view_module, @view_template, assigns) %></description>
|
<description><%= excerpt(@conn, assigns) %></description>
|
||||||
<link><%= Legendary.Content.Router.Helpers.url(Legendary.CoreWeb.Endpoint) %></link>
|
<link><%= Legendary.Content.Router.Helpers.url(Legendary.CoreWeb.Endpoint) %></link>
|
||||||
<atom:link href="<%= Legendary.Content.Router.Helpers.url(Legendary.CoreWeb.Endpoint) %><%= @feed_url %>" rel="self" type="application/rss+xml" />
|
<atom:link href="<%= Legendary.Content.Router.Helpers.url(Legendary.CoreWeb.Endpoint) %><%= @feed_url %>" rel="self" type="application/rss+xml" />
|
||||||
|
|
||||||
<%= for post <- @posts do %>
|
<%= for post <- @posts do %>
|
||||||
|
<%= if unauthenticated_post?(post) do %>
|
||||||
<item>
|
<item>
|
||||||
<title><%= post.title |> HtmlSanitizeEx.strip_tags() %></title>
|
<title><%= post.title |> HtmlSanitizeEx.strip_tags() %></title>
|
||||||
<description>
|
<description>
|
||||||
|
@ -19,6 +20,7 @@
|
||||||
%></pubDate>
|
%></pubDate>
|
||||||
<guid isPermaLink="true"><%= post.guid %></guid>
|
<guid isPermaLink="true"><%= post.guid %></guid>
|
||||||
</item>
|
</item>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
|
|
|
@ -4,9 +4,9 @@
|
||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
|
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
<title><%= title(@view_module, @view_template, assigns) %></title>
|
<title><%= title(@conn, assigns) %></title>
|
||||||
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
|
<link phx-track-static rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
|
||||||
<%= feed_tag(@conn, @view_module, @view_template, assigns) %>
|
<%= feed_tag(@conn, assigns) %>
|
||||||
<script phx-track-static defer type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
|
<script phx-track-static defer type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="text-gray-800 antialiased">
|
<body class="text-gray-800 antialiased">
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<%= sanitize comment.content |> auto_paragraph_tags |> elem(1) |> IO.iodata_to_binary() %>
|
<%= HtmlSanitizeEx.basic_html(comment.content |> auto_paragraph_tags |> elem(1) |> IO.iodata_to_binary()) %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end) %>
|
<% end) %>
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
defmodule Legendary.ContentWeb.Uploaders.SocialMediaPreview do
|
||||||
|
@moduledoc """
|
||||||
|
Uploader definition for social media preview images.
|
||||||
|
"""
|
||||||
|
use Waffle.Definition
|
||||||
|
|
||||||
|
@versions [:original]
|
||||||
|
|
||||||
|
# Override the persisted filenames:
|
||||||
|
def filename(version, _) do
|
||||||
|
Atom.to_string(version)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Override the storage directory:
|
||||||
|
def storage_dir(_version, {_file, %{name: name}}) do
|
||||||
|
"public_uploads/content/posts/preview_images/#{name}"
|
||||||
|
end
|
||||||
|
end
|
|
@ -8,10 +8,12 @@ defmodule Legendary.Content.ErrorHelpers do
|
||||||
@doc """
|
@doc """
|
||||||
Generates tag for inlined form input errors.
|
Generates tag for inlined form input errors.
|
||||||
"""
|
"""
|
||||||
def error_tag(form, field) do
|
def error_tag(form, field, opts \\ []) do
|
||||||
|
{extra_classes, _rest_opts} = Keyword.pop(opts, :class, "")
|
||||||
|
|
||||||
Enum.map(Keyword.get_values(form.errors, field), fn error ->
|
Enum.map(Keyword.get_values(form.errors, field), fn error ->
|
||||||
content_tag(:span, translate_error(error),
|
content_tag(:span, translate_error(error),
|
||||||
class: "invalid-feedback",
|
class: "invalid-feedback #{extra_classes}",
|
||||||
phx_feedback_for: input_id(form, field)
|
phx_feedback_for: input_id(form, field)
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
|
|
|
@ -1,71 +1,10 @@
|
||||||
defmodule Legendary.Content.FeedsView do
|
defmodule Legendary.Content.FeedsView do
|
||||||
use Legendary.Content, :view
|
use Legendary.Content, :view
|
||||||
use Phoenix.HTML
|
use Phoenix.HTML
|
||||||
alias Phoenix.HTML
|
|
||||||
alias Phoenix.HTML.Tag
|
|
||||||
|
|
||||||
import Legendary.Content.LayoutView, only: [title: 3, excerpt: 3]
|
import Legendary.Content.LayoutView, only: [title: 2, excerpt: 2]
|
||||||
|
|
||||||
def gravatar_url_for_email(email) do
|
def unauthenticated_post?(post) do
|
||||||
email
|
|
||||||
|> Kernel.||("noreply@example.com")
|
|
||||||
|> String.trim()
|
|
||||||
|> String.downcase()
|
|
||||||
|> (&(:crypto.hash(:md5, &1))).()
|
|
||||||
|> Base.encode16()
|
|
||||||
|> String.downcase()
|
|
||||||
|> (&("https://www.gravatar.com/avatar/#{&1}")).()
|
|
||||||
end
|
|
||||||
|
|
||||||
def auto_paragraph_tags(string) do
|
|
||||||
string
|
|
||||||
|> Kernel.||("")
|
|
||||||
|> String.split(["\n\n", "\r\n\r\n"], trim: true)
|
|
||||||
|> Enum.map(fn text ->
|
|
||||||
[Tag.content_tag(:p, text |> HTML.raw(), []), ?\n]
|
|
||||||
end)
|
|
||||||
|> HTML.html_escape()
|
|
||||||
end
|
|
||||||
|
|
||||||
def post_class(post) do
|
|
||||||
sticky =
|
|
||||||
if post.sticky do
|
|
||||||
"sticky"
|
|
||||||
end
|
|
||||||
"post post-#{post.id} #{sticky}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def post_topmatter(conn, post) do
|
|
||||||
author =
|
|
||||||
post.author ||
|
|
||||||
%Legendary.Auth.User{
|
|
||||||
email: "example@example.org",
|
|
||||||
display_name: "Anonymous",
|
|
||||||
homepage_url: "#"
|
|
||||||
}
|
|
||||||
assigns = %{post: post, author: author, conn: conn}
|
|
||||||
~E"""
|
|
||||||
<% _ = assigns # suppress unused assigns warning %>
|
|
||||||
<div class="Comment-topmatter">
|
|
||||||
|
|
||||||
<h4>
|
|
||||||
<%= link to: author.homepage_url || "#", rel: "author", class: "p-author h-card" do %>
|
|
||||||
<%= author.display_name %>
|
|
||||||
<%= img_tag gravatar_url_for_email(author.email), alt: "Photo of #{author.display_name}", class: "Gravatar u-photo" %>
|
|
||||||
<% end %>
|
|
||||||
</h4>
|
|
||||||
<h5>
|
|
||||||
<%= link to: Routes.posts_path(conn, :show, post) do %>
|
|
||||||
<time class="dt-published" datetime="<%= post.post %>">
|
|
||||||
<%= post.post |> Timex.format!("%F", :strftime) %>
|
|
||||||
</time>
|
|
||||||
<% end %>
|
|
||||||
</h5>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
|
|
||||||
def unauthenticated_post?(_conn, post) do
|
|
||||||
post.password == nil || String.length(post.password) == 0
|
post.password == nil || String.length(post.password) == 0
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
defmodule Legendary.Content.LayoutView do
|
defmodule Legendary.Content.LayoutView do
|
||||||
use Legendary.Content, :view
|
use Legendary.Content, :view
|
||||||
|
|
||||||
|
def feed_tag(conn, assigns), do: feed_tag(conn, view_module(conn), view_template(conn), assigns)
|
||||||
|
|
||||||
def feed_tag(conn, view_module, view_template, assigns) do
|
def feed_tag(conn, view_module, view_template, assigns) do
|
||||||
~E"""
|
~E"""
|
||||||
<link
|
<link
|
||||||
|
@ -12,6 +14,8 @@ defmodule Legendary.Content.LayoutView do
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def title(conn, assigns), do: title(view_module(conn), view_template(conn), assigns)
|
||||||
|
|
||||||
def title(Legendary.Content.PostsView, "index.html", assigns) do
|
def title(Legendary.Content.PostsView, "index.html", assigns) do
|
||||||
"Page #{assigns.page} | #{title(nil, nil, nil)}"
|
"Page #{assigns.page} | #{title(nil, nil, nil)}"
|
||||||
end
|
end
|
||||||
|
@ -26,6 +30,8 @@ defmodule Legendary.Content.LayoutView do
|
||||||
|
|
||||||
def title(_, _, _), do: Legendary.I18n.t! "en", "site.title"
|
def title(_, _, _), do: Legendary.I18n.t! "en", "site.title"
|
||||||
|
|
||||||
|
def excerpt(conn, assigns), do: excerpt(view_module(conn), view_template(conn), assigns)
|
||||||
|
|
||||||
def excerpt(Legendary.Content.PostsView, "show.html", assigns) do
|
def excerpt(Legendary.Content.PostsView, "show.html", assigns) do
|
||||||
assigns.post.excerpt
|
assigns.post.excerpt
|
||||||
|> HtmlSanitizeEx.strip_tags()
|
|> HtmlSanitizeEx.strip_tags()
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
defmodule Legendary.Content.MixProject do
|
defmodule Legendary.Content.MixProject do
|
||||||
use Mix.Project
|
use Mix.Project
|
||||||
|
|
||||||
@version "2.11.5"
|
@version "4.3.0"
|
||||||
|
|
||||||
def project do
|
def project do
|
||||||
[
|
[
|
||||||
|
@ -11,7 +11,7 @@ defmodule Legendary.Content.MixProject do
|
||||||
config_path: "../../config/config.exs",
|
config_path: "../../config/config.exs",
|
||||||
deps_path: "../../deps",
|
deps_path: "../../deps",
|
||||||
lockfile: "../../mix.lock",
|
lockfile: "../../mix.lock",
|
||||||
elixir: "~> 1.7",
|
elixir: "~> 1.10",
|
||||||
elixirc_paths: elixirc_paths(Mix.env()),
|
elixirc_paths: elixirc_paths(Mix.env()),
|
||||||
compilers: [:phoenix, :gettext] ++ Mix.compilers(),
|
compilers: [:phoenix, :gettext] ++ Mix.compilers(),
|
||||||
start_permanent: Mix.env() == :prod,
|
start_permanent: Mix.env() == :prod,
|
||||||
|
@ -46,23 +46,25 @@ defmodule Legendary.Content.MixProject do
|
||||||
{:excoveralls, "~> 0.10", only: [:dev, :test]},
|
{:excoveralls, "~> 0.10", only: [:dev, :test]},
|
||||||
{:floki, ">= 0.30.0"},
|
{:floki, ">= 0.30.0"},
|
||||||
{:gettext, "~> 0.11"},
|
{:gettext, "~> 0.11"},
|
||||||
|
{:html_sanitize_ex, "~> 1.4.1"},
|
||||||
{:jason, "~> 1.0"},
|
{:jason, "~> 1.0"},
|
||||||
|
{:mime, "~> 2.0.1"},
|
||||||
{:mock, "~> 0.3.0", only: :test},
|
{:mock, "~> 0.3.0", only: :test},
|
||||||
{:meck, "~> 0.8.13", only: :test},
|
{:meck, "~> 0.8.13", only: :test},
|
||||||
{:neotomex, "~> 0.1.7"},
|
{:neotomex, "~> 0.1.7"},
|
||||||
{:oban, "~> 2.1"},
|
{:oban, "~> 2.9"},
|
||||||
{:phoenix, "~> 1.5.8"},
|
{:phoenix, "~> 1.6.0"},
|
||||||
{:phoenix_ecto, "~> 4.0"},
|
{:phoenix_ecto, "~> 4.4"},
|
||||||
{:phoenix_html, "~> 2.11"},
|
{:ecto_sql, "~> 3.7"},
|
||||||
{:phoenix_html_sanitizer, "~> 1.1.0"},
|
{:phoenix_html, "~> 3.0.4", override: true},
|
||||||
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
{:phoenix_live_reload, "~> 1.3", only: :dev},
|
||||||
{:phoenix_live_dashboard, "~> 0.4.0"},
|
{:phoenix_live_dashboard, "~> 0.5.0"},
|
||||||
{:php_serializer, "~> 2.0.0"},
|
{:php_serializer, "~> 2.0.0"},
|
||||||
{:plug_cowboy, "~> 2.0"},
|
{:plug_cowboy, "~> 2.0"},
|
||||||
{:sitemap, "~> 1.1"},
|
{:sitemap, "~> 1.1"},
|
||||||
{:slugger, "~> 0.3"},
|
{:slugger, "~> 0.3"},
|
||||||
{:telemetry_metrics, "~> 0.4"},
|
{:telemetry_metrics, "~> 0.4"},
|
||||||
{:telemetry_poller, "~> 0.4"},
|
{:telemetry_poller, "~> 1.0"},
|
||||||
{:timex, "~> 3.1"},
|
{:timex, "~> 3.1"},
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
36
apps/content/test/content/posts/preview_images_test.exs
Normal file
36
apps/content/test/content/posts/preview_images_test.exs
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
defmodule Legendary.Content.Posts.PreviewImagesTest do
|
||||||
|
use Legendary.Content.DataCase
|
||||||
|
|
||||||
|
import Legendary.Content.Posts.PreviewImages
|
||||||
|
|
||||||
|
alias Legendary.Content.Post
|
||||||
|
|
||||||
|
@png "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAAXNSR0IArs4c6QAAAMJlWElmTU0AKgAAAAgABwESAAMAAAABAAEAAAEaAAUAAAABAAAAYgEbAAUAAAABAAAAagEoAAMAAAABAAIAAAExAAIAAAARAAAAcgEyAAIAAAAUAAAAhIdpAAQAAAABAAAAmAAAAAAAAABIAAAAAQAAAEgAAAABUGl4ZWxtYXRvciAzLjkuOAAAMjAyMTowOToyMiAxNTowOTowMQAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAAaADAAQAAAABAAAAAQAAAAAYjzhKAAAACXBIWXMAAAsTAAALEwEAmpwYAAADpmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgICAgICAgICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICAgICAgICAgIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyI+CiAgICAgICAgIDx0aWZmOkNvbXByZXNzaW9uPjA8L3RpZmY6Q29tcHJlc3Npb24+CiAgICAgICAgIDx0aWZmOlJlc29sdXRpb25Vbml0PjI8L3RpZmY6UmVzb2x1dGlvblVuaXQ+CiAgICAgICAgIDx0aWZmOlhSZXNvbHV0aW9uPjcyPC90aWZmOlhSZXNvbHV0aW9uPgogICAgICAgICA8dGlmZjpZUmVzb2x1dGlvbj43MjwvdGlmZjpZUmVzb2x1dGlvbj4KICAgICAgICAgPHRpZmY6T3JpZW50YXRpb24+MTwvdGlmZjpPcmllbnRhdGlvbj4KICAgICAgICAgPGV4aWY6UGl4ZWxYRGltZW5zaW9uPjE8L2V4aWY6UGl4ZWxYRGltZW5zaW9uPgogICAgICAgICA8ZXhpZjpDb2xvclNwYWNlPjE8L2V4aWY6Q29sb3JTcGFjZT4KICAgICAgICAgPGV4aWY6UGl4ZWxZRGltZW5zaW9uPjE8L2V4aWY6UGl4ZWxZRGltZW5zaW9uPgogICAgICAgICA8eG1wOkNyZWF0b3JUb29sPlBpeGVsbWF0b3IgMy45Ljg8L3htcDpDcmVhdG9yVG9vbD4KICAgICAgICAgPHhtcDpNb2RpZnlEYXRlPjIwMjEtMDktMjJUMTU6MDk6MDE8L3htcDpNb2RpZnlEYXRlPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4K/LzAdAAAAAxJREFUCB1j+P//PwAF/gL+n8otEwAAAABJRU5ErkJggg=="
|
||||||
|
@bad_png "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAAACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII%3D"
|
||||||
|
|
||||||
|
@path "priv/test/static/public_uploads/content/posts/preview_images/preview-image-test/original.png"
|
||||||
|
|
||||||
|
describe "handle_preview_image_upload/2" do
|
||||||
|
test "stores an image given as a data uri" do
|
||||||
|
File.rm(@path)
|
||||||
|
|
||||||
|
changeset =
|
||||||
|
%Post{name: "preview-image-test", status: :draft}
|
||||||
|
|> Post.changeset()
|
||||||
|
|
||||||
|
assert handle_preview_image_upload(changeset, %{"social_media_preview_image" => @png}).valid?
|
||||||
|
assert File.exists?(@path)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "adds a validation error if the image is invalid" do
|
||||||
|
File.rm(@path)
|
||||||
|
|
||||||
|
changeset =
|
||||||
|
%Post{name: "bad-image-test", status: :draft}
|
||||||
|
|> Post.changeset()
|
||||||
|
|
||||||
|
refute handle_preview_image_upload(changeset, %{"social_media_preview_image" => @bad_png}).valid?
|
||||||
|
refute File.exists?(@path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -45,14 +45,38 @@ defmodule Legendary.Content.PostsTest do
|
||||||
assert %Post{} = Posts.get_post_with_drafts!(Integer.to_string(id))
|
assert %Post{} = Posts.get_post_with_drafts!(Integer.to_string(id))
|
||||||
end
|
end
|
||||||
|
|
||||||
test "update_posts/2", %{public_post: post} do
|
describe "update_posts/2" do
|
||||||
assert {:ok, %Post{content: "boop"}} = Posts.update_posts(post, %{content: "boop"})
|
setup do
|
||||||
|
post_with_guid =
|
||||||
|
%Post{
|
||||||
|
title: "Post with guid",
|
||||||
|
name: "post-with-guid",
|
||||||
|
status: "publish",
|
||||||
|
type: "post",
|
||||||
|
guid: "/beep",
|
||||||
|
date: ~N[2020-01-01T00:00:00],
|
||||||
|
}
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
%{
|
||||||
|
post_with_guid: post_with_guid
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "with no guid", %{public_post: post} do
|
||||||
|
assert {:ok, %Post{content: "boop"}} = Posts.update_posts(post, %{content: "boop"})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "with an existing guid", %{post_with_guid: post} do
|
||||||
|
revised_post = Posts.update_posts(post, %{content: "boop"})
|
||||||
|
assert {:ok, %Post{content: "boop", guid: "/beep"}} = revised_post
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "delete_posts/1", %{public_post: post} do
|
test "delete_posts/1", %{public_post: post} do
|
||||||
assert Enum.count(Posts.list_posts()) == 1
|
assert Enum.count(Posts.list_posts()) == 1
|
||||||
assert {:ok, _} = Posts.delete_posts(post)
|
assert {:ok, _} = Posts.delete_posts(post)
|
||||||
assert Enum.count(Posts.list_posts()) == 0
|
assert Enum.empty?(Posts.list_posts())
|
||||||
end
|
end
|
||||||
|
|
||||||
test "change_posts/1", %{public_post: post} do
|
test "change_posts/1", %{public_post: post} do
|
||||||
|
|
38
apps/content/test/content/sitemap_storage_test.exs
Normal file
38
apps/content/test/content/sitemap_storage_test.exs
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
defmodule Legendary.Content.SitemapStorageTest do
|
||||||
|
use Legendary.Content.DataCase
|
||||||
|
|
||||||
|
import Legendary.Content.SitemapStorage
|
||||||
|
alias Sitemap.Location
|
||||||
|
alias Legendary.Content.{Post, Repo}
|
||||||
|
|
||||||
|
test "creates a post with the content" do
|
||||||
|
data = "<hello />"
|
||||||
|
content = data |> :zlib.gzip() |> Base.encode64
|
||||||
|
write(:file, data)
|
||||||
|
path = Location.filename(:file)
|
||||||
|
|
||||||
|
post = from(p in Post, where: p.name == ^path) |> Repo.one()
|
||||||
|
|
||||||
|
assert post.content == content
|
||||||
|
end
|
||||||
|
|
||||||
|
test "updates an existing sitemap" do
|
||||||
|
path = Location.filename(:file)
|
||||||
|
|
||||||
|
%Post{
|
||||||
|
content: "<hello />" |> :zlib.gzip() |> Base.encode64,
|
||||||
|
name: path
|
||||||
|
}
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
new_data = "<world />"
|
||||||
|
new_content = new_data |> :zlib.gzip() |> Base.encode64
|
||||||
|
|
||||||
|
write(:file, new_data)
|
||||||
|
|
||||||
|
|
||||||
|
post = from(p in Post, where: p.name == ^path) |> Repo.one()
|
||||||
|
|
||||||
|
assert post.content == new_content
|
||||||
|
end
|
||||||
|
end
|
78
apps/content/test/content/sitemaps_test.exs
Normal file
78
apps/content/test/content/sitemaps_test.exs
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
defmodule Legendary.Content.SitemapsTest do
|
||||||
|
use Legendary.Content.DataCase
|
||||||
|
|
||||||
|
alias Legendary.Content.{Post, Repo}
|
||||||
|
|
||||||
|
import Mock
|
||||||
|
|
||||||
|
import Legendary.Content.Sitemaps
|
||||||
|
|
||||||
|
@xml ~s(
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset
|
||||||
|
xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance'
|
||||||
|
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
|
||||||
|
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"
|
||||||
|
xmlns='http://www.sitemaps.org/schemas/sitemap/0.9'
|
||||||
|
xmlns:geo='http://www.google.com/geo/schemas/sitemap/1.0'
|
||||||
|
xmlns:news='http://www.google.com/schemas/sitemap-news/0.9'
|
||||||
|
xmlns:image='http://www.google.com/schemas/sitemap-image/1.1'
|
||||||
|
xmlns:video='http://www.google.com/schemas/sitemap-video/1.1'
|
||||||
|
xmlns:mobile='http://www.google.com/schemas/sitemap-mobile/1.0'
|
||||||
|
xmlns:pagemap='http://www.google.com/schemas/sitemap-pagemap/1.0'
|
||||||
|
xmlns:xhtml='http://www.w3.org/1999/xhtml'
|
||||||
|
>
|
||||||
|
<url>
|
||||||
|
<loc>https://localhost</loc>
|
||||||
|
<lastmod>2021-08-19T21:44:37Z</lastmod>
|
||||||
|
<changefreq>hourly</changefreq>
|
||||||
|
<priority>0.5</priority>
|
||||||
|
</url><url>
|
||||||
|
<loc>https://localhost/public-post</loc>
|
||||||
|
<lastmod>2021-08-19T21:44:37Z</lastmod>
|
||||||
|
<changefreq>hourly</changefreq>
|
||||||
|
<priority>0.5</priority>
|
||||||
|
</url><url>
|
||||||
|
<loc>https://localhost/public-post?page=2</loc>
|
||||||
|
<lastmod>2021-08-19T21:44:37Z</lastmod>
|
||||||
|
<changefreq>hourly</changefreq>
|
||||||
|
<priority>0.5</priority>
|
||||||
|
</url></urlset>
|
||||||
|
)
|
||||||
|
|
||||||
|
describe "generate/0" do
|
||||||
|
setup do
|
||||||
|
public_post =
|
||||||
|
%Post{
|
||||||
|
title: "Public post",
|
||||||
|
name: "public-post",
|
||||||
|
status: "publish",
|
||||||
|
type: "post",
|
||||||
|
date: ~N[2020-01-01T00:00:00],
|
||||||
|
content: """
|
||||||
|
Page 1
|
||||||
|
<!--nextpage-->
|
||||||
|
Page 2
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
%{
|
||||||
|
public_post: public_post,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "generates results" do
|
||||||
|
with_mock Sitemap.Funcs, [
|
||||||
|
iso8601: fn -> "2021-08-19T21:44:37Z" end,
|
||||||
|
iso8601: & &1,
|
||||||
|
eraser: fn (elm) -> passthrough([elm]) end
|
||||||
|
] do
|
||||||
|
with_mock Legendary.Content.SitemapStorage, [write: fn (_name, _data) -> :ok end] do
|
||||||
|
assert :ok = perform(%{})
|
||||||
|
assert_called Legendary.Content.SitemapStorage.write(:file, String.trim(@xml))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -24,7 +24,7 @@ defmodule Legendary.Content.CommentControllerTest do
|
||||||
post = fixture(:post)
|
post = fixture(:post)
|
||||||
conn = post conn, Routes.comment_path(conn, :create), comment: @create_attrs
|
conn = post conn, Routes.comment_path(conn, :create), comment: @create_attrs
|
||||||
|
|
||||||
assert %{id: id} = redirected_params(conn)
|
assert %{id: _} = redirected_params(conn)
|
||||||
assert redirected_to(conn) == Routes.posts_path(conn, :show, post)
|
assert redirected_to(conn) == Routes.posts_path(conn, :show, post)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
defmodule Legendary.Content.PostsControllerTest do
|
defmodule Legendary.Content.PostsControllerTest do
|
||||||
use Legendary.Content.ConnCase
|
use Legendary.Content.ConnCase
|
||||||
|
|
||||||
alias Legendary.Content.{Comment, Options, Posts, Repo, Term, TermRelationship, TermTaxonomy}
|
alias Legendary.Content.{Comment, Options, Post, Posts, Repo, Term, TermRelationship, TermTaxonomy}
|
||||||
|
|
||||||
@create_attrs %{
|
@create_attrs %{
|
||||||
id: 123,
|
id: 123,
|
||||||
|
@ -165,6 +165,21 @@ defmodule Legendary.Content.PostsControllerTest do
|
||||||
assert html_response(conn, 200)
|
assert html_response(conn, 200)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "shows the post if the id has slashes", %{conn: conn} do
|
||||||
|
%Post{
|
||||||
|
name: "a/b/c",
|
||||||
|
content: "slashed id",
|
||||||
|
status: "publish",
|
||||||
|
type: "post",
|
||||||
|
date: ~N[2020-01-01T00:00:00]
|
||||||
|
}
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
conn = get conn, Routes.nested_posts_path(conn, :show, ["a", "b", "c"])
|
||||||
|
|
||||||
|
assert html_response(conn, 200)
|
||||||
|
end
|
||||||
|
|
||||||
test "show a 404 if there's no match", %{conn: conn} do
|
test "show a 404 if there's no match", %{conn: conn} do
|
||||||
assert_raise Phoenix.Router.NoRouteError, fn ->
|
assert_raise Phoenix.Router.NoRouteError, fn ->
|
||||||
get conn, Routes.posts_path(conn, :show, "blooper")
|
get conn, Routes.posts_path(conn, :show, "blooper")
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
defmodule Legendary.ContentWeb.Uploaders.SocialMediaPreviewTest do
|
||||||
|
use Legendary.Content.DataCase
|
||||||
|
|
||||||
|
import Legendary.ContentWeb.Uploaders.SocialMediaPreview
|
||||||
|
|
||||||
|
describe "filename/2" do
|
||||||
|
test "" do
|
||||||
|
assert filename(:original, {%{file_name: "original.png"}, nil}) =~ "original"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "storage_dir/2" do
|
||||||
|
test "" do
|
||||||
|
assert storage_dir(nil, {nil, %{name: "test-slug"}}) =~ "public_uploads/content/posts/preview_images/test-slug"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
29
apps/content/test/content_web/views/error_helpers_test.exs
Normal file
29
apps/content/test/content_web/views/error_helpers_test.exs
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
defmodule Legendary.Content.ErrorHelpersTest do
|
||||||
|
use Legendary.Content.DataCase
|
||||||
|
|
||||||
|
import Phoenix.HTML, only: [safe_to_string: 1]
|
||||||
|
import Phoenix.HTML.Form, only: [form_for: 3]
|
||||||
|
|
||||||
|
import Legendary.Content.ErrorHelpers
|
||||||
|
|
||||||
|
def form do
|
||||||
|
:example
|
||||||
|
|> form_for(
|
||||||
|
"/example",
|
||||||
|
as: :test_params,
|
||||||
|
errors: [error_field: {"is an error", []}]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "error_tag/2" do
|
||||||
|
test "generates a span with an invalid-feedback class" do
|
||||||
|
[safe] = error_tag(form(), :error_field)
|
||||||
|
assert safe_to_string(safe) =~ "invalid-feedback"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "error_tag/3" do
|
||||||
|
[safe] = error_tag(form(), :error_field, class: "test-class")
|
||||||
|
assert safe_to_string(safe) =~ "test-class"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
17
apps/content/test/content_web/views/feeds_view_test.exs
Normal file
17
apps/content/test/content_web/views/feeds_view_test.exs
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
defmodule Legendary.Content.FeedsViewTest do
|
||||||
|
use Legendary.Content.DataCase
|
||||||
|
|
||||||
|
import Legendary.Content.FeedsView
|
||||||
|
|
||||||
|
alias Legendary.Content.Post
|
||||||
|
|
||||||
|
describe "unauthenticated_post?/1" do
|
||||||
|
test "with post password" do
|
||||||
|
refute unauthenticated_post?(%Post{password: "password"})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "without post password" do
|
||||||
|
assert unauthenticated_post?(%Post{})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -4,6 +4,8 @@ defmodule Legendary.Content.PostsViewTest do
|
||||||
import Legendary.Content.PostsView
|
import Legendary.Content.PostsView
|
||||||
import Phoenix.HTML, only: [safe_to_string: 1]
|
import Phoenix.HTML, only: [safe_to_string: 1]
|
||||||
|
|
||||||
|
alias Legendary.Content.Post
|
||||||
|
|
||||||
test "auto_paragraph_tags/1 with nil" do
|
test "auto_paragraph_tags/1 with nil" do
|
||||||
assert safe_to_string(auto_paragraph_tags(nil)) =~ ""
|
assert safe_to_string(auto_paragraph_tags(nil)) =~ ""
|
||||||
end
|
end
|
||||||
|
@ -11,4 +13,22 @@ defmodule Legendary.Content.PostsViewTest do
|
||||||
test "auto_paragraph_tags/1 with text" do
|
test "auto_paragraph_tags/1 with text" do
|
||||||
assert safe_to_string(auto_paragraph_tags("Bloop\n\nBloop")) =~ "<p>Bloop</p>\n<p>Bloop</p>"
|
assert safe_to_string(auto_paragraph_tags("Bloop\n\nBloop")) =~ "<p>Bloop</p>\n<p>Bloop</p>"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "authenticated_for_post?/2" do
|
||||||
|
test "without password" do
|
||||||
|
assert authenticated_for_post?(nil, %Post{})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "with post password that matches", %{conn: conn} do
|
||||||
|
with_mock Plug.Conn, [get_session: fn (_conn, :post_password) -> "password" end] do
|
||||||
|
assert authenticated_for_post?(conn, %Post{password: "password"})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "with post password that does not match", %{conn: conn} do
|
||||||
|
with_mock Plug.Conn, [get_session: fn (_conn, :post_password) -> "password" end] do
|
||||||
|
refute authenticated_for_post?(conn, %Post{password: "password2"})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
0
apps/content/test/seed_sets/.keep
Normal file
0
apps/content/test/seed_sets/.keep
Normal file
3
apps/core/cypress.json
Normal file
3
apps/core/cypress.json
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"baseUrl": "http://localhost:4002"
|
||||||
|
}
|
5
apps/core/cypress/integration/example_spec.js
Normal file
5
apps/core/cypress/integration/example_spec.js
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
describe('My First Test', () => {
|
||||||
|
it('Does not do much!', () => {
|
||||||
|
expect(true).to.equal(true)
|
||||||
|
})
|
||||||
|
})
|
22
apps/core/cypress/plugins/index.js
Normal file
22
apps/core/cypress/plugins/index.js
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
/// <reference types="cypress" />
|
||||||
|
// ***********************************************************
|
||||||
|
// This example plugins/index.js can be used to load plugins
|
||||||
|
//
|
||||||
|
// You can change the location of this file or turn off loading
|
||||||
|
// the plugins file with the 'pluginsFile' configuration option.
|
||||||
|
//
|
||||||
|
// You can read more here:
|
||||||
|
// https://on.cypress.io/plugins-guide
|
||||||
|
// ***********************************************************
|
||||||
|
|
||||||
|
// This function is called when a project is opened or re-opened (e.g. due to
|
||||||
|
// the project's config changing)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {Cypress.PluginConfig}
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
module.exports = (on, config) => {
|
||||||
|
// `on` is used to hook into various events Cypress emits
|
||||||
|
// `config` is the resolved Cypress config
|
||||||
|
}
|
36
apps/core/cypress/support/commands.js
Normal file
36
apps/core/cypress/support/commands.js
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
// ***********************************************
|
||||||
|
// This example commands.js shows you how to
|
||||||
|
// create various custom commands and overwrite
|
||||||
|
// existing commands.
|
||||||
|
//
|
||||||
|
// For more comprehensive examples of custom
|
||||||
|
// commands please read more here:
|
||||||
|
// https://on.cypress.io/custom-commands
|
||||||
|
// ***********************************************
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a parent command --
|
||||||
|
// Cypress.Commands.add('login', (email, password) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a child command --
|
||||||
|
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a dual command --
|
||||||
|
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This will overwrite an existing command --
|
||||||
|
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||||
|
|
||||||
|
Cypress.Commands.add("setupDB", (app, seedSet) => {
|
||||||
|
cy.request('POST', '/end-to-end/db/setup', {
|
||||||
|
app,
|
||||||
|
seed_set: seedSet,
|
||||||
|
}).as('setupDB')
|
||||||
|
})
|
||||||
|
|
||||||
|
Cypress.Commands.add("teardownDB", () => {
|
||||||
|
cy.request('POST', '/end-to-end/db/teardown').as('teardownDB')
|
||||||
|
})
|
29
apps/core/cypress/support/index.js
Normal file
29
apps/core/cypress/support/index.js
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
// ***********************************************************
|
||||||
|
// This example support/index.js is processed and
|
||||||
|
// loaded automatically before your test files.
|
||||||
|
//
|
||||||
|
// This is a great place to put global configuration and
|
||||||
|
// behavior that modifies Cypress.
|
||||||
|
//
|
||||||
|
// You can change the location of this file or turn off
|
||||||
|
// automatically serving support files with the
|
||||||
|
// 'supportFile' configuration option.
|
||||||
|
//
|
||||||
|
// You can read more here:
|
||||||
|
// https://on.cypress.io/configuration
|
||||||
|
// ***********************************************************
|
||||||
|
|
||||||
|
// Import commands.js using ES2015 syntax:
|
||||||
|
import './commands'
|
||||||
|
|
||||||
|
// Alternatively you can use CommonJS syntax:
|
||||||
|
// require('./commands')
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
// Make sure we don't have a rogue DB connection checked out
|
||||||
|
cy.teardownDB()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cy.teardownDB()
|
||||||
|
})
|
4
apps/core/guides/assets/testing-pyramid.svg
Normal file
4
apps/core/guides/assets/testing-pyramid.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 9.4 KiB |
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue