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
|
||||
*.dump
|
||||
.journal-*
|
||||
.git
|
||||
|
||||
.priv/mnesia*
|
||||
|
|
10
.gitignore
vendored
10
.gitignore
vendored
|
@ -33,6 +33,9 @@ node_modules
|
|||
# this depending on your deployment strategy.
|
||||
/priv/static/
|
||||
|
||||
# Temp files generated by tests
|
||||
/apps/*/priv/test/
|
||||
|
||||
# Mnesia DBs
|
||||
/apps/*/priv/mnesia*
|
||||
/priv/mnesia*
|
||||
|
@ -44,3 +47,10 @@ config/*.secret.exs
|
|||
|
||||
# Temporary CI build file
|
||||
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)
|
||||
GIT_STRATEGY: fetch
|
||||
|
||||
include:
|
||||
- .gitlab/ci/.cypress.yml
|
||||
|
||||
# Test stage. Runs various tests and speculatively builds docker image in
|
||||
# parallel, in case the build passes.
|
||||
.test_template: &test_template
|
||||
|
@ -24,9 +27,11 @@ variables:
|
|||
- deps/
|
||||
services:
|
||||
- name: postgres:12
|
||||
script: script/cibuild
|
||||
script:
|
||||
- apk add git
|
||||
- script/cibuild
|
||||
|
||||
test:
|
||||
test_1.10.4:
|
||||
<<: *test_template
|
||||
image: "elixir:1.10.4-alpine"
|
||||
|
||||
|
@ -34,9 +39,47 @@ test_1.11.4:
|
|||
<<: *test_template
|
||||
image: "elixir:1.11.4-alpine"
|
||||
|
||||
test_1.12.1:
|
||||
test:
|
||||
<<: *test_template
|
||||
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:
|
||||
stage: test
|
||||
|
@ -56,10 +99,31 @@ build_image_for_commit:
|
|||
script:
|
||||
- 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
|
||||
deploy_to_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"
|
||||
only:
|
||||
- master
|
||||
|
@ -116,7 +180,7 @@ deploy_commit_image_to_tag:
|
|||
|
||||
.dependabot_gitlab: &dependabot_gitlab
|
||||
image:
|
||||
name: docker.io/andrcuns/dependabot-gitlab:0.4.3
|
||||
name: docker.io/andrcuns/dependabot-gitlab:0.9.7
|
||||
entrypoint: [""]
|
||||
variables:
|
||||
GIT_STRATEGY: none
|
||||
|
@ -150,6 +214,7 @@ npm-assets:
|
|||
- $PACKAGE_MANAGER_SET =~ /(\bnpm|yarn\b)/
|
||||
|
||||
mix-admin:
|
||||
timeout: 3 hours 30 minutes # hopefully temporary hack because hex runs slowly in docker right now
|
||||
extends: .dependabot_gitlab
|
||||
variables:
|
||||
DIRECTORY: "/apps/admin"
|
||||
|
@ -159,6 +224,7 @@ mix-admin:
|
|||
- $PACKAGE_MANAGER_SET =~ /\bmix\b/
|
||||
|
||||
mix-app:
|
||||
timeout: 3 hours 30 minutes # hopefully temporary hack because hex runs slowly in docker right now
|
||||
extends: .dependabot_gitlab
|
||||
variables:
|
||||
DIRECTORY: "/apps/app"
|
||||
|
@ -168,6 +234,7 @@ mix-app:
|
|||
- $PACKAGE_MANAGER_SET =~ /\bmix\b/
|
||||
|
||||
mix-core:
|
||||
timeout: 3 hours 30 minutes # hopefully temporary hack because hex runs slowly in docker right now
|
||||
extends: .dependabot_gitlab
|
||||
variables:
|
||||
DIRECTORY: "/apps/core"
|
||||
|
@ -177,6 +244,7 @@ mix-core:
|
|||
- $PACKAGE_MANAGER_SET =~ /\bmix\b/
|
||||
|
||||
mix-content:
|
||||
timeout: 3 hours 30 minutes # hopefully temporary hack because hex runs slowly in docker right now
|
||||
extends: .dependabot_gitlab
|
||||
variables:
|
||||
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
|
||||
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 "readline"
|
||||
brew "unixodbc"
|
||||
brew "asdf"
|
||||
brew "curl"
|
||||
brew "fop"
|
||||
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 \
|
||||
&& 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
|
||||
# cache those layers, before we do the app build itself
|
||||
ADD ./priv ./priv
|
||||
ADD ./mix.exs ./mix.lock ./
|
||||
ADD ./config ./config
|
||||
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
|
||||
|
||||
FROM node:16.5.0 AS asset-builder
|
||||
FROM node:16.10.0 AS asset-builder
|
||||
|
||||
# Build assets in a node container
|
||||
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
|
||||
|
||||
import Ecto.Query
|
||||
import Ecto.Query.API, only: [field: 2]
|
||||
|
||||
def list_resource(conn, resource, params \\ %{}) do
|
||||
per_page = Map.get(params, "limit", "100") |> String.to_integer()
|
||||
|
|
|
@ -31,7 +31,7 @@ defmodule Kaffy.MixProject do
|
|||
defp deps do
|
||||
[
|
||||
{:phoenix, "~> 1.4"},
|
||||
{:phoenix_html, "~> 2.11"},
|
||||
{:phoenix_html, "~> 2.11 or ~> 3.0"},
|
||||
{:ecto, "~> 3.0"},
|
||||
{:ex_doc, ">= 0.0.0", only: :dev, runtime: false}
|
||||
]
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
defmodule Legendary.Admin.Routes do
|
||||
@moduledoc """
|
||||
Routes from the admin app. Use like:
|
||||
|
||||
use Legendary.Admin.Routes
|
||||
"""
|
||||
defmacro __using__(_opts \\ []) do
|
||||
quote do
|
||||
use Kaffy.Routes, scope: "/admin", pipe_through: [:require_admin]
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
defmodule Legendary.Admin.Telemetry do
|
||||
@moduledoc """
|
||||
Collect metrics from the admin app.
|
||||
"""
|
||||
|
||||
use Supervisor
|
||||
import Telemetry.Metrics
|
||||
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
defmodule Legendary.Admin.Kaffy.Config do
|
||||
@moduledoc """
|
||||
Pull in the resource list for the admin from the application config.
|
||||
"""
|
||||
|
||||
def create_resources(_conn) do
|
||||
config = Application.get_env(:admin, Legendary.Admin)
|
||||
|
||||
|
|
|
@ -1,14 +1,22 @@
|
|||
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
|
||||
[
|
||||
{: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" />)},
|
||||
tag(:meta, property: "og:site_name", content: Legendary.I18n.t!("en", "site.title"))
|
||||
]
|
||||
end
|
||||
|
||||
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>)},
|
||||
]
|
||||
end
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
defmodule Legendary.Admin.MixProject do
|
||||
use Mix.Project
|
||||
|
||||
@version "2.11.5"
|
||||
@version "4.3.0"
|
||||
|
||||
def project do
|
||||
[
|
||||
|
@ -12,7 +12,7 @@ defmodule Legendary.Admin.MixProject do
|
|||
config_path: "../../config/config.exs",
|
||||
deps_path: "../../deps",
|
||||
lockfile: "../../mix.lock",
|
||||
elixir: "~> 1.7",
|
||||
elixir: "~> 1.10",
|
||||
elixirc_paths: elixirc_paths(Mix.env()),
|
||||
compilers: [:phoenix, :gettext] ++ Mix.compilers(),
|
||||
start_permanent: Mix.env() == :prod,
|
||||
|
@ -43,17 +43,17 @@ defmodule Legendary.Admin.MixProject do
|
|||
defp deps do
|
||||
[
|
||||
{:core, in_umbrella: true},
|
||||
{:ecto_sql, "~> 3.4"},
|
||||
{:ecto_sql, "~> 3.7"},
|
||||
{:excoveralls, "~> 0.10", only: [:dev, :test]},
|
||||
{:kaffy, path: "kaffy"},
|
||||
{:phoenix, "~> 1.5.8"},
|
||||
{:phoenix_ecto, "~> 4.0"},
|
||||
{:phoenix_html, "~> 2.11"},
|
||||
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
||||
{:phoenix_live_dashboard, "~> 0.4.0"},
|
||||
{:phoenix, "~> 1.6.0"},
|
||||
{:phoenix_ecto, "~> 4.4"},
|
||||
{:phoenix_html, "~> 3.0.4", override: true},
|
||||
{:phoenix_live_reload, "~> 1.3", only: :dev},
|
||||
{:phoenix_live_dashboard, "~> 0.5.0"},
|
||||
{:postgrex, ">= 0.0.0"},
|
||||
{:telemetry_metrics, "~> 0.4"},
|
||||
{:telemetry_poller, "~> 0.4"},
|
||||
{:telemetry_poller, "~> 1.0"},
|
||||
{:gettext, "~> 0.11"},
|
||||
{:jason, "~> 1.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": [
|
||||
"@babel/preset-env"
|
||||
]
|
||||
"presets": ["@babel/preset-env"]
|
||||
}
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
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/components";
|
||||
|
||||
@import "tailwindcss/utilities";
|
||||
|
||||
@import "@fortawesome/fontawesome-free/css/all.css";
|
||||
|
||||
@import "blog";
|
||||
@import "code";
|
||||
|
||||
|
@ -14,27 +10,28 @@ body {
|
|||
}
|
||||
|
||||
input[type="checkbox"]::after {
|
||||
content: "";
|
||||
color: currentColor;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: inline-flex;
|
||||
color: currentColor;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
content: "";
|
||||
}
|
||||
|
||||
input[type="checkbox"]:checked::after {
|
||||
content: "✓";
|
||||
}
|
||||
|
||||
.hidden-options-toggle:checked+.hidden {
|
||||
.hidden-options-toggle:checked + .hidden {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.password-revealer {
|
||||
@apply absolute;
|
||||
height: 45px;
|
||||
width: 45px;
|
||||
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
}
|
||||
|
|
|
@ -57,7 +57,7 @@
|
|||
|
||||
.hljs {
|
||||
@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");
|
||||
--section-color: theme("colors.teal.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.
|
||||
// The MiniCssExtractPlugin is used to separate it out into
|
||||
// its own CSS file.
|
||||
import "../css/app.css"
|
||||
import "../css/app.css";
|
||||
|
||||
// webpack automatically bundles all modules in your
|
||||
// entry points. Those entry points can be configured
|
||||
|
@ -12,48 +12,46 @@ import "../css/app.css"
|
|||
// import {Socket} from "phoenix"
|
||||
// import socket from "./socket"
|
||||
//
|
||||
import "phoenix_html"
|
||||
import "alpinejs"
|
||||
import "./live"
|
||||
import { ready } from "./utils"
|
||||
import "phoenix_html";
|
||||
import "alpinejs";
|
||||
import "./live";
|
||||
import { ready } from "./utils";
|
||||
|
||||
function togglePasswordFieldVisibility()
|
||||
{
|
||||
const passwordFields = document.querySelectorAll('[name="user[password]"]')
|
||||
var bloop;
|
||||
|
||||
function togglePasswordFieldVisibility() {
|
||||
const passwordFields = document.querySelectorAll('[name="user[password]"]');
|
||||
passwordFields.forEach((el) => {
|
||||
if (el.type == 'password')
|
||||
{
|
||||
el.type = 'text'
|
||||
if (el.type == "password") {
|
||||
el.type = "text";
|
||||
} else {
|
||||
el.type = "password";
|
||||
}
|
||||
else
|
||||
{
|
||||
el.type = 'password'
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
const toggleSidebar = (event) => {
|
||||
document.querySelectorAll('.sidebar').forEach((el) => {
|
||||
el.classList.toggle('visible')
|
||||
})
|
||||
}
|
||||
document.querySelectorAll(".sidebar").forEach((el) => {
|
||||
el.classList.toggle("visible");
|
||||
});
|
||||
};
|
||||
|
||||
ready(() => {
|
||||
(document.getElementById('nav-toggle') ||{}).onclick = function(){
|
||||
(document.getElementById("nav-toggle") || {}).onclick = function () {
|
||||
document.getElementById("nav-content").classList.toggle("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
document.querySelectorAll('.js-passwordRevealer').forEach((el) => {
|
||||
el.addEventListener('click', togglePasswordFieldVisibility)
|
||||
})
|
||||
document.querySelectorAll(".js-passwordRevealer").forEach((el) => {
|
||||
el.addEventListener("click", togglePasswordFieldVisibility);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.js-SidebarOpener').forEach((el) => {
|
||||
el.addEventListener('click', toggleSidebar)
|
||||
})
|
||||
document.querySelectorAll(".js-SidebarOpener").forEach((el) => {
|
||||
el.addEventListener("click", toggleSidebar);
|
||||
});
|
||||
|
||||
document.querySelectorAll('.js-flash-closer').forEach((el) => {
|
||||
el.addEventListener('click', () => {
|
||||
el.closest('.js-flash').remove()
|
||||
})
|
||||
})
|
||||
})
|
||||
document.querySelectorAll(".js-flash-closer").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
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 LiveSocket from "phoenix_live_view"
|
||||
import topbar from "topbar"
|
||||
import { Socket } from "phoenix";
|
||||
import { LiveSocket } from "phoenix_live_view";
|
||||
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
|
||||
topbar.config({barColors: {0: "#3B82F6"}, shadowColor: "rgba(0, 0, 0, .3)"})
|
||||
window.addEventListener("phx:page-loading-start", info => topbar.show())
|
||||
window.addEventListener("phx:page-loading-stop", info => topbar.hide())
|
||||
topbar.config({
|
||||
barColors: { 0: "#3B82F6" },
|
||||
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
|
||||
liveSocket.connect()
|
||||
liveSocket.connect();
|
||||
|
||||
// Expose liveSocket on window for web console debug logs and latency simulation:
|
||||
// >> liveSocket.enableDebug()
|
||||
|
@ -20,4 +27,4 @@ liveSocket.connect()
|
|||
// The latency simulator is enabled for the duration of the browser session.
|
||||
// Call disableLatencySim() to disable:
|
||||
// >> liveSocket.disableLatencySim()
|
||||
window.liveSocket = liveSocket
|
||||
window.liveSocket = liveSocket;
|
||||
|
|
|
@ -6,9 +6,9 @@
|
|||
//
|
||||
// Pass the token on params as below. Or remove it
|
||||
// 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.
|
||||
// For example, imagine you have an authentication plug, `MyAuth`,
|
||||
|
@ -52,12 +52,17 @@ let socket = new Socket("/socket", {params: {token: window.userToken}})
|
|||
// end
|
||||
//
|
||||
// Finally, connect to the socket:
|
||||
socket.connect()
|
||||
socket.connect();
|
||||
|
||||
// Now that you are connected, you can join channels with a topic:
|
||||
let channel = socket.channel("topic:subtopic", {})
|
||||
channel.join()
|
||||
.receive("ok", resp => { console.log("Joined successfully", resp) })
|
||||
.receive("error", resp => { console.log("Unable to join", resp) })
|
||||
let channel = socket.channel("topic:subtopic", {});
|
||||
channel
|
||||
.join()
|
||||
.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) => {
|
||||
if (document.readyState != 'loading') {
|
||||
fn()
|
||||
if (document.readyState != "loading") {
|
||||
fn();
|
||||
} 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": {
|
||||
"deploy": "webpack --mode production",
|
||||
"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": {
|
||||
"@fortawesome/fontawesome-free": "^5.14.0",
|
||||
"alpinejs": "^2.8.1",
|
||||
"autoprefixer": "^9.8.6",
|
||||
"csswring": "^7.0.0",
|
||||
"fabric": "^4.6.0",
|
||||
"glob": "^7.1.6",
|
||||
"npm-force-resolutions": "^0.0.10",
|
||||
"phoenix": "file:/../../../deps/phoenix",
|
||||
"phoenix_html": "file:/../../../deps/phoenix_html",
|
||||
"phoenix_live_view": "file:../../../deps/phoenix_live_view",
|
||||
"postcss-clean": "^1.2.2",
|
||||
"postcss-color-function": "^4.1.0",
|
||||
"simplemde": "^1.11.2",
|
||||
"tailwindcss": "^1.7.3",
|
||||
"squeak": "^1.3.0",
|
||||
"tailwindcss": "^2.2.6",
|
||||
"topbar": "^1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -29,25 +33,28 @@
|
|||
"css-loader": "^3.4.2",
|
||||
"css-minimizer-webpack-plugin": "^3.0.2",
|
||||
"file-loader": "^6.0.0",
|
||||
"image-webpack-loader": "^6.0.0",
|
||||
"image-webpack-loader": "^7.0.1",
|
||||
"less": "^3.11.3",
|
||||
"less-loader": "^6.2.0",
|
||||
"mini-css-extract-plugin": "^1.6.2",
|
||||
"postcss-css-variables": "^0.17.0",
|
||||
"postcss-import": "^12.0.1",
|
||||
"postcss-loader": "^6.1.0",
|
||||
"prettier": "2.3.2",
|
||||
"sass": "^1.35.1",
|
||||
"sass-loader": "^8.0.2",
|
||||
"style-loader": "^1.2.1",
|
||||
"stylelint": "^13.13.1",
|
||||
"stylelint-config-standard": "^22.0.0",
|
||||
"stylelint": "^13.8.0",
|
||||
"stylelint-config-standard": "^20.0.0",
|
||||
"stylelint-order": "^4.1.0",
|
||||
"terser-webpack-plugin": "^2.3.2",
|
||||
"terser-webpack-plugin": "^5.1.4",
|
||||
"webpack": "^5.1.0",
|
||||
"webpack-cli": "^4.7.2",
|
||||
"yargs-parser": "^20.2.9"
|
||||
},
|
||||
"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 = {
|
||||
plugins: [
|
||||
require('postcss-import')({
|
||||
plugins: [
|
||||
require('stylelint')(),
|
||||
]
|
||||
require("postcss-import")({
|
||||
plugins: [require("stylelint")()],
|
||||
}),
|
||||
require('tailwindcss'),
|
||||
require('autoprefixer'),
|
||||
require('csswring')(),
|
||||
require('postcss-color-function')()
|
||||
require("tailwindcss"),
|
||||
require("autoprefixer"),
|
||||
require("postcss-clean")(),
|
||||
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 mode = process.env.NODE_ENV || cliArgs.mode || 'development';
|
||||
const mode = process.env.NODE_ENV || cliArgs.mode || "development";
|
||||
|
||||
module.exports = {
|
||||
future: {
|
||||
removeDeprecatedGapUtilities: true,
|
||||
purgeLayersByDefault: true,
|
||||
},
|
||||
purge: {
|
||||
enabled: mode == 'production',
|
||||
layers: ['base', 'components', 'utilities'],
|
||||
enabled: mode == "production",
|
||||
layers: ["base", "components", "utilities"],
|
||||
content: [
|
||||
'../../../**/views/*.ex',
|
||||
'../../../**/*.html.eex',
|
||||
'../../../**/*.html.leex',
|
||||
'../../../**/*.html.heex',
|
||||
'./js/**/*.js'
|
||||
]
|
||||
"../../../**/views/*.ex",
|
||||
"../../../**/*.html.eex",
|
||||
"../../../**/*.html.leex",
|
||||
"../../../**/*.html.heex",
|
||||
"./js/**/*.js",
|
||||
],
|
||||
},
|
||||
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: {
|
||||
backgroundColor: ['responsive', 'hover', 'focus', 'checked'],
|
||||
backgroundColor: ["responsive", "hover", "focus", "checked"],
|
||||
extend: {
|
||||
fontWeight: ["hover", "focus"],
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,31 +1,31 @@
|
|||
const path = require('path');
|
||||
const glob = require('glob');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
const TerserPlugin = require('terser-webpack-plugin');
|
||||
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
|
||||
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
||||
const path = require("path");
|
||||
const glob = require("glob");
|
||||
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||
const TerserPlugin = require("terser-webpack-plugin");
|
||||
const CssMinimizerPlugin = require("css-minimizer-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) => {
|
||||
const devMode = options.mode !== 'production';
|
||||
const devMode = options.mode !== "production";
|
||||
|
||||
return {
|
||||
optimization: {
|
||||
minimizer: [
|
||||
new TerserPlugin({ cache: true, parallel: true, sourceMap: devMode }),
|
||||
new TerserPlugin({ parallel: true }),
|
||||
new CssMinimizerPlugin(),
|
||||
]
|
||||
],
|
||||
},
|
||||
mode: options.mode,
|
||||
devtool: devMode ? 'source-map' : undefined,
|
||||
devtool: devMode ? "source-map" : undefined,
|
||||
entry: {
|
||||
'app': glob.sync('./vendor/**/*.js').concat(['./js/app.js']),
|
||||
'content-editor': ['./js/content-editor.js'],
|
||||
app: glob.sync("./vendor/**/*.js").concat(["./js/app.js"]),
|
||||
admin: ["./js/admin.js"],
|
||||
},
|
||||
output: {
|
||||
filename: 'js/[name].js',
|
||||
path: path.resolve(__dirname, '../priv/static/')
|
||||
filename: "js/[name].js",
|
||||
path: path.resolve(__dirname, "../priv/static/"),
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
|
@ -33,9 +33,9 @@ module.exports = (env, options) => {
|
|||
{
|
||||
test: /\.(jpg|jpeg|gif|png)$/,
|
||||
use: [
|
||||
'file-loader',
|
||||
"file-loader",
|
||||
{
|
||||
loader: 'image-webpack-loader',
|
||||
loader: "image-webpack-loader",
|
||||
options: {
|
||||
disable: devMode,
|
||||
},
|
||||
|
@ -44,61 +44,108 @@ module.exports = (env, options) => {
|
|||
},
|
||||
{
|
||||
test: /\.(woff2?|ttf|eot|svg)(\?[a-z0-9\=\.]+)?$/,
|
||||
loader: 'file-loader',
|
||||
loader: "file-loader",
|
||||
options: {
|
||||
publicPath: '/fonts',
|
||||
publicPath: "/fonts",
|
||||
outputPath: (url, resourcePath, context) => {
|
||||
return `/fonts/${url}`;
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.js$/,
|
||||
exclude: /node_modules/,
|
||||
use: {
|
||||
loader: 'babel-loader'
|
||||
}
|
||||
loader: "babel-loader",
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [
|
||||
{loader: MiniCssExtractPlugin.loader},
|
||||
{loader: 'css-loader', options: {sourceMap: true}},
|
||||
{loader: 'postcss-loader', options: {sourceMap: true}},
|
||||
{ loader: MiniCssExtractPlugin.loader },
|
||||
{ loader: "css-loader", options: { sourceMap: true } },
|
||||
{ loader: "postcss-loader", options: { sourceMap: true } },
|
||||
],
|
||||
},
|
||||
]
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
new MiniCssExtractPlugin({
|
||||
filename: 'css/[name].css',
|
||||
chunkFilename: '[id].css',
|
||||
filename: "css/[name].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: {
|
||||
alias: {
|
||||
"../webfonts/fa-brands-400.eot": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.eot"),
|
||||
"../webfonts/fa-brands-400.woff2": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.woff2"),
|
||||
"../webfonts/fa-brands-400.woff": path.resolve(__dirname, "node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.woff"),
|
||||
"../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-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"),
|
||||
}
|
||||
"../webfonts/fa-brands-400.eot": path.resolve(
|
||||
__dirname,
|
||||
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.eot"
|
||||
),
|
||||
"../webfonts/fa-brands-400.woff2": path.resolve(
|
||||
__dirname,
|
||||
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.woff2"
|
||||
),
|
||||
"../webfonts/fa-brands-400.woff": path.resolve(
|
||||
__dirname,
|
||||
"node_modules/@fortawesome/fontawesome-free/webfonts/fa-brands-400.woff"
|
||||
),
|
||||
"../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-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: "/",
|
||||
from: :app,
|
||||
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,
|
||||
at: "/kaffy",
|
||||
|
|
|
@ -4,19 +4,16 @@ defmodule AppWeb.LiveHelpers do
|
|||
"""
|
||||
import Phoenix.LiveView
|
||||
|
||||
alias Legendary.Auth.User
|
||||
alias Pow.Store.CredentialsCache
|
||||
alias AppWeb.Pow.Routes
|
||||
|
||||
def assign_defaults(socket, session) do
|
||||
assign_new(socket, :current_user, fn -> get_user(socket, session) end)
|
||||
end
|
||||
|
||||
def require_auth(socket) do
|
||||
if socket.assigns.current_user do
|
||||
socket
|
||||
else
|
||||
redirect(socket, to: Routes.after_sign_out_path(%Plug.Conn{}))
|
||||
case socket.assigns do
|
||||
%{current_user: user} when not is_nil(user) ->
|
||||
socket
|
||||
_ ->
|
||||
redirect(socket, to: "/")
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -51,6 +51,10 @@ defmodule AppWeb.Router do
|
|||
forward "/sent_emails", Bamboo.SentEmailViewerPlug
|
||||
end
|
||||
|
||||
if Mix.env() in [:e2e, :test] do
|
||||
forward("/end-to-end", Legendary.CoreWeb.Plug.TestEndToEnd, otp_app: :app)
|
||||
end
|
||||
|
||||
scope "/" do
|
||||
pipe_through :browser
|
||||
|
||||
|
@ -60,5 +64,6 @@ defmodule AppWeb.Router do
|
|||
|
||||
use Legendary.Core.Routes
|
||||
use Legendary.Admin.Routes
|
||||
use Legendary.ObjectStorageWeb.Routes
|
||||
use Legendary.Content.Routes
|
||||
end
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
defmodule AppWeb.Telemetry do
|
||||
@moduledoc """
|
||||
Collect metrics on your app.
|
||||
"""
|
||||
|
||||
use Supervisor
|
||||
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 name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<%= 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") %>"/>
|
||||
<script defer phx-track-static type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
|
||||
</head>
|
||||
|
|
|
@ -8,10 +8,12 @@ defmodule AppWeb.ErrorHelpers do
|
|||
@doc """
|
||||
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 ->
|
||||
content_tag(:span, translate_error(error),
|
||||
class: "invalid-feedback",
|
||||
class: "invalid-feedback #{extra_classes}",
|
||||
phx_feedback_for: input_id(form, field)
|
||||
)
|
||||
end)
|
||||
|
|
|
@ -1,15 +1,73 @@
|
|||
defmodule AppWeb.LayoutView do
|
||||
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
|
||||
Legendary.I18n.t!("en", "site.title")
|
||||
end
|
||||
end
|
||||
|
||||
def excerpt(_, _, _) do
|
||||
Legendary.I18n.t!("en", "site.excerpt")
|
||||
end
|
||||
def excerpt(conn, assigns), do: excerpt(view_module(conn), view_template(conn), assigns)
|
||||
|
||||
def feed_tag(_, _, _, _) do
|
||||
nil
|
||||
end
|
||||
def excerpt(_, _, %{post: post}) do
|
||||
post.excerpt
|
||||
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
|
||||
|
|
|
@ -9,7 +9,7 @@ defmodule App.MixProject do
|
|||
config_path: "../../config/config.exs",
|
||||
deps_path: "../../deps",
|
||||
lockfile: "../../mix.lock",
|
||||
elixir: "~> 1.7",
|
||||
elixir: "~> 1.10",
|
||||
elixirc_paths: elixirc_paths(Mix.env()),
|
||||
compilers: [:phoenix, :gettext] ++ Mix.compilers(),
|
||||
start_permanent: Mix.env() == :prod,
|
||||
|
@ -42,19 +42,20 @@ defmodule App.MixProject do
|
|||
{:admin, in_umbrella: true},
|
||||
{:content, 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]},
|
||||
{:floki, ">= 0.30.0"},
|
||||
{:oban, "~> 2.1"},
|
||||
{:phoenix, "~> 1.5.8"},
|
||||
{:phoenix_ecto, "~> 4.0"},
|
||||
{:phoenix_html, "~> 2.11"},
|
||||
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
||||
{:phoenix_live_dashboard, "~> 0.4.0"},
|
||||
{:phoenix_live_view, "~> 0.15.7", override: true},
|
||||
{:oban, "~> 2.9"},
|
||||
{:phoenix, "~> 1.6.0"},
|
||||
{:phoenix_ecto, "~> 4.4"},
|
||||
{:phoenix_html, "~> 3.0.4", override: true},
|
||||
{:phoenix_live_reload, "~> 1.3", only: :dev},
|
||||
{:phoenix_live_dashboard, "~> 0.5.0"},
|
||||
{:phoenix_live_view, "~> 0.16.0", override: true},
|
||||
{:postgrex, ">= 0.0.0"},
|
||||
{:telemetry_metrics, "~> 0.4"},
|
||||
{:telemetry_poller, "~> 0.4"},
|
||||
{:telemetry_poller, "~> 1.0"},
|
||||
{:gettext, "~> 0.11"},
|
||||
{:jason, "~> 1.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
|
||||
|
||||
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
|
||||
def default_title do
|
||||
|
@ -11,6 +22,10 @@ defmodule App.LayoutViewTest do
|
|||
test "for nil" do
|
||||
assert title(nil, nil, nil) =~ default_title()
|
||||
end
|
||||
|
||||
test "for post" do
|
||||
assert title(nil, nil, %{post: @post}) =~ "Test Post | #{default_title()}"
|
||||
end
|
||||
end
|
||||
|
||||
describe "excerpt/3" do
|
||||
|
@ -21,6 +36,10 @@ defmodule App.LayoutViewTest do
|
|||
test "for nil" do
|
||||
assert excerpt(nil, nil, nil) =~ default_excerpt()
|
||||
end
|
||||
|
||||
test "for post" do
|
||||
assert excerpt(nil, nil, %{post: @post}) =~ "This is a test post."
|
||||
end
|
||||
end
|
||||
|
||||
describe "feed_tag/4" do
|
||||
|
@ -28,4 +47,39 @@ defmodule App.LayoutViewTest do
|
|||
assert feed_tag(nil, nil, nil, nil) == nil
|
||||
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
|
||||
|
|
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
|
||||
# for more information on OTP Applications
|
||||
def start(_type, _args) do
|
||||
import Supervisor.Spec
|
||||
|
||||
# Define workers and child supervisors to be supervised
|
||||
children = [
|
||||
# Start the Ecto repository
|
||||
supervisor(Legendary.Content.Repo, []),
|
||||
Legendary.Content.Repo,
|
||||
# Start the endpoint when the application starts
|
||||
# Start your own worker by calling: Legendary.Content.Worker.start_link(arg1, arg2, arg3)
|
||||
# worker(Legendary.Content.Worker, [arg1, arg2, arg3]),
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
defmodule Legendary.Content.CommentAdmin do
|
||||
@moduledoc """
|
||||
Custom admin logic for blog post comments.
|
||||
"""
|
||||
|
||||
def index(_) do
|
||||
[
|
||||
id: nil,
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
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
|
||||
def type, do: :string
|
||||
|
||||
|
|
|
@ -116,15 +116,17 @@ defmodule Legendary.Content.Post do
|
|||
|
||||
def maybe_put_guid(changeset) do
|
||||
import Legendary.Content.Router.Helpers, only: [url: 1, posts_url: 3]
|
||||
slug = changeset |> get_field(:name)
|
||||
guid = changeset |> get_field(:guid)
|
||||
|
||||
case slug do
|
||||
nil -> changeset
|
||||
_ ->
|
||||
case guid do
|
||||
nil ->
|
||||
base = url(Legendary.CoreWeb.Endpoint)
|
||||
slug = changeset |> get_field(:name)
|
||||
|
||||
changeset
|
||||
|> put_default(:guid, posts_url(URI.merge(base, "/pages"), :show, slug))
|
||||
_ ->
|
||||
changeset
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
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]
|
||||
|
||||
def singular_name(_) do
|
||||
|
@ -10,11 +16,15 @@ defmodule Legendary.Content.PostAdmin do
|
|||
end
|
||||
|
||||
def create_changeset(schema, attrs) do
|
||||
Legendary.Content.Post.changeset(schema, attrs)
|
||||
schema
|
||||
|> Post.changeset(attrs)
|
||||
|> PreviewImages.handle_preview_image_upload(attrs)
|
||||
end
|
||||
|
||||
def update_changeset(schema, attrs) do
|
||||
Legendary.Content.Post.changeset(schema, attrs)
|
||||
schema
|
||||
|> Post.changeset(attrs)
|
||||
|> PreviewImages.handle_preview_image_upload(attrs)
|
||||
end
|
||||
|
||||
def index(_) do
|
||||
|
@ -54,6 +64,7 @@ defmodule Legendary.Content.PostAdmin do
|
|||
comment_status: %{choices: [{"open", :open}, {"closed", :closed}]},
|
||||
ping_status: %{choices: [{"open", :open}, {"closed", :closed}]},
|
||||
menu_order: nil,
|
||||
social_media_preview_image: %{type: :hidden},
|
||||
]
|
||||
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()
|
||||
end
|
||||
|
||||
@spec generate :: :ok
|
||||
def generate do
|
||||
create do
|
||||
add "", priority: 0.5, changefreq: "hourly", expires: nil
|
|
@ -4,7 +4,7 @@ defmodule Legendary.Content.TermRelationship do
|
|||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
alias Legendary.Content.{Post}
|
||||
alias Legendary.Content.Post
|
||||
|
||||
@primary_key {:object_id, :integer, []}
|
||||
@primary_key {:term_taxonomy_id, :integer, []}
|
||||
|
|
|
@ -34,8 +34,6 @@ defmodule Legendary.Content do
|
|||
namespace: Legendary.Content,
|
||||
pattern: "**/*"
|
||||
|
||||
use PhoenixHtmlSanitizer, :basic_html
|
||||
|
||||
# Import convenience functions from controllers
|
||||
import Phoenix.Controller,
|
||||
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)
|
||||
end
|
||||
end
|
||||
def show(conn, %{"id" => id, "page" => page_string}) when is_list(id) do
|
||||
show(conn, %{"id" => Enum.join(id, "/"), "page" => page_string})
|
||||
end
|
||||
def show(conn, %{"id" => id} = params) when is_list(id), do: show(conn, Map.merge(params, %{"id" => Enum.join(id, "/")}))
|
||||
def show(conn, %{"id" => id}), do: show(conn, %{"id" => id, "page" => "1"})
|
||||
|
||||
defp try_static_post(conn, id) do
|
||||
|
@ -87,11 +85,12 @@ defmodule Legendary.Content.PostsController do
|
|||
end
|
||||
|
||||
# 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)
|
||||
_ ->
|
||||
# We aren't missing the static page, we're missing a partial. This is probably
|
||||
# a developer error, so bubble it up
|
||||
raise e
|
||||
reraise e, System.stacktrace
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -113,10 +112,20 @@ defmodule Legendary.Content.PostsController do
|
|||
{:ok, decoded} = post.content |> Base.decode64
|
||||
|
||||
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)
|
||||
_ ->
|
||||
render(conn, template, post: post, page: page, thumbs: thumbs)
|
||||
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
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
defmodule Legendary.Content.Routes do
|
||||
@moduledoc """
|
||||
Routes for the content engine, including blog posts, feeds, and pages.
|
||||
"""
|
||||
|
||||
defmacro __using__(_opts \\ []) do
|
||||
quote do
|
||||
pipeline :feed do
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title><%= title(@view_module, @view_template, assigns) %></title>
|
||||
<description><%= excerpt(@view_module, @view_template, assigns) %></description>
|
||||
<title><%= title(@conn, assigns) %></title>
|
||||
<description><%= excerpt(@conn, assigns) %></description>
|
||||
<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" />
|
||||
|
||||
<%= for post <- @posts do %>
|
||||
<%= if unauthenticated_post?(post) do %>
|
||||
<item>
|
||||
<title><%= post.title |> HtmlSanitizeEx.strip_tags() %></title>
|
||||
<description>
|
||||
|
@ -19,6 +20,7 @@
|
|||
%></pubDate>
|
||||
<guid isPermaLink="true"><%= post.guid %></guid>
|
||||
</item>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</channel>
|
||||
</rss>
|
||||
|
|
|
@ -4,9 +4,9 @@
|
|||
<meta charset="utf-8"/>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
|
||||
<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") %>"/>
|
||||
<%= 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>
|
||||
</head>
|
||||
<body class="text-gray-800 antialiased">
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
</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>
|
||||
<% 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 """
|
||||
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 ->
|
||||
content_tag(:span, translate_error(error),
|
||||
class: "invalid-feedback",
|
||||
class: "invalid-feedback #{extra_classes}",
|
||||
phx_feedback_for: input_id(form, field)
|
||||
)
|
||||
end)
|
||||
|
|
|
@ -1,71 +1,10 @@
|
|||
defmodule Legendary.Content.FeedsView do
|
||||
use Legendary.Content, :view
|
||||
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
|
||||
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
|
||||
def unauthenticated_post?(post) do
|
||||
post.password == nil || String.length(post.password) == 0
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
defmodule Legendary.Content.LayoutView do
|
||||
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
|
||||
~E"""
|
||||
<link
|
||||
|
@ -12,6 +14,8 @@ defmodule Legendary.Content.LayoutView do
|
|||
"""
|
||||
end
|
||||
|
||||
def title(conn, assigns), do: title(view_module(conn), view_template(conn), assigns)
|
||||
|
||||
def title(Legendary.Content.PostsView, "index.html", assigns) do
|
||||
"Page #{assigns.page} | #{title(nil, nil, nil)}"
|
||||
end
|
||||
|
@ -26,6 +30,8 @@ defmodule Legendary.Content.LayoutView do
|
|||
|
||||
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
|
||||
assigns.post.excerpt
|
||||
|> HtmlSanitizeEx.strip_tags()
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
defmodule Legendary.Content.MixProject do
|
||||
use Mix.Project
|
||||
|
||||
@version "2.11.5"
|
||||
@version "4.3.0"
|
||||
|
||||
def project do
|
||||
[
|
||||
|
@ -11,7 +11,7 @@ defmodule Legendary.Content.MixProject do
|
|||
config_path: "../../config/config.exs",
|
||||
deps_path: "../../deps",
|
||||
lockfile: "../../mix.lock",
|
||||
elixir: "~> 1.7",
|
||||
elixir: "~> 1.10",
|
||||
elixirc_paths: elixirc_paths(Mix.env()),
|
||||
compilers: [:phoenix, :gettext] ++ Mix.compilers(),
|
||||
start_permanent: Mix.env() == :prod,
|
||||
|
@ -46,23 +46,25 @@ defmodule Legendary.Content.MixProject do
|
|||
{:excoveralls, "~> 0.10", only: [:dev, :test]},
|
||||
{:floki, ">= 0.30.0"},
|
||||
{:gettext, "~> 0.11"},
|
||||
{:html_sanitize_ex, "~> 1.4.1"},
|
||||
{:jason, "~> 1.0"},
|
||||
{:mime, "~> 2.0.1"},
|
||||
{:mock, "~> 0.3.0", only: :test},
|
||||
{:meck, "~> 0.8.13", only: :test},
|
||||
{:neotomex, "~> 0.1.7"},
|
||||
{:oban, "~> 2.1"},
|
||||
{:phoenix, "~> 1.5.8"},
|
||||
{:phoenix_ecto, "~> 4.0"},
|
||||
{:phoenix_html, "~> 2.11"},
|
||||
{:phoenix_html_sanitizer, "~> 1.1.0"},
|
||||
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
||||
{:phoenix_live_dashboard, "~> 0.4.0"},
|
||||
{:oban, "~> 2.9"},
|
||||
{:phoenix, "~> 1.6.0"},
|
||||
{:phoenix_ecto, "~> 4.4"},
|
||||
{:ecto_sql, "~> 3.7"},
|
||||
{:phoenix_html, "~> 3.0.4", override: true},
|
||||
{:phoenix_live_reload, "~> 1.3", only: :dev},
|
||||
{:phoenix_live_dashboard, "~> 0.5.0"},
|
||||
{:php_serializer, "~> 2.0.0"},
|
||||
{:plug_cowboy, "~> 2.0"},
|
||||
{:sitemap, "~> 1.1"},
|
||||
{:slugger, "~> 0.3"},
|
||||
{:telemetry_metrics, "~> 0.4"},
|
||||
{:telemetry_poller, "~> 0.4"},
|
||||
{:telemetry_poller, "~> 1.0"},
|
||||
{:timex, "~> 3.1"},
|
||||
]
|
||||
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))
|
||||
end
|
||||
|
||||
test "update_posts/2", %{public_post: post} do
|
||||
assert {:ok, %Post{content: "boop"}} = Posts.update_posts(post, %{content: "boop"})
|
||||
describe "update_posts/2" do
|
||||
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
|
||||
|
||||
test "delete_posts/1", %{public_post: post} do
|
||||
assert Enum.count(Posts.list_posts()) == 1
|
||||
assert {:ok, _} = Posts.delete_posts(post)
|
||||
assert Enum.count(Posts.list_posts()) == 0
|
||||
assert Enum.empty?(Posts.list_posts())
|
||||
end
|
||||
|
||||
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)
|
||||
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)
|
||||
end
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
defmodule Legendary.Content.PostsControllerTest do
|
||||
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 %{
|
||||
id: 123,
|
||||
|
@ -165,6 +165,21 @@ defmodule Legendary.Content.PostsControllerTest do
|
|||
assert html_response(conn, 200)
|
||||
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
|
||||
assert_raise Phoenix.Router.NoRouteError, fn ->
|
||||
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 Phoenix.HTML, only: [safe_to_string: 1]
|
||||
|
||||
alias Legendary.Content.Post
|
||||
|
||||
test "auto_paragraph_tags/1 with nil" do
|
||||
assert safe_to_string(auto_paragraph_tags(nil)) =~ ""
|
||||
end
|
||||
|
@ -11,4 +13,22 @@ defmodule Legendary.Content.PostsViewTest 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>"
|
||||
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
|
||||
|
|
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