Compare commits
151 Commits
4a5c09d819
...
0.1.9
Author | SHA1 | Date | |
---|---|---|---|
c5e82c60b6 | |||
c7debb331c | |||
bc2c936480 | |||
9b4837a044 | |||
bd5dd57c6c | |||
4f0f3ec610 | |||
6b7abdb3e1 | |||
db2ec3b4a3 | |||
f8a1d88e0e | |||
0eafb54266 | |||
b1d442ea88 | |||
8b59339f74 | |||
563bbaedf8 | |||
12814be5e1 | |||
bc034c0361 | |||
eb9280fa7e | |||
ad1e44fd42 | |||
482a3902b5 | |||
2fa1105f40 | |||
df44be2b1b | |||
56dae6cdfe | |||
8ef3bd65a3 | |||
6e635d1b30 | |||
ebe09bcf84 | |||
0ea953a34c | |||
1284a0c4b1 | |||
fd0b2c455a | |||
f1139d0ec4 | |||
f10d061279 | |||
cd6bb6fbc3 | |||
ad7810a8ea | |||
5c05f3b6fe | |||
cd7220cea3 | |||
30d3f76fe1 | |||
ed8c20e967 | |||
eb75937587 | |||
7b60938a75 | |||
f19d024d8a | |||
1726a1b72e | |||
1fbed50b0f | |||
f990fafc6a | |||
737484c36e | |||
2cb6aa8d33 | |||
24f608163f | |||
6dbadc58ae | |||
40f301ca71 | |||
178111ce80 | |||
f155a43ee8 | |||
50c4878453 | |||
bbaa1dfd6b | |||
2c2b9fefc9 | |||
51e6f49d17 | |||
bafc824a32 | |||
8c2f7e0509 | |||
9289b79afa | |||
87f4d18a6d | |||
30bee92e37 | |||
22abc7a8d0 | |||
f32d52db26 | |||
d77421613c | |||
5a685ac00e | |||
a5c12b3e17 | |||
27af5acf8b | |||
469428c007 | |||
0d61e5ee96 | |||
89413df8c4 | |||
8ccd94818c | |||
482f339da2 | |||
af911f211c | |||
1cd28e43b8 | |||
695002c9d9 | |||
dde60d71d1 | |||
91794ddc55 | |||
1e3cec95fe | |||
f0a8c515f9 | |||
e99775eef2 | |||
6760f83ca0 | |||
10877bb754 | |||
38a581b639 | |||
9408705430 | |||
302aa7eeda | |||
fd4fdcc36b | |||
3cb723b9e4 | |||
1f92c452d1 | |||
c10cff63ea | |||
70faed71d0 | |||
b5c46c09ec | |||
7745765fc0 | |||
e16fbba810 | |||
e35bdf101b | |||
aa314e5ca1 | |||
616de3c117 | |||
74bcec6cfe | |||
41090c46d0 | |||
c3f5744ad6 | |||
95a339fe02 | |||
1e3b027367 | |||
dd46e1795f | |||
9e517e6477 | |||
7813738f91 | |||
c1337ebc10 | |||
59283a0217 | |||
634891ee73 | |||
571f6fffdb | |||
926805ba9b | |||
220122dec6 | |||
de399b4819 | |||
c3ceb877b2 | |||
0b4449c8a8 | |||
6452f64fec | |||
c0afc96b8d | |||
2cfecc54a0 | |||
501eb5d560 | |||
16c4ae16d3 | |||
516fe4a9f7 | |||
4a15f27923 | |||
dcfd1b87df | |||
c0df3440f5 | |||
6f03bee761 | |||
fe91d8de6d | |||
7fe636a99e | |||
a94407a216 | |||
70de3f42a4 | |||
03890d2431 | |||
30535fe5e1 | |||
b7464cff38 | |||
769a7838a5 | |||
b8bb36bfd3 | |||
8fcbb7aced | |||
1ba5b9ec41 | |||
443ff86aee | |||
5ee7071dff | |||
1802e54caf | |||
3dceb17085 | |||
ca5b29c914 | |||
264f13e523 | |||
e9360fb3d5 | |||
44c8cf77bb | |||
632c2b3480 | |||
852e60dd14 | |||
85c4559d1f | |||
7195c7fba6 | |||
ad457b428a | |||
cc11491106 | |||
a0a0697f2d | |||
2ce4fe3cc8 | |||
f9be5229e7 | |||
b95d3039bb | |||
3c250e9064 | |||
6f80ef35a1 | |||
1a508a42ef |
14
.credo.exs
14
.credo.exs
@ -157,17 +157,17 @@
|
||||
#
|
||||
# Controversial and experimental checks (opt-in, just replace `false` with `[]`)
|
||||
#
|
||||
{Credo.Check.Consistency.MultiAliasImportRequireUse, false},
|
||||
{Credo.Check.Consistency.UnusedVariableNames, false},
|
||||
{Credo.Check.Consistency.MultiAliasImportRequireUse, []},
|
||||
{Credo.Check.Consistency.UnusedVariableNames, [force: :meaningful]},
|
||||
{Credo.Check.Design.DuplicatedCode, false},
|
||||
{Credo.Check.Readability.AliasAs, false},
|
||||
{Credo.Check.Readability.BlockPipe, false},
|
||||
{Credo.Check.Readability.ImplTrue, false},
|
||||
{Credo.Check.Readability.MultiAlias, false},
|
||||
{Credo.Check.Readability.SeparateAliasRequire, false},
|
||||
{Credo.Check.Readability.SeparateAliasRequire, []},
|
||||
{Credo.Check.Readability.SinglePipe, false},
|
||||
{Credo.Check.Readability.Specs, false},
|
||||
{Credo.Check.Readability.StrictModuleLayout, false},
|
||||
{Credo.Check.Readability.StrictModuleLayout, []},
|
||||
{Credo.Check.Readability.WithCustomTaggedTuple, false},
|
||||
{Credo.Check.Refactor.ABCSize, false},
|
||||
{Credo.Check.Refactor.AppendSingleItem, false},
|
||||
@ -176,9 +176,9 @@
|
||||
{Credo.Check.Refactor.NegatedIsNil, false},
|
||||
{Credo.Check.Refactor.PipeChainStart, false},
|
||||
{Credo.Check.Refactor.VariableRebinding, false},
|
||||
{Credo.Check.Warning.LeakyEnvironment, false},
|
||||
{Credo.Check.Warning.MapGetUnsafePass, false},
|
||||
{Credo.Check.Warning.UnsafeToAtom, false}
|
||||
{Credo.Check.Warning.LeakyEnvironment, []},
|
||||
{Credo.Check.Warning.MapGetUnsafePass, []},
|
||||
{Credo.Check.Warning.UnsafeToAtom, []}
|
||||
|
||||
#
|
||||
# Custom checks can be created using `mix credo.gen.check`.
|
||||
|
27
.drone.yml
27
.drone.yml
@ -13,23 +13,27 @@ steps:
|
||||
mount:
|
||||
- _build
|
||||
- deps
|
||||
- assets/node_modules/
|
||||
- .npm
|
||||
- .mix
|
||||
|
||||
- name: test
|
||||
image: elixir:1.13.4-alpine
|
||||
image: elixir:1.14.1-alpine
|
||||
environment:
|
||||
TEST_DATABASE_URL: ecto://postgres:postgres@database/memex_test
|
||||
HOST: testing.example.tld
|
||||
MIX_HOME: /drone/src/.mix
|
||||
MIX_ARCHIVES: /drone/src/.mix/archives
|
||||
MIX_ENV: test
|
||||
commands:
|
||||
- apk add --no-cache build-base npm git python3
|
||||
- mix local.rebar --force
|
||||
- mix local.hex --force
|
||||
- apk add --no-cache build-base npm git
|
||||
- mix local.rebar --force --if-missing
|
||||
- mix local.hex --force --if-missing
|
||||
- mix deps.get
|
||||
- mix deps.compile
|
||||
- npm --prefix ./assets ci --progress=false --no-audit --loglevel=error
|
||||
- npm set cache .npm
|
||||
- npm --prefix ./assets ci --no-audit --prefer-offline
|
||||
- npm run --prefix ./assets deploy
|
||||
- mix do phx.digest, gettext.extract
|
||||
- mix test
|
||||
- mix test.all
|
||||
|
||||
- name: build and publish stable
|
||||
image: thegeeklab/drone-docker-buildx
|
||||
@ -38,7 +42,7 @@ steps:
|
||||
repo: shibaobun/memex
|
||||
purge: true
|
||||
compress: true
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
platforms: linux/amd64,linux/arm/v7
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
@ -55,7 +59,7 @@ steps:
|
||||
repo: shibaobun/memex
|
||||
purge: true
|
||||
compress: true
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
platforms: linux/amd64,linux/arm/v7
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
@ -76,7 +80,8 @@ steps:
|
||||
mount:
|
||||
- _build
|
||||
- deps
|
||||
- assets/node_modules/
|
||||
- .npm
|
||||
- .mix
|
||||
|
||||
services:
|
||||
- name: database
|
||||
|
@ -1,3 +1,3 @@
|
||||
elixir 1.14.1-otp-25
|
||||
erlang 25.1.2
|
||||
nodejs 16.18.0
|
||||
nodejs 18.9.1
|
||||
|
@ -1,4 +1,4 @@
|
||||
FROM elixir:1.13-alpine AS build
|
||||
FROM elixir:1.14.1-alpine AS build
|
||||
|
||||
# install build dependencies
|
||||
RUN apk add --no-cache build-base npm git python3
|
||||
@ -37,7 +37,7 @@ RUN mix do compile, release
|
||||
FROM alpine:latest AS app
|
||||
|
||||
RUN apk upgrade --no-cache && \
|
||||
apk add --no-cache bash openssl libgcc libstdc++ ncurses-libs
|
||||
apk add --no-cache bash openssl libssl1.1 libcrypto1.1 libgcc libstdc++ ncurses-libs
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
@ -25,12 +25,13 @@ $fa-font-path: "@fortawesome/fontawesome-free/webfonts";
|
||||
100% { scale: 1.0; opacity: 1; }
|
||||
}
|
||||
|
||||
.phx-connected > #disconnect, #loading {
|
||||
// disconnect toast
|
||||
.phx-connected > #disconnect {
|
||||
opacity: 0 !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.phx-loading:not(.phx-error) > #loading, .phx-error > #disconnect {
|
||||
.phx-error > #disconnect {
|
||||
opacity: 0.95 !important;
|
||||
}
|
||||
|
||||
|
@ -2,16 +2,16 @@
|
||||
.input {
|
||||
@apply rounded-lg px-4 py-2 border focus:outline-none;
|
||||
@apply shadow-sm focus:shadow-lg;
|
||||
@apply transition-all duration-300 ease-in-out;
|
||||
}
|
||||
|
||||
.input-primary {
|
||||
@apply bg-primary-900;
|
||||
@apply border-primary-900 hover:border-primary-800 active:border-primary-700;
|
||||
@apply text-primary-400;
|
||||
@apply text-primary-400 placeholder-primary-600;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
@apply bg-primary-900;
|
||||
-ms-transform: scale(1.5);
|
||||
-moz-transform: scale(1.5);
|
||||
-webkit-transform: scale(1.5);
|
||||
@ -27,29 +27,31 @@
|
||||
|
||||
.btn {
|
||||
@apply focus:outline-none px-4 py-2 rounded-lg;
|
||||
@apply shadow-sm focus:shadow-lg;
|
||||
@apply shadow-sm active:shadow-lg;
|
||||
@apply border;
|
||||
@apply transition-all duration-300 ease-in-out;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary-900 focus:bg-primary-900 active:bg-primary-800;
|
||||
@apply bg-primary-900 active:bg-primary-800;
|
||||
@apply border-primary-900 hover:border-primary-800 active:border-primary-700;
|
||||
@apply text-primary-400;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-primary-800 active:bg-primary-700;
|
||||
@apply border-primary-800 hover:border-primary-700 active:border-primary-600;
|
||||
@apply text-primary-400;
|
||||
}
|
||||
|
||||
.btn-alert {
|
||||
@apply bg-red-700 focus:bg-red-800 active:bg-red-900;
|
||||
@apply border-red-700 focus:border-red-800 active:border-red-900;
|
||||
@apply bg-red-700 active:bg-red-900;
|
||||
@apply border-red-700 active:border-red-900;
|
||||
@apply text-primary-300;
|
||||
}
|
||||
|
||||
.hr {
|
||||
@apply border border-primary-400 w-full max-w-2xl;
|
||||
}
|
||||
|
||||
.hr-light {
|
||||
@apply border border-primary-600 w-full max-w-2xl;
|
||||
@apply mx-auto border border-primary-600 w-full max-w-3xl;
|
||||
}
|
||||
|
||||
.link {
|
||||
@ -79,7 +81,7 @@
|
||||
}
|
||||
|
||||
.alert p {
|
||||
margin-bottom: 0;
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
.alert:empty {
|
||||
|
@ -25,11 +25,13 @@ import 'phoenix_html'
|
||||
// Establish Phoenix Socket and LiveView configuration.
|
||||
import { Socket } from 'phoenix'
|
||||
import { LiveSocket } from 'phoenix_live_view'
|
||||
import topbar from '../vendor/topbar'
|
||||
import topbar from 'topbar'
|
||||
import MaintainAttrs from './maintain_attrs'
|
||||
import Alpine from 'alpinejs'
|
||||
|
||||
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content')
|
||||
const csrfTokenElement = document.querySelector("meta[name='csrf-token']")
|
||||
let csrfToken
|
||||
if (csrfTokenElement) { csrfToken = csrfTokenElement.getAttribute('content') }
|
||||
const liveSocket = new LiveSocket('/live', Socket, {
|
||||
dom: {
|
||||
onBeforeElUpdated (from, to) {
|
||||
@ -45,9 +47,11 @@ window.Alpine = Alpine
|
||||
Alpine.start()
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' })
|
||||
topbar.config({ barThickness: 1, barColors: { 0: '#fff' }, shadowColor: 'rgba(0, 0, 0, .3)' })
|
||||
window.addEventListener('phx:page-loading-start', info => topbar.show())
|
||||
window.addEventListener('phx:page-loading-stop', info => topbar.hide())
|
||||
window.addEventListener('submit', info => topbar.show())
|
||||
window.addEventListener('beforeunload', info => topbar.show())
|
||||
|
||||
// connect if there are any LiveViews on the page
|
||||
liveSocket.connect()
|
||||
|
@ -2,7 +2,21 @@
|
||||
// update. https://github.com/phoenixframework/phoenix_live_view/issues/1011
|
||||
|
||||
export default {
|
||||
attrs () { return this.el.getAttribute('data-attrs').split(', ') },
|
||||
beforeUpdate () { this.prevAttrs = this.attrs().map(name => [name, this.el.getAttribute(name)]) },
|
||||
updated () { this.prevAttrs.forEach(([name, val]) => this.el.setAttribute(name, val)) }
|
||||
attrs () {
|
||||
if (this.el && this.el.getAttribute('data-attrs')) {
|
||||
return this.el.getAttribute('data-attrs').split(', ')
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
},
|
||||
beforeUpdate () {
|
||||
if (this.el) {
|
||||
this.prevAttrs = this.attrs().map(name => [name, this.el.getAttribute(name)])
|
||||
}
|
||||
},
|
||||
updated () {
|
||||
if (this.el) {
|
||||
this.prevAttrs.forEach(([name, val]) => this.el.setAttribute(name, val))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
1482
assets/package-lock.json
generated
1482
assets/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -2,6 +2,10 @@
|
||||
"repository": {},
|
||||
"description": " ",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "v18.9.1",
|
||||
"npm": "8.10.0"
|
||||
},
|
||||
"scripts": {
|
||||
"deploy": "NODE_ENV=production webpack --mode production",
|
||||
"watch": "webpack --mode development --watch --watch-options-stdin",
|
||||
@ -26,16 +30,14 @@
|
||||
"css-loader": "^6.7.1",
|
||||
"css-minimizer-webpack-plugin": "^3.4.1",
|
||||
"file-loader": "^6.2.0",
|
||||
"hard-source-webpack-plugin": "^0.13.1",
|
||||
"mini-css-extract-plugin": "^2.6.0",
|
||||
"node-sass": "^7.0.1",
|
||||
"postcss": "^8.4.13",
|
||||
"postcss-import": "^14.1.0",
|
||||
"postcss-loader": "^6.2.1",
|
||||
"postcss-preset-env": "^7.5.0",
|
||||
"sass": "^1.56.0",
|
||||
"sass-loader": "^12.6.0",
|
||||
"standard": "^17.0.0",
|
||||
"style-loader": "^3.3.1",
|
||||
"tailwindcss": "^3.0.24",
|
||||
"terser-webpack-plugin": "^5.3.1",
|
||||
"webpack": "^5.72.0",
|
||||
|
157
assets/vendor/topbar.js
vendored
157
assets/vendor/topbar.js
vendored
@ -1,157 +0,0 @@
|
||||
/**
|
||||
* @license MIT
|
||||
* topbar 1.0.0, 2021-01-06
|
||||
* https://buunguyen.github.io/topbar
|
||||
* Copyright (c) 2021 Buu Nguyen
|
||||
*/
|
||||
(function (window, document) {
|
||||
"use strict";
|
||||
|
||||
// https://gist.github.com/paulirish/1579671
|
||||
(function () {
|
||||
var lastTime = 0;
|
||||
var vendors = ["ms", "moz", "webkit", "o"];
|
||||
for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
|
||||
window.requestAnimationFrame =
|
||||
window[vendors[x] + "RequestAnimationFrame"];
|
||||
window.cancelAnimationFrame =
|
||||
window[vendors[x] + "CancelAnimationFrame"] ||
|
||||
window[vendors[x] + "CancelRequestAnimationFrame"];
|
||||
}
|
||||
if (!window.requestAnimationFrame)
|
||||
window.requestAnimationFrame = function (callback, element) {
|
||||
var currTime = new Date().getTime();
|
||||
var timeToCall = Math.max(0, 16 - (currTime - lastTime));
|
||||
var id = window.setTimeout(function () {
|
||||
callback(currTime + timeToCall);
|
||||
}, timeToCall);
|
||||
lastTime = currTime + timeToCall;
|
||||
return id;
|
||||
};
|
||||
if (!window.cancelAnimationFrame)
|
||||
window.cancelAnimationFrame = function (id) {
|
||||
clearTimeout(id);
|
||||
};
|
||||
})();
|
||||
|
||||
var canvas,
|
||||
progressTimerId,
|
||||
fadeTimerId,
|
||||
currentProgress,
|
||||
showing,
|
||||
addEvent = function (elem, type, handler) {
|
||||
if (elem.addEventListener) elem.addEventListener(type, handler, false);
|
||||
else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
|
||||
else elem["on" + type] = handler;
|
||||
},
|
||||
options = {
|
||||
autoRun: true,
|
||||
barThickness: 3,
|
||||
barColors: {
|
||||
0: "rgba(26, 188, 156, .9)",
|
||||
".25": "rgba(52, 152, 219, .9)",
|
||||
".50": "rgba(241, 196, 15, .9)",
|
||||
".75": "rgba(230, 126, 34, .9)",
|
||||
"1.0": "rgba(211, 84, 0, .9)",
|
||||
},
|
||||
shadowBlur: 10,
|
||||
shadowColor: "rgba(0, 0, 0, .6)",
|
||||
className: null,
|
||||
},
|
||||
repaint = function () {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = options.barThickness * 5; // need space for shadow
|
||||
|
||||
var ctx = canvas.getContext("2d");
|
||||
ctx.shadowBlur = options.shadowBlur;
|
||||
ctx.shadowColor = options.shadowColor;
|
||||
|
||||
var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
|
||||
for (var stop in options.barColors)
|
||||
lineGradient.addColorStop(stop, options.barColors[stop]);
|
||||
ctx.lineWidth = options.barThickness;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, options.barThickness / 2);
|
||||
ctx.lineTo(
|
||||
Math.ceil(currentProgress * canvas.width),
|
||||
options.barThickness / 2
|
||||
);
|
||||
ctx.strokeStyle = lineGradient;
|
||||
ctx.stroke();
|
||||
},
|
||||
createCanvas = function () {
|
||||
canvas = document.createElement("canvas");
|
||||
var style = canvas.style;
|
||||
style.position = "fixed";
|
||||
style.top = style.left = style.right = style.margin = style.padding = 0;
|
||||
style.zIndex = 100001;
|
||||
style.display = "none";
|
||||
if (options.className) canvas.classList.add(options.className);
|
||||
document.body.appendChild(canvas);
|
||||
addEvent(window, "resize", repaint);
|
||||
},
|
||||
topbar = {
|
||||
config: function (opts) {
|
||||
for (var key in opts)
|
||||
if (options.hasOwnProperty(key)) options[key] = opts[key];
|
||||
},
|
||||
show: function () {
|
||||
if (showing) return;
|
||||
showing = true;
|
||||
if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
|
||||
if (!canvas) createCanvas();
|
||||
canvas.style.opacity = 1;
|
||||
canvas.style.display = "block";
|
||||
topbar.progress(0);
|
||||
if (options.autoRun) {
|
||||
(function loop() {
|
||||
progressTimerId = window.requestAnimationFrame(loop);
|
||||
topbar.progress(
|
||||
"+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
|
||||
);
|
||||
})();
|
||||
}
|
||||
},
|
||||
progress: function (to) {
|
||||
if (typeof to === "undefined") return currentProgress;
|
||||
if (typeof to === "string") {
|
||||
to =
|
||||
(to.indexOf("+") >= 0 || to.indexOf("-") >= 0
|
||||
? currentProgress
|
||||
: 0) + parseFloat(to);
|
||||
}
|
||||
currentProgress = to > 1 ? 1 : to;
|
||||
repaint();
|
||||
return currentProgress;
|
||||
},
|
||||
hide: function () {
|
||||
if (!showing) return;
|
||||
showing = false;
|
||||
if (progressTimerId != null) {
|
||||
window.cancelAnimationFrame(progressTimerId);
|
||||
progressTimerId = null;
|
||||
}
|
||||
(function loop() {
|
||||
if (topbar.progress("+.1") >= 1) {
|
||||
canvas.style.opacity -= 0.05;
|
||||
if (canvas.style.opacity <= 0.05) {
|
||||
canvas.style.display = "none";
|
||||
fadeTimerId = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
fadeTimerId = window.requestAnimationFrame(loop);
|
||||
})();
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module === "object" && typeof module.exports === "object") {
|
||||
module.exports = topbar;
|
||||
} else if (typeof define === "function" && define.amd) {
|
||||
define(function () {
|
||||
return topbar;
|
||||
});
|
||||
} else {
|
||||
this.topbar = topbar;
|
||||
}
|
||||
}.call(this, window, document));
|
48
changelog.md
Normal file
48
changelog.md
Normal file
@ -0,0 +1,48 @@
|
||||
# v0.1.9
|
||||
- Improve server log
|
||||
- Style 大一點
|
||||
- Various minor improvements
|
||||
|
||||
# v0.1.8
|
||||
- Fix bug with public registration
|
||||
- Improve templates
|
||||
- Improve invites, record usage
|
||||
- Fix padding on more pages when using chrome
|
||||
- Add oban metrics to server log and live dashboard
|
||||
|
||||
# v0.1.7
|
||||
- Update dependencies
|
||||
- Show topbar on form submit/page refresh
|
||||
- Make loading/reconnection less intrusive
|
||||
- Add QR code for invite link
|
||||
|
||||
# v0.1.6
|
||||
- fix formatting in note/context/step contents
|
||||
- add json export for data
|
||||
|
||||
# v0.1.5
|
||||
- fix overflow on note/contexts/step contents
|
||||
|
||||
# v0.1.4
|
||||
- fix docker-compose
|
||||
- fix newlines in note/context/step contents
|
||||
- fix user invite page
|
||||
- improve tagging logic
|
||||
|
||||
# v0.1.3
|
||||
- backlink to other notes in notes
|
||||
- search tags on click
|
||||
|
||||
# v0.1.2
|
||||
- fix more typos
|
||||
- add to faq
|
||||
- check for slug uniqueness before submitting
|
||||
|
||||
# v0.1.1
|
||||
- improve search a whole lot
|
||||
- improve table information for notes and contexts
|
||||
- fix some typos
|
||||
- use project version on homepage
|
||||
|
||||
# v0.1.0
|
||||
- initial release >:3c
|
@ -11,6 +11,8 @@ config :memex,
|
||||
ecto_repos: [Memex.Repo],
|
||||
generators: [binary_id: true]
|
||||
|
||||
config :memex, Memex.Accounts, registration: System.get_env("REGISTRATION", "invite")
|
||||
|
||||
# Configures the endpoint
|
||||
config :memex, MemexWeb.Endpoint,
|
||||
url: [scheme: "https", host: System.get_env("HOST") || "localhost", port: "443"],
|
||||
@ -18,8 +20,7 @@ config :memex, MemexWeb.Endpoint,
|
||||
secret_key_base: "KH59P0iZixX5gP/u+zkxxG8vAAj6vgt0YqnwEB5JP5K+E567SsqkCz69uWShjE7I",
|
||||
render_errors: [view: MemexWeb.ErrorView, accepts: ~w(html json), layout: false],
|
||||
pubsub_server: Memex.PubSub,
|
||||
live_view: [signing_salt: "zOLgd3lr"],
|
||||
registration: System.get_env("REGISTRATION") || "invite"
|
||||
live_view: [signing_salt: "zOLgd3lr"]
|
||||
|
||||
config :memex, Memex.Application, automigrate: false
|
||||
|
||||
|
@ -64,8 +64,9 @@ config :memex, MemexWeb.Endpoint,
|
||||
]
|
||||
]
|
||||
|
||||
# Do not include metadata nor timestamps in development logs
|
||||
config :logger, :console, format: "[$level] $message\n"
|
||||
config :logger, :console,
|
||||
format: "[$level] $message $metadata\n\n",
|
||||
metadata: [:data]
|
||||
|
||||
# Set a higher stacktrace during development. Avoid configuring such
|
||||
# in production as building large stacktraces may be expensive.
|
||||
|
@ -13,17 +13,18 @@ if System.get_env("PHX_SERVER") && System.get_env("RELEASE_NAME") do
|
||||
end
|
||||
|
||||
# Set default locale
|
||||
config :gettext, :default_locale, System.get_env("LOCALE") || "en_US"
|
||||
config :gettext, :default_locale, System.get_env("LOCALE", "en_US")
|
||||
|
||||
maybe_ipv6 = if System.get_env("ECTO_IPV6") == "true", do: [:inet6], else: []
|
||||
|
||||
database_url =
|
||||
if config_env() == :test do
|
||||
System.get_env("TEST_DATABASE_URL") ||
|
||||
System.get_env(
|
||||
"TEST_DATABASE_URL",
|
||||
"ecto://postgres:postgres@localhost/memex_test#{System.get_env("MIX_TEST_PARTITION")}"
|
||||
)
|
||||
else
|
||||
System.get_env("DATABASE_URL") ||
|
||||
"ecto://postgres:postgres@memex-db/memex"
|
||||
System.get_env("DATABASE_URL", "ecto://postgres:postgres@memex-db/memex")
|
||||
end
|
||||
|
||||
host =
|
||||
@ -38,7 +39,7 @@ interface =
|
||||
config :memex, Memex.Repo,
|
||||
# ssl: true,
|
||||
url: database_url,
|
||||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
|
||||
pool_size: String.to_integer(System.get_env("POOL_SIZE", "10")),
|
||||
socket_options: maybe_ipv6
|
||||
|
||||
config :memex, MemexWeb.Endpoint,
|
||||
@ -47,10 +48,13 @@ config :memex, MemexWeb.Endpoint,
|
||||
# See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
|
||||
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
|
||||
ip: interface,
|
||||
port: String.to_integer(System.get_env("PORT") || "4000")
|
||||
port: String.to_integer(System.get_env("PORT", "4000"))
|
||||
],
|
||||
server: true,
|
||||
registration: System.get_env("REGISTRATION") || "invite"
|
||||
server: true
|
||||
|
||||
if config_env() in [:dev, :prod] do
|
||||
config :memex, Memex.Accounts, registration: System.get_env("REGISTRATION", "invite")
|
||||
end
|
||||
|
||||
if config_env() == :prod do
|
||||
# The secret key base is used to sign/encrypt cookies and other secrets.
|
||||
@ -62,7 +66,7 @@ if config_env() == :prod do
|
||||
System.get_env("SECRET_KEY_BASE") ||
|
||||
raise """
|
||||
environment variable SECRET_KEY_BASE is missing.
|
||||
You can generate one by calling: mix phx.gen.secret
|
||||
You can generate one by running: mix phx.gen.secret
|
||||
"""
|
||||
|
||||
config :memex, MemexWeb.Endpoint, secret_key_base: secret_key_base
|
||||
@ -74,12 +78,12 @@ if config_env() == :prod do
|
||||
config :memex, Memex.Mailer,
|
||||
adapter: Swoosh.Adapters.SMTP,
|
||||
relay: System.get_env("SMTP_HOST") || raise("No SMTP_HOST set!"),
|
||||
port: System.get_env("SMTP_PORT") || 587,
|
||||
port: System.get_env("SMTP_PORT", "587"),
|
||||
username: System.get_env("SMTP_USERNAME") || raise("No SMTP_USERNAME set!"),
|
||||
password: System.get_env("SMTP_PASSWORD") || raise("No SMTP_PASSWORD set!"),
|
||||
ssl: System.get_env("SMTP_SSL") == "true",
|
||||
email_from: System.get_env("EMAIL_FROM") || "no-reply@#{System.get_env("HOST")}",
|
||||
email_name: System.get_env("EMAIL_NAME") || "Memex"
|
||||
email_from: System.get_env("EMAIL_FROM", "no-reply@#{System.get_env("HOST")}"),
|
||||
email_name: System.get_env("EMAIL_NAME", "memEx")
|
||||
|
||||
# ## Using releases
|
||||
#
|
||||
|
@ -22,6 +22,9 @@ config :memex, MemexWeb.Endpoint,
|
||||
# In test we don't send emails.
|
||||
config :memex, Memex.Mailer, adapter: Swoosh.Adapters.Test
|
||||
|
||||
# Don't require invites for signups
|
||||
config :memex, Memex.Accounts, registration: "public"
|
||||
|
||||
# Print only warnings and errors during test
|
||||
config :logger, level: :warn
|
||||
|
||||
|
@ -77,14 +77,14 @@ Check them out!
|
||||
For development, I recommend setting environment variables with
|
||||
[direnv](https://direnv.net).
|
||||
|
||||
By default, Memex will always bind to all external IPv4 and IPv6 addresses in
|
||||
By default, memEx will always bind to all external IPv4 and IPv6 addresses in
|
||||
`dev` and `prod` mode, respectively. If you would like to use different values,
|
||||
they will need to be overridden in `config/dev.exs` and `config/runtime.exs` for
|
||||
`dev` and `prod` modes, respectively.
|
||||
|
||||
## `MIX_ENV=dev`
|
||||
|
||||
In `dev` mode, Memex will listen for these environment variables at runtime.
|
||||
In `dev` mode, memEx will listen for these environment variables at runtime.
|
||||
|
||||
- `HOST`: External url to generate links with. Set this especially if you're
|
||||
behind a reverse proxy. Defaults to `localhost`. External URLs will always be
|
||||
@ -100,7 +100,7 @@ In `dev` mode, Memex will listen for these environment variables at runtime.
|
||||
|
||||
## `MIX_ENV=test`
|
||||
|
||||
In `test` mode (or in the Docker container), Memex will listen for the same environment variables as dev mode, but also include the following at runtime:
|
||||
In `test` mode (or in the Docker container), memEx will listen for the same environment variables as dev mode, but also include the following at runtime:
|
||||
|
||||
- `TEST_DATABASE_URL`: REPLACES `DATABASE_URL`. Controls the database url to
|
||||
connect to. Defaults to `ecto://postgres:postgres@localhost/memex_test`.
|
||||
@ -110,7 +110,7 @@ In `test` mode (or in the Docker container), Memex will listen for the same envi
|
||||
|
||||
## `MIX_ENV=prod`
|
||||
|
||||
In `prod` mode (or in the Docker container), Memex will listen for the same environment variables as dev mode, but also include the following at runtime:
|
||||
In `prod` mode (or in the Docker container), memEx will listen for the same environment variables as dev mode, but also include the following at runtime:
|
||||
|
||||
- `SECRET_KEY_BASE`: Secret key base used to sign cookies. Must be generated
|
||||
with `docker run -it shibaobun/memex mix phx.gen.secret` and set for server to start.
|
||||
@ -121,4 +121,4 @@ In `prod` mode (or in the Docker container), Memex will listen for the same envi
|
||||
- `SMTP_SSL`: Set to `true` to enable SSL for emails. Defaults to `false`.
|
||||
- `EMAIL_FROM`: Sets the sender email in sent emails. Defaults to
|
||||
`no-reply@HOST` where `HOST` was previously defined.
|
||||
- `EMAIL_NAME`: Sets the sender name in sent emails. Defaults to "Memex".
|
||||
- `EMAIL_NAME`: Sets the sender name in sent emails. Defaults to "memEx".
|
||||
|
10
de.tbx
Normal file
10
de.tbx
Normal file
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0"?>
|
||||
<!DOCTYPE martif PUBLIC "ISO 12200:1999A//DTD MARTIF core (DXFcdV04)//EN" "TBXcdv04.dtd">
|
||||
<martif type="TBX">
|
||||
<martifHeader>
|
||||
<fileDesc>
|
||||
<sourceDesc><p>Translate Toolkit</p></sourceDesc>
|
||||
</fileDesc>
|
||||
</martifHeader>
|
||||
<text><body></body></text>
|
||||
</martif>
|
@ -2,8 +2,7 @@ version: '3'
|
||||
|
||||
services:
|
||||
memex:
|
||||
build:
|
||||
context: .
|
||||
image: shibaobun/memex
|
||||
container_name: memex
|
||||
restart: always
|
||||
environment:
|
||||
@ -25,8 +24,8 @@ services:
|
||||
# - SMTP_SSL=false
|
||||
# optional, default is format below
|
||||
# - EMAIL_FROM=no-reply@memex.example.tld
|
||||
# optional, default is "Memex"
|
||||
# - EMAIL_NAME=Memex
|
||||
# optional, default is "memEx"
|
||||
# - EMAIL_NAME=memEx
|
||||
expose:
|
||||
- "4000"
|
||||
depends_on:
|
||||
|
@ -5,7 +5,7 @@ defmodule Memex.Accounts do
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Memex.{Mailer, Repo}
|
||||
alias Memex.Accounts.{User, UserToken}
|
||||
alias Memex.Accounts.{Invite, Invites, User, UserToken}
|
||||
alias Ecto.{Changeset, Multi}
|
||||
alias Oban.Job
|
||||
|
||||
@ -23,22 +23,24 @@ defmodule Memex.Accounts do
|
||||
nil
|
||||
|
||||
"""
|
||||
@spec get_user_by_email(String.t()) :: User.t() | nil
|
||||
def get_user_by_email(email) when is_binary(email), do: Repo.get_by(User, email: email)
|
||||
@spec get_user_by_email(email :: String.t()) :: User.t() | nil
|
||||
def get_user_by_email(email) when is_binary(email) do
|
||||
Repo.get_by(User, email: email)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a user by email and password.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user_by_email_and_password("foo@example.com", "correct_password")
|
||||
iex> get_user_by_email_and_password("foo@example.com", "valid_password")
|
||||
%User{}
|
||||
|
||||
iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
|
||||
nil
|
||||
|
||||
"""
|
||||
@spec get_user_by_email_and_password(String.t(), String.t()) ::
|
||||
@spec get_user_by_email_and_password(email :: String.t(), password :: String.t()) ::
|
||||
User.t() | nil
|
||||
def get_user_by_email_and_password(email, password)
|
||||
when is_binary(email) and is_binary(password) do
|
||||
@ -53,28 +55,30 @@ defmodule Memex.Accounts do
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user!(123)
|
||||
%User{}
|
||||
iex> get_user!(user_id)
|
||||
user
|
||||
|
||||
iex> get_user!(456)
|
||||
iex> get_user!()
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
@spec get_user!(User.t()) :: User.t()
|
||||
def get_user!(id), do: Repo.get!(User, id)
|
||||
@spec get_user!(User.id()) :: User.t()
|
||||
def get_user!(id) do
|
||||
Repo.get!(User, id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns all users grouped by role.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_users_by_role(%User{id: 123, role: :admin})
|
||||
[admin: [%User{}], user: [%User{}, %User{}]]
|
||||
iex> list_all_users_by_role(user1)
|
||||
%{admin: [%User{role: :admin}], user: [%User{role: :user}]}
|
||||
|
||||
"""
|
||||
@spec list_all_users_by_role(User.t()) :: %{String.t() => [User.t()]}
|
||||
@spec list_all_users_by_role(User.t()) :: %{User.role() => [User.t()]}
|
||||
def list_all_users_by_role(%User{role: :admin}) do
|
||||
Repo.all(from u in User, order_by: u.email) |> Enum.group_by(fn user -> user.role end)
|
||||
Repo.all(from u in User, order_by: u.email) |> Enum.group_by(fn %{role: role} -> role end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
@ -82,13 +86,12 @@ defmodule Memex.Accounts do
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_users_by_role(%User{id: 123, role: :admin})
|
||||
[%User{}]
|
||||
iex> list_users_by_role(:admin)
|
||||
[%User{role: :admin}]
|
||||
|
||||
"""
|
||||
@spec list_users_by_role(:admin | :user) :: [User.t()]
|
||||
def list_users_by_role(role) do
|
||||
role = role |> to_string()
|
||||
@spec list_users_by_role(:admin) :: [User.t()]
|
||||
def list_users_by_role(:admin = role) do
|
||||
Repo.all(from u in User, where: u.role == ^role, order_by: u.email)
|
||||
end
|
||||
|
||||
@ -99,22 +102,38 @@ defmodule Memex.Accounts do
|
||||
|
||||
## Examples
|
||||
|
||||
iex> register_user(%{field: value})
|
||||
{:ok, %User{}}
|
||||
iex> register_user(%{email: "foo@example.com", password: "valid_password"})
|
||||
{:ok, %User{email: "foo@example.com"}}
|
||||
|
||||
iex> register_user(%{field: bad_value})
|
||||
iex> register_user(%{email: "foo@example"})
|
||||
{:error, %Changeset{}}
|
||||
|
||||
"""
|
||||
@spec register_user(map()) :: {:ok, User.t()} | {:error, Changeset.t(User.new_user())}
|
||||
def register_user(attrs) do
|
||||
# if no registered users, make first user an admin
|
||||
role =
|
||||
if Repo.one!(from u in User, select: count(u.id), distinct: true) == 0,
|
||||
do: "admin",
|
||||
else: "user"
|
||||
|
||||
%User{} |> User.registration_changeset(attrs |> Map.put("role", role)) |> Repo.insert()
|
||||
@spec register_user(attrs :: map()) ::
|
||||
{:ok, User.t()} | {:error, :invalid_token | User.changeset()}
|
||||
@spec register_user(attrs :: map(), Invite.token() | nil) ::
|
||||
{:ok, User.t()} | {:error, :invalid_token | User.changeset()}
|
||||
def register_user(attrs, invite_token \\ nil) do
|
||||
Multi.new()
|
||||
|> Multi.one(:users_count, from(u in User, select: count(u.id), distinct: true))
|
||||
|> Multi.run(:use_invite, fn _changes_so_far, _repo ->
|
||||
if allow_registration?() and invite_token |> is_nil() do
|
||||
{:ok, nil}
|
||||
else
|
||||
Invites.use_invite(invite_token)
|
||||
end
|
||||
end)
|
||||
|> Multi.insert(:add_user, fn %{users_count: count, use_invite: invite} ->
|
||||
# if no registered users, make first user an admin
|
||||
role = if count == 0, do: :admin, else: :user
|
||||
User.registration_changeset(attrs, invite) |> User.role_changeset(role)
|
||||
end)
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{add_user: user}} -> {:ok, user}
|
||||
{:error, :use_invite, :invalid_token, _changes_so_far} -> {:error, :invalid_token}
|
||||
{:error, :add_user, changeset, _changes_so_far} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
@ -122,16 +141,18 @@ defmodule Memex.Accounts do
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_user_registration(user)
|
||||
%Changeset{data: %User{}}
|
||||
iex> change_user_registration()
|
||||
%Changeset{}
|
||||
|
||||
iex> change_user_registration(%{password: "hi"}
|
||||
%Changeset{}
|
||||
|
||||
"""
|
||||
@spec change_user_registration(User.t() | User.new_user()) ::
|
||||
Changeset.t(User.t() | User.new_user())
|
||||
@spec change_user_registration(User.t() | User.new_user(), map()) ::
|
||||
Changeset.t(User.t() | User.new_user())
|
||||
def change_user_registration(user, attrs \\ %{}),
|
||||
do: User.registration_changeset(user, attrs, hash_password: false)
|
||||
@spec change_user_registration() :: User.changeset()
|
||||
@spec change_user_registration(attrs :: map()) :: User.changeset()
|
||||
def change_user_registration(attrs \\ %{}) do
|
||||
User.registration_changeset(attrs, nil, hash_password: false)
|
||||
end
|
||||
|
||||
## Settings
|
||||
|
||||
@ -140,24 +161,29 @@ defmodule Memex.Accounts do
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_user_email(user)
|
||||
%Changeset{data: %User{}}
|
||||
iex> change_user_email(%User{email: "foo@example.com"})
|
||||
%Changeset{}
|
||||
|
||||
"""
|
||||
@spec change_user_email(User.t(), map()) :: Changeset.t(User.t())
|
||||
def change_user_email(user, attrs \\ %{}), do: User.email_changeset(user, attrs)
|
||||
@spec change_user_email(User.t()) :: User.changeset()
|
||||
@spec change_user_email(User.t(), attrs :: map()) :: User.changeset()
|
||||
def change_user_email(user, attrs \\ %{}) do
|
||||
User.email_changeset(user, attrs)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Changeset{}` for changing the user role.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_user_role(user)
|
||||
%Changeset{data: %User{}}
|
||||
iex> change_user_role(%User{}, :user)
|
||||
%Changeset{}
|
||||
|
||||
"""
|
||||
@spec change_user_role(User.t(), atom()) :: Changeset.t(User.t())
|
||||
def change_user_role(user, role), do: User.role_changeset(user, role)
|
||||
@spec change_user_role(User.t(), User.role()) :: User.changeset()
|
||||
def change_user_role(user, role) do
|
||||
User.role_changeset(user, role)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Emulates that the email will change without actually changing
|
||||
@ -165,15 +191,15 @@ defmodule Memex.Accounts do
|
||||
|
||||
## Examples
|
||||
|
||||
iex> apply_user_email(user, "valid password", %{email: ...})
|
||||
iex> apply_user_email(user, "valid_password", %{email: "new_email@account.com"})
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> apply_user_email(user, "invalid password", %{email: ...})
|
||||
iex> apply_user_email(user, "invalid password", %{email: "new_email@account"})
|
||||
{:error, %Changeset{}}
|
||||
|
||||
"""
|
||||
@spec apply_user_email(User.t(), String.t(), map()) ::
|
||||
{:ok, User.t()} | {:error, Changeset.t(User.t())}
|
||||
@spec apply_user_email(User.t(), email :: String.t(), attrs :: map()) ::
|
||||
{:ok, User.t()} | {:error, User.changeset()}
|
||||
def apply_user_email(user, password, attrs) do
|
||||
user
|
||||
|> User.email_changeset(attrs)
|
||||
@ -187,7 +213,7 @@ defmodule Memex.Accounts do
|
||||
If the token matches, the user email is updated and the token is deleted.
|
||||
The confirmed_at date is also updated to the current time.
|
||||
"""
|
||||
@spec update_user_email(User.t(), String.t()) :: :ok | :error
|
||||
@spec update_user_email(User.t(), token :: String.t()) :: :ok | :error
|
||||
def update_user_email(user, token) do
|
||||
context = "change:#{user.email}"
|
||||
|
||||
@ -196,11 +222,11 @@ defmodule Memex.Accounts do
|
||||
{:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do
|
||||
:ok
|
||||
else
|
||||
_ -> :error
|
||||
_error_tuple -> :error
|
||||
end
|
||||
end
|
||||
|
||||
@spec user_email_multi(User.t(), String.t(), String.t()) :: Multi.t()
|
||||
@spec user_email_multi(User.t(), email :: String.t(), context :: String.t()) :: Multi.t()
|
||||
defp user_email_multi(user, email, context) do
|
||||
changeset = user |> User.email_changeset(%{email: email}) |> User.confirm_changeset()
|
||||
|
||||
@ -214,11 +240,12 @@ defmodule Memex.Accounts do
|
||||
|
||||
## Examples
|
||||
|
||||
iex> deliver_update_email_instructions(user, current_email, &Routes.user_update_email_url(conn, :edit, &1))
|
||||
{:ok, %{to: ..., body: ...}}
|
||||
iex> deliver_update_email_instructions(user, "new_foo@example.com", fn _token -> "example url" end)
|
||||
%Oban.Job{args: %{email: :update_email, user_id: ^user_id, attrs: %{url: "example url"}}}
|
||||
|
||||
"""
|
||||
@spec deliver_update_email_instructions(User.t(), String.t(), function) :: Job.t()
|
||||
@spec deliver_update_email_instructions(User.t(), current_email :: String.t(), function) ::
|
||||
Job.t()
|
||||
def deliver_update_email_instructions(user, current_email, update_email_url_fun)
|
||||
when is_function(update_email_url_fun, 1) do
|
||||
{encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")
|
||||
@ -231,28 +258,32 @@ defmodule Memex.Accounts do
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_user_password(user)
|
||||
%Changeset{data: %User{}}
|
||||
iex> change_user_password(%User{})
|
||||
%Changeset{}
|
||||
|
||||
"""
|
||||
@spec change_user_password(User.t(), map()) :: Changeset.t(User.t())
|
||||
def change_user_password(user, attrs \\ %{}),
|
||||
do: User.password_changeset(user, attrs, hash_password: false)
|
||||
@spec change_user_password(User.t(), attrs :: map()) :: User.changeset()
|
||||
def change_user_password(user, attrs \\ %{}) do
|
||||
User.password_changeset(user, attrs, hash_password: false)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the user password.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_user_password(user, "valid password", %{password: ...})
|
||||
iex> reset_user_password(user, %{
|
||||
...> password: "new password",
|
||||
...> password_confirmation: "new password"
|
||||
...> })
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> update_user_password(user, "invalid password", %{password: ...})
|
||||
iex> update_user_password(user, "invalid password", %{password: "123"})
|
||||
{:error, %Changeset{}}
|
||||
|
||||
"""
|
||||
@spec update_user_password(User.t(), String.t(), map()) ::
|
||||
{:ok, User.t()} | {:error, Changeset.t(User.t())}
|
||||
@spec update_user_password(User.t(), String.t(), attrs :: map()) ::
|
||||
{:ok, User.t()} | {:error, User.changeset()}
|
||||
def update_user_password(user, password, attrs) do
|
||||
changeset =
|
||||
user
|
||||
@ -265,54 +296,59 @@ defmodule Memex.Accounts do
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{user: user}} -> {:ok, user}
|
||||
{:error, :user, changeset, _} -> {:error, changeset}
|
||||
{:error, :user, changeset, _changes_so_far} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Changeset{}` for changing the user locale.
|
||||
Returns an `Ecto.Changeset.t()` for changing the user locale.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_user_locale(user)
|
||||
%Changeset{data: %User{}}
|
||||
iex> change_user_locale(%User{})
|
||||
%Changeset{}
|
||||
|
||||
"""
|
||||
@spec change_user_locale(User.t()) :: Changeset.t(User.t())
|
||||
def change_user_locale(%{locale: locale} = user), do: User.locale_changeset(user, locale)
|
||||
@spec change_user_locale(User.t()) :: User.changeset()
|
||||
def change_user_locale(%{locale: locale} = user) do
|
||||
User.locale_changeset(user, locale)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the user locale.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_user_locale(user, "valid locale")
|
||||
iex> update_user_locale(user, "en_US")
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> update_user_password(user, "invalid locale")
|
||||
{:error, %Changeset{}}
|
||||
|
||||
"""
|
||||
@spec update_user_locale(User.t(), locale :: String.t()) ::
|
||||
{:ok, User.t()} | {:error, Changeset.t(User.t())}
|
||||
def update_user_locale(user, locale),
|
||||
do: user |> User.locale_changeset(locale) |> Repo.update()
|
||||
{:ok, User.t()} | {:error, User.changeset()}
|
||||
def update_user_locale(user, locale) do
|
||||
user |> User.locale_changeset(locale) |> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a user. must be performed by an admin or the same user!
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_user!(user_to_delete, %User{id: 123, role: :admin})
|
||||
iex> delete_user!(user, %User{id: 123, role: :admin})
|
||||
%User{}
|
||||
|
||||
iex> delete_user!(%User{id: 123}, %User{id: 123})
|
||||
iex> delete_user!(user, user)
|
||||
%User{}
|
||||
|
||||
"""
|
||||
@spec delete_user!(User.t(), User.t()) :: User.t()
|
||||
def delete_user!(user, %User{role: :admin}), do: user |> Repo.delete!()
|
||||
def delete_user!(%User{id: user_id} = user, %User{id: user_id}), do: user |> Repo.delete!()
|
||||
@spec delete_user!(user_to_delete :: User.t(), User.t()) :: User.t()
|
||||
def delete_user!(user, %User{role: :admin}) do
|
||||
user |> Repo.delete!()
|
||||
end
|
||||
|
||||
def delete_user!(%User{id: user_id} = user, %User{id: user_id}) do
|
||||
user |> Repo.delete!()
|
||||
end
|
||||
|
||||
## Session
|
||||
|
||||
@ -329,7 +365,7 @@ defmodule Memex.Accounts do
|
||||
@doc """
|
||||
Gets the user with the given signed token.
|
||||
"""
|
||||
@spec get_user_by_session_token(String.t()) :: User.t()
|
||||
@spec get_user_by_session_token(token :: String.t()) :: User.t()
|
||||
def get_user_by_session_token(token) do
|
||||
{:ok, query} = UserToken.verify_session_token_query(token)
|
||||
Repo.one(query)
|
||||
@ -338,9 +374,9 @@ defmodule Memex.Accounts do
|
||||
@doc """
|
||||
Deletes the signed token with the given context.
|
||||
"""
|
||||
@spec delete_session_token(String.t()) :: :ok
|
||||
@spec delete_session_token(token :: String.t()) :: :ok
|
||||
def delete_session_token(token) do
|
||||
Repo.delete_all(UserToken.token_and_context_query(token, "session"))
|
||||
UserToken.token_and_context_query(token, "session") |> Repo.delete_all()
|
||||
:ok
|
||||
end
|
||||
|
||||
@ -349,19 +385,43 @@ defmodule Memex.Accounts do
|
||||
"""
|
||||
@spec allow_registration?() :: boolean()
|
||||
def allow_registration? do
|
||||
Application.get_env(:memex, MemexWeb.Endpoint)[:registration] == "public" or
|
||||
Application.get_env(:memex, Memex.Accounts)[:registration] == "public" or
|
||||
list_users_by_role(:admin) |> Enum.empty?()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if user is an admin
|
||||
|
||||
## Examples
|
||||
|
||||
iex> is_admin?(%User{role: :admin})
|
||||
true
|
||||
|
||||
iex> is_admin?(%User{})
|
||||
false
|
||||
|
||||
"""
|
||||
@spec is_admin?(User.t()) :: boolean()
|
||||
def is_admin?(%User{id: user_id}) do
|
||||
Repo.one(from u in User, where: u.id == ^user_id and u.role == :admin)
|
||||
|> is_nil()
|
||||
Repo.exists?(from u in User, where: u.id == ^user_id, where: u.role == :admin)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks to see if user has the admin role
|
||||
|
||||
## Examples
|
||||
|
||||
iex> is_already_admin?(%User{role: :admin})
|
||||
true
|
||||
|
||||
iex> is_already_admin?(%User{})
|
||||
false
|
||||
|
||||
"""
|
||||
@spec is_already_admin?(User.t() | nil) :: boolean()
|
||||
def is_already_admin?(%User{role: :admin}), do: true
|
||||
def is_already_admin?(_invalid_user), do: false
|
||||
|
||||
## Confirmation
|
||||
|
||||
@doc """
|
||||
@ -369,10 +429,10 @@ defmodule Memex.Accounts do
|
||||
|
||||
## Examples
|
||||
|
||||
iex> deliver_user_confirmation_instructions(user, &Routes.user_confirmation_url(conn, :confirm, &1))
|
||||
{:ok, %{to: ..., body: ...}}
|
||||
iex> deliver_user_confirmation_instructions(user, fn _token -> "example url" end)
|
||||
%Oban.Job{args: %{email: :welcome, user_id: ^user_id, attrs: %{url: "example url"}}}
|
||||
|
||||
iex> deliver_user_confirmation_instructions(confirmed_user, &Routes.user_confirmation_url(conn, :confirm, &1))
|
||||
iex> deliver_user_confirmation_instructions(user, fn _token -> "example url" end)
|
||||
{:error, :already_confirmed}
|
||||
|
||||
"""
|
||||
@ -394,14 +454,14 @@ defmodule Memex.Accounts do
|
||||
If the token matches, the user account is marked as confirmed
|
||||
and the token is deleted.
|
||||
"""
|
||||
@spec confirm_user(String.t()) :: {:ok, User.t()} | atom()
|
||||
@spec confirm_user(token :: String.t()) :: {:ok, User.t()} | :error
|
||||
def confirm_user(token) do
|
||||
with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"),
|
||||
%User{} = user <- Repo.one(query),
|
||||
{:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do
|
||||
{:ok, user}
|
||||
else
|
||||
_ -> :error
|
||||
_error_tuple -> :error
|
||||
end
|
||||
end
|
||||
|
||||
@ -419,8 +479,8 @@ defmodule Memex.Accounts do
|
||||
|
||||
## Examples
|
||||
|
||||
iex> deliver_user_reset_password_instructions(user, &Routes.user_reset_password_url(conn, :edit, &1))
|
||||
{:ok, %{to: ..., body: ...}}
|
||||
iex> deliver_user_reset_password_instructions(user, fn _token -> "example url" end)
|
||||
%Oban.Job{args: %{email: :reset_password, user_id: ^user_id, attrs: %{url: "example url"}}}
|
||||
|
||||
"""
|
||||
@spec deliver_user_reset_password_instructions(User.t(), function()) :: Job.t()
|
||||
@ -436,20 +496,20 @@ defmodule Memex.Accounts do
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user_by_reset_password_token("validtoken")
|
||||
iex> get_user_by_reset_password_token(encoded_token)
|
||||
%User{}
|
||||
|
||||
iex> get_user_by_reset_password_token("invalidtoken")
|
||||
nil
|
||||
|
||||
"""
|
||||
@spec get_user_by_reset_password_token(String.t()) :: User.t() | nil
|
||||
@spec get_user_by_reset_password_token(token :: String.t()) :: User.t() | nil
|
||||
def get_user_by_reset_password_token(token) do
|
||||
with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"),
|
||||
%User{} = user <- Repo.one(query) do
|
||||
user
|
||||
else
|
||||
_ -> nil
|
||||
_error_tuple -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@ -458,14 +518,18 @@ defmodule Memex.Accounts do
|
||||
|
||||
## Examples
|
||||
|
||||
iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
|
||||
iex> reset_user_password(user, %{
|
||||
...> password: "new password",
|
||||
...> password_confirmation: "new password"
|
||||
...> })
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
|
||||
{:error, %Changeset{}}
|
||||
|
||||
"""
|
||||
@spec reset_user_password(User.t(), map()) :: {:ok, User.t()} | {:error, Changeset.t(User.t())}
|
||||
@spec reset_user_password(User.t(), attrs :: map()) ::
|
||||
{:ok, User.t()} | {:error, User.changeset()}
|
||||
def reset_user_password(user, attrs) do
|
||||
Multi.new()
|
||||
|> Multi.update(:user, User.password_changeset(user, attrs))
|
||||
@ -473,7 +537,7 @@ defmodule Memex.Accounts do
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{user: user}} -> {:ok, user}
|
||||
{:error, :user, changeset, _} -> {:error, changeset}
|
||||
{:error, :user, changeset, _changes_so_far} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -19,8 +19,8 @@ defmodule Memex.Email do
|
||||
|
||||
@spec base_email(User.t(), String.t()) :: t()
|
||||
defp base_email(%User{email: email}, subject) do
|
||||
from = Application.get_env(:Memex, Memex.Mailer)[:email_from] || "noreply@localhost"
|
||||
name = Application.get_env(:Memex, Memex.Mailer)[:email_name]
|
||||
from = Application.get_env(:memex, Memex.Mailer)[:email_from] || "noreply@localhost"
|
||||
name = Application.get_env(:memex, Memex.Mailer)[:email_name]
|
||||
new() |> to(email) |> from({name, from}) |> subject(subject)
|
||||
end
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
defmodule Memex.Invites.Invite do
|
||||
defmodule Memex.Accounts.Invite do
|
||||
@moduledoc """
|
||||
An invite, created by an admin to allow someone to join their instance. An
|
||||
invite can be enabled or disabled, and can have an optional number of uses if
|
||||
@ -7,8 +7,8 @@ defmodule Memex.Invites.Invite do
|
||||
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
alias Ecto.{Changeset, UUID}
|
||||
alias Memex.{Accounts.User, Invites.Invite}
|
||||
alias Ecto.{Association, Changeset, UUID}
|
||||
alias Memex.Accounts.User
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
@ -18,40 +18,46 @@ defmodule Memex.Invites.Invite do
|
||||
field :uses_left, :integer, default: nil
|
||||
field :disabled_at, :naive_datetime
|
||||
|
||||
belongs_to :user, User
|
||||
belongs_to :created_by, User
|
||||
|
||||
has_many :users, User
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@type t :: %Invite{
|
||||
@type t :: %__MODULE__{
|
||||
id: id(),
|
||||
name: String.t(),
|
||||
token: String.t(),
|
||||
token: token(),
|
||||
uses_left: integer() | nil,
|
||||
disabled_at: NaiveDateTime.t(),
|
||||
user: User.t(),
|
||||
user_id: User.id(),
|
||||
created_by: User.t() | nil | Association.NotLoaded.t(),
|
||||
created_by_id: User.id() | nil,
|
||||
users: [User.t()] | Association.NotLoaded.t(),
|
||||
inserted_at: NaiveDateTime.t(),
|
||||
updated_at: NaiveDateTime.t()
|
||||
}
|
||||
@type new_invite :: %Invite{}
|
||||
@type new_invite :: %__MODULE__{}
|
||||
@type id :: UUID.t()
|
||||
@type changeset :: Changeset.t(t() | new_invite())
|
||||
@type token :: String.t()
|
||||
|
||||
@doc false
|
||||
@spec create_changeset(new_invite(), attrs :: map()) :: Changeset.t(new_invite())
|
||||
def create_changeset(invite, attrs) do
|
||||
invite
|
||||
|> cast(attrs, [:name, :token, :uses_left, :disabled_at, :user_id])
|
||||
|> validate_required([:name, :token, :user_id])
|
||||
@spec create_changeset(User.t(), token(), attrs :: map()) :: changeset()
|
||||
def create_changeset(%User{id: user_id}, token, attrs) do
|
||||
%__MODULE__{}
|
||||
|> change(token: token, created_by_id: user_id)
|
||||
|> cast(attrs, [:name, :uses_left, :disabled_at])
|
||||
|> validate_required([:name, :token, :created_by_id])
|
||||
|> validate_number(:uses_left, greater_than_or_equal_to: 0)
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec update_changeset(t() | new_invite(), attrs :: map()) :: Changeset.t(t() | new_invite())
|
||||
@spec update_changeset(t() | new_invite(), attrs :: map()) :: changeset()
|
||||
def update_changeset(invite, attrs) do
|
||||
invite
|
||||
|> cast(attrs, [:name, :uses_left, :disabled_at])
|
||||
|> validate_required([:name, :token, :user_id])
|
||||
|> validate_required([:name])
|
||||
|> validate_number(:uses_left, greater_than_or_equal_to: 0)
|
||||
end
|
||||
end
|
198
lib/memex/accounts/invites.ex
Normal file
198
lib/memex/accounts/invites.ex
Normal file
@ -0,0 +1,198 @@
|
||||
defmodule Memex.Accounts.Invites do
|
||||
@moduledoc """
|
||||
The Invites context.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Ecto.Multi
|
||||
alias Memex.Accounts.{Invite, User}
|
||||
alias Memex.Repo
|
||||
|
||||
@invite_token_length 20
|
||||
|
||||
@doc """
|
||||
Returns the list of invites.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_invites(%User{id: 123, role: :admin})
|
||||
[%Invite{}, ...]
|
||||
|
||||
"""
|
||||
@spec list_invites(User.t()) :: [Invite.t()]
|
||||
def list_invites(%User{role: :admin}) do
|
||||
Repo.all(from i in Invite, order_by: i.name)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single invite for a user
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Invite does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_invite!(123, %User{id: 123, role: :admin})
|
||||
%Invite{}
|
||||
|
||||
> get_invite!(456, %User{id: 123, role: :admin})
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
@spec get_invite!(Invite.id(), User.t()) :: Invite.t()
|
||||
def get_invite!(id, %User{role: :admin}) do
|
||||
Repo.get!(Invite, id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns if an invite token is still valid
|
||||
|
||||
## Examples
|
||||
|
||||
iex> valid_invite_token?("valid_token")
|
||||
%Invite{}
|
||||
|
||||
iex> valid_invite_token?("invalid_token")
|
||||
nil
|
||||
"""
|
||||
@spec valid_invite_token?(Invite.token() | nil) :: boolean()
|
||||
def valid_invite_token?(token) when token in [nil, ""], do: false
|
||||
|
||||
def valid_invite_token?(token) do
|
||||
Repo.exists?(
|
||||
from i in Invite,
|
||||
where: i.token == ^token,
|
||||
where: i.disabled_at |> is_nil()
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Uses invite by decrementing uses_left, or marks invite invalid if it's been
|
||||
completely used.
|
||||
"""
|
||||
@spec use_invite(Invite.token()) :: {:ok, Invite.t()} | {:error, :invalid_token}
|
||||
def use_invite(invite_token) do
|
||||
Multi.new()
|
||||
|> Multi.run(:invite, fn _changes_so_far, _repo ->
|
||||
invite_token |> get_invite_by_token()
|
||||
end)
|
||||
|> Multi.update(:decrement_invite, fn %{invite: invite} ->
|
||||
decrement_invite_changeset(invite)
|
||||
end)
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{decrement_invite: invite}} -> {:ok, invite}
|
||||
{:error, :invite, :invalid_token, _changes_so_far} -> {:error, :invalid_token}
|
||||
end
|
||||
end
|
||||
|
||||
@spec get_invite_by_token(Invite.token() | nil) :: {:ok, Invite.t()} | {:error, :invalid_token}
|
||||
defp get_invite_by_token(token) when token in [nil, ""], do: {:error, :invalid_token}
|
||||
|
||||
defp get_invite_by_token(token) do
|
||||
Repo.one(
|
||||
from i in Invite,
|
||||
where: i.token == ^token,
|
||||
where: i.disabled_at |> is_nil()
|
||||
)
|
||||
|> case do
|
||||
nil -> {:error, :invalid_token}
|
||||
invite -> {:ok, invite}
|
||||
end
|
||||
end
|
||||
|
||||
@spec get_use_count(Invite.t(), User.t()) :: non_neg_integer()
|
||||
def get_use_count(%Invite{id: invite_id}, %User{role: :admin}) do
|
||||
Repo.one(
|
||||
from u in User,
|
||||
where: u.invite_id == ^invite_id,
|
||||
select: count(u.id)
|
||||
)
|
||||
end
|
||||
|
||||
@spec decrement_invite_changeset(Invite.t()) :: Invite.changeset()
|
||||
defp decrement_invite_changeset(%Invite{uses_left: nil} = invite) do
|
||||
invite |> Invite.update_changeset(%{})
|
||||
end
|
||||
|
||||
defp decrement_invite_changeset(%Invite{uses_left: 1} = invite) do
|
||||
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
|
||||
invite |> Invite.update_changeset(%{uses_left: 0, disabled_at: now})
|
||||
end
|
||||
|
||||
defp decrement_invite_changeset(%Invite{uses_left: uses_left} = invite) do
|
||||
invite |> Invite.update_changeset(%{uses_left: uses_left - 1})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a invite.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_invite(%User{id: 123, role: :admin}, %{field: value})
|
||||
{:ok, %Invite{}}
|
||||
|
||||
iex> create_invite(%User{id: 123, role: :admin}, %{field: bad_value})
|
||||
{:error, %Changeset{}}
|
||||
|
||||
"""
|
||||
@spec create_invite(User.t(), attrs :: map()) ::
|
||||
{:ok, Invite.t()} | {:error, Invite.changeset()}
|
||||
def create_invite(%User{role: :admin} = user, attrs) do
|
||||
token =
|
||||
:crypto.strong_rand_bytes(@invite_token_length)
|
||||
|> Base.url_encode64()
|
||||
|> binary_part(0, @invite_token_length)
|
||||
|
||||
Invite.create_changeset(user, token, attrs) |> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a invite.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_invite(invite, %{field: new_value}, %User{id: 123, role: :admin})
|
||||
{:ok, %Invite{}}
|
||||
|
||||
iex> update_invite(invite, %{field: bad_value}, %User{id: 123, role: :admin})
|
||||
{:error, %Changeset{}}
|
||||
|
||||
"""
|
||||
@spec update_invite(Invite.t(), attrs :: map(), User.t()) ::
|
||||
{:ok, Invite.t()} | {:error, Invite.changeset()}
|
||||
def update_invite(invite, attrs, %User{role: :admin}) do
|
||||
invite |> Invite.update_changeset(attrs) |> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a invite.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_invite(invite, %User{id: 123, role: :admin})
|
||||
{:ok, %Invite{}}
|
||||
|
||||
iex> delete_invite(invite, %User{id: 123, role: :admin})
|
||||
{:error, %Changeset{}}
|
||||
|
||||
"""
|
||||
@spec delete_invite(Invite.t(), User.t()) ::
|
||||
{:ok, Invite.t()} | {:error, Invite.changeset()}
|
||||
def delete_invite(invite, %User{role: :admin}) do
|
||||
invite |> Repo.delete()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a invite.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_invite(invite, %User{id: 123, role: :admin})
|
||||
%Invite{}
|
||||
|
||||
"""
|
||||
@spec delete_invite!(Invite.t(), User.t()) :: Invite.t()
|
||||
def delete_invite!(invite, %User{role: :admin}) do
|
||||
invite |> Repo.delete!()
|
||||
end
|
||||
end
|
@ -6,9 +6,19 @@ defmodule Memex.Accounts.User do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
import MemexWeb.Gettext
|
||||
alias Ecto.{Changeset, UUID}
|
||||
alias Memex.{Accounts.User, Invites.Invite}
|
||||
alias Ecto.{Association, Changeset, UUID}
|
||||
alias Memex.Accounts.{Invite, User}
|
||||
|
||||
@derive {Jason.Encoder,
|
||||
only: [
|
||||
:id,
|
||||
:email,
|
||||
:confirmed_at,
|
||||
:role,
|
||||
:locale,
|
||||
:inserted_at,
|
||||
:updated_at
|
||||
]}
|
||||
@derive {Inspect, except: [:password]}
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
@ -20,7 +30,9 @@ defmodule Memex.Accounts.User do
|
||||
field :role, Ecto.Enum, values: [:admin, :user], default: :user
|
||||
field :locale, :string
|
||||
|
||||
has_many :invites, Invite, on_delete: :delete_all
|
||||
has_many :created_invites, Invite, foreign_key: :created_by_id
|
||||
|
||||
belongs_to :invite, Invite
|
||||
|
||||
timestamps()
|
||||
end
|
||||
@ -31,14 +43,18 @@ defmodule Memex.Accounts.User do
|
||||
password: String.t(),
|
||||
hashed_password: String.t(),
|
||||
confirmed_at: NaiveDateTime.t(),
|
||||
role: atom(),
|
||||
invites: [Invite.t()],
|
||||
role: role(),
|
||||
locale: String.t() | nil,
|
||||
created_invites: [Invite.t()] | Association.NotLoaded.t(),
|
||||
invite: Invite.t() | nil | Association.NotLoaded.t(),
|
||||
invite_id: Invite.id() | nil,
|
||||
inserted_at: NaiveDateTime.t(),
|
||||
updated_at: NaiveDateTime.t()
|
||||
}
|
||||
@type new_user :: %User{}
|
||||
@type id :: UUID.t()
|
||||
@type changeset :: Changeset.t(t() | new_user())
|
||||
@type role :: :admin | :user
|
||||
|
||||
@doc """
|
||||
A user changeset for registration.
|
||||
@ -57,26 +73,25 @@ defmodule Memex.Accounts.User do
|
||||
validations on a LiveView form), this option can be set to `false`.
|
||||
Defaults to `true`.
|
||||
"""
|
||||
@spec registration_changeset(t() | new_user(), attrs :: map()) :: Changeset.t(t() | new_user())
|
||||
@spec registration_changeset(t() | new_user(), attrs :: map(), opts :: keyword()) ::
|
||||
Changeset.t(t() | new_user())
|
||||
def registration_changeset(user, attrs, opts \\ []) do
|
||||
user
|
||||
|> cast(attrs, [:email, :password, :role, :locale])
|
||||
@spec registration_changeset(attrs :: map(), Invite.t() | nil) :: changeset()
|
||||
@spec registration_changeset(attrs :: map(), Invite.t() | nil, opts :: keyword()) :: changeset()
|
||||
def registration_changeset(attrs, invite, opts \\ []) do
|
||||
%User{}
|
||||
|> cast(attrs, [:email, :password, :locale])
|
||||
|> put_change(:invite_id, if(invite, do: invite.id))
|
||||
|> validate_email()
|
||||
|> validate_password(opts)
|
||||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for role.
|
||||
|
||||
"""
|
||||
@spec role_changeset(t(), role :: atom()) :: Changeset.t(t())
|
||||
@spec role_changeset(t() | new_user() | changeset(), role()) :: changeset()
|
||||
def role_changeset(user, role) do
|
||||
user |> cast(%{"role" => role}, [:role])
|
||||
user |> change(role: role)
|
||||
end
|
||||
|
||||
@spec validate_email(Changeset.t(t() | new_user())) :: Changeset.t(t() | new_user())
|
||||
@spec validate_email(changeset()) :: changeset()
|
||||
defp validate_email(changeset) do
|
||||
changeset
|
||||
|> validate_required([:email])
|
||||
@ -88,8 +103,8 @@ defmodule Memex.Accounts.User do
|
||||
|> unique_constraint(:email)
|
||||
end
|
||||
|
||||
@spec validate_password(Changeset.t(t() | new_user()), opts :: keyword()) ::
|
||||
Changeset.t(t() | new_user())
|
||||
@spec validate_password(changeset(), opts :: keyword()) ::
|
||||
changeset()
|
||||
defp validate_password(changeset, opts) do
|
||||
changeset
|
||||
|> validate_required([:password])
|
||||
@ -100,8 +115,7 @@ defmodule Memex.Accounts.User do
|
||||
|> maybe_hash_password(opts)
|
||||
end
|
||||
|
||||
@spec maybe_hash_password(Changeset.t(t() | new_user()), opts :: keyword()) ::
|
||||
Changeset.t(t() | new_user())
|
||||
@spec maybe_hash_password(changeset(), opts :: keyword()) :: changeset()
|
||||
defp maybe_hash_password(changeset, opts) do
|
||||
hash_password? = Keyword.get(opts, :hash_password, true)
|
||||
password = get_change(changeset, :password)
|
||||
@ -120,7 +134,7 @@ defmodule Memex.Accounts.User do
|
||||
|
||||
It requires the email to change otherwise an error is added.
|
||||
"""
|
||||
@spec email_changeset(t(), attrs :: map()) :: Changeset.t(t())
|
||||
@spec email_changeset(t(), attrs :: map()) :: changeset()
|
||||
def email_changeset(user, attrs) do
|
||||
user
|
||||
|> cast(attrs, [:email])
|
||||
@ -143,8 +157,8 @@ defmodule Memex.Accounts.User do
|
||||
validations on a LiveView form), this option can be set to `false`.
|
||||
Defaults to `true`.
|
||||
"""
|
||||
@spec password_changeset(t(), attrs :: map()) :: Changeset.t(t())
|
||||
@spec password_changeset(t(), attrs :: map(), opts :: keyword()) :: Changeset.t(t())
|
||||
@spec password_changeset(t(), attrs :: map()) :: changeset()
|
||||
@spec password_changeset(t(), attrs :: map(), opts :: keyword()) :: changeset()
|
||||
def password_changeset(user, attrs, opts \\ []) do
|
||||
user
|
||||
|> cast(attrs, [:password])
|
||||
@ -155,7 +169,7 @@ defmodule Memex.Accounts.User do
|
||||
@doc """
|
||||
Confirms the account by setting `confirmed_at`.
|
||||
"""
|
||||
@spec confirm_changeset(t() | Changeset.t(t())) :: Changeset.t(t())
|
||||
@spec confirm_changeset(t() | changeset()) :: changeset()
|
||||
def confirm_changeset(user_or_changeset) do
|
||||
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
|
||||
user_or_changeset |> change(confirmed_at: now)
|
||||
@ -173,7 +187,7 @@ defmodule Memex.Accounts.User do
|
||||
Bcrypt.verify_pass(password, hashed_password)
|
||||
end
|
||||
|
||||
def valid_password?(_, _) do
|
||||
def valid_password?(_invalid_user, _invalid_password) do
|
||||
Bcrypt.no_user_verify()
|
||||
false
|
||||
end
|
||||
@ -181,7 +195,7 @@ defmodule Memex.Accounts.User do
|
||||
@doc """
|
||||
Validates the current password otherwise adds an error to the changeset.
|
||||
"""
|
||||
@spec validate_current_password(Changeset.t(t()), String.t()) :: Changeset.t(t())
|
||||
@spec validate_current_password(changeset(), String.t()) :: changeset()
|
||||
def validate_current_password(changeset, password) do
|
||||
if valid_password?(changeset.data, password),
|
||||
do: changeset,
|
||||
@ -191,7 +205,7 @@ defmodule Memex.Accounts.User do
|
||||
@doc """
|
||||
A changeset for changing the user's locale
|
||||
"""
|
||||
@spec locale_changeset(t() | Changeset.t(t()), locale :: String.t() | nil) :: Changeset.t(t())
|
||||
@spec locale_changeset(t() | changeset(), locale :: String.t() | nil) :: changeset()
|
||||
def locale_changeset(user_or_changeset, locale) do
|
||||
user_or_changeset
|
||||
|> cast(%{"locale" => locale}, [:locale])
|
||||
|
@ -1,77 +0,0 @@
|
||||
defmodule Memex.Accounts.UserNotifier do
|
||||
@moduledoc """
|
||||
Contains templates and messages for user messages
|
||||
"""
|
||||
|
||||
# For simplicity, this module simply logs messages to the terminal.
|
||||
# You should replace it by a proper email or notification tool, such as:
|
||||
#
|
||||
# * Swoosh - https://hexdocs.pm/swoosh
|
||||
# * Bamboo - https://hexdocs.pm/bamboo
|
||||
#
|
||||
defp deliver(to, body) do
|
||||
require Logger
|
||||
Logger.debug(body)
|
||||
{:ok, %{to: to, body: body}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to confirm account.
|
||||
"""
|
||||
def deliver_confirmation_instructions(user, url) do
|
||||
deliver(user.email, """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can confirm your account by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't create an account with us, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to reset a user password.
|
||||
"""
|
||||
def deliver_reset_password_instructions(user, url) do
|
||||
deliver(user.email, """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can reset your password by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't request this change, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to update a user email.
|
||||
"""
|
||||
def deliver_update_email_instructions(user, url) do
|
||||
deliver(user.email, """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can change your email by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't request this change, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
end
|
||||
end
|
@ -5,6 +5,8 @@ defmodule Memex.Accounts.UserToken do
|
||||
|
||||
use Ecto.Schema
|
||||
import Ecto.Query
|
||||
alias Ecto.{Association, UUID}
|
||||
alias Memex.Accounts.User
|
||||
|
||||
@hash_algorithm :sha256
|
||||
@rand_size 32
|
||||
@ -22,11 +24,25 @@ defmodule Memex.Accounts.UserToken do
|
||||
field :token, :binary
|
||||
field :context, :string
|
||||
field :sent_to, :string
|
||||
belongs_to :user, Memex.Accounts.User
|
||||
|
||||
belongs_to :user, User
|
||||
|
||||
timestamps(updated_at: false)
|
||||
end
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: id(),
|
||||
token: token(),
|
||||
context: String.t(),
|
||||
sent_to: String.t(),
|
||||
user: User.t() | Association.NotLoaded.t(),
|
||||
user_id: User.id() | nil,
|
||||
inserted_at: NaiveDateTime.t()
|
||||
}
|
||||
@type new_user_token :: %__MODULE__{}
|
||||
@type id :: UUID.t()
|
||||
@type token :: binary()
|
||||
|
||||
@doc """
|
||||
Generates a token that will be stored in a signed place,
|
||||
such as session or cookie. As they are signed, those
|
||||
@ -34,7 +50,7 @@ defmodule Memex.Accounts.UserToken do
|
||||
"""
|
||||
def build_session_token(user) do
|
||||
token = :crypto.strong_rand_bytes(@rand_size)
|
||||
{token, %Memex.Accounts.UserToken{token: token, context: "session", user_id: user.id}}
|
||||
{token, %__MODULE__{token: token, context: "session", user_id: user.id}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
@ -69,7 +85,7 @@ defmodule Memex.Accounts.UserToken do
|
||||
hashed_token = :crypto.hash(@hash_algorithm, token)
|
||||
|
||||
{Base.url_encode64(token, padding: false),
|
||||
%Memex.Accounts.UserToken{
|
||||
%__MODULE__{
|
||||
token: hashed_token,
|
||||
context: context,
|
||||
sent_to: sent_to,
|
||||
@ -129,17 +145,17 @@ defmodule Memex.Accounts.UserToken do
|
||||
Returns the given token with the given context.
|
||||
"""
|
||||
def token_and_context_query(token, context) do
|
||||
from Memex.Accounts.UserToken, where: [token: ^token, context: ^context]
|
||||
from __MODULE__, where: [token: ^token, context: ^context]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all tokens for the given user for the given contexts.
|
||||
"""
|
||||
def user_and_contexts_query(user, :all) do
|
||||
from t in Memex.Accounts.UserToken, where: t.user_id == ^user.id
|
||||
from t in __MODULE__, where: t.user_id == ^user.id
|
||||
end
|
||||
|
||||
def user_and_contexts_query(user, [_ | _] = contexts) do
|
||||
from t in Memex.Accounts.UserToken, where: t.user_id == ^user.id and t.context in ^contexts
|
||||
from t in __MODULE__, where: t.user_id == ^user.id and t.context in ^contexts
|
||||
end
|
||||
end
|
||||
|
@ -4,6 +4,7 @@ defmodule Memex.Application do
|
||||
@moduledoc false
|
||||
|
||||
use Application
|
||||
alias Memex.Logger
|
||||
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
@ -17,16 +18,24 @@ defmodule Memex.Application do
|
||||
# Start the Endpoint (http/https)
|
||||
MemexWeb.Endpoint,
|
||||
# Add Oban
|
||||
{Oban, oban_config()}
|
||||
{Oban, oban_config()},
|
||||
Memex.Repo.Migrator
|
||||
# Start a worker by calling: Memex.Worker.start_link(arg)
|
||||
# {Memex.Worker, arg}
|
||||
]
|
||||
|
||||
# Automatically migrate on start in prod
|
||||
children =
|
||||
if Application.get_env(:memex, Memex.Application, automigrate: false)[:automigrate],
|
||||
do: children ++ [Memex.Repo.Migrator],
|
||||
else: children
|
||||
# Oban events logging https://hexdocs.pm/oban/Oban.html#module-reporting-errors
|
||||
:ok =
|
||||
:telemetry.attach_many(
|
||||
"oban-logger",
|
||||
[
|
||||
[:oban, :job, :exception],
|
||||
[:oban, :job, :start],
|
||||
[:oban, :job, :stop]
|
||||
],
|
||||
&Logger.handle_event/4,
|
||||
[]
|
||||
)
|
||||
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||
# for other strategies and supported options
|
||||
|
@ -4,21 +4,88 @@ defmodule Memex.Contexts do
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Memex.Repo
|
||||
|
||||
alias Memex.Contexts.Context
|
||||
alias Memex.{Accounts.User, Contexts.Context, Repo}
|
||||
|
||||
@doc """
|
||||
Returns the list of contexts.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_contexts()
|
||||
iex> list_contexts(%User{id: 123})
|
||||
[%Context{}, ...]
|
||||
|
||||
iex> list_contexts("my context", %User{id: 123})
|
||||
[%Context{slug: "my context"}, ...]
|
||||
|
||||
"""
|
||||
def list_contexts do
|
||||
Repo.all(Context)
|
||||
@spec list_contexts(User.t()) :: [Context.t()]
|
||||
@spec list_contexts(search :: String.t() | nil, User.t()) :: [Context.t()]
|
||||
def list_contexts(search \\ nil, user)
|
||||
|
||||
def list_contexts(search, %{id: user_id}) when search |> is_nil() or search == "" do
|
||||
Repo.all(from c in Context, where: c.user_id == ^user_id, order_by: c.slug)
|
||||
end
|
||||
|
||||
def list_contexts(search, %{id: user_id}) when search |> is_binary() do
|
||||
trimmed_search = String.trim(search)
|
||||
|
||||
Repo.all(
|
||||
from c in Context,
|
||||
where: c.user_id == ^user_id,
|
||||
where:
|
||||
fragment(
|
||||
"search @@ websearch_to_tsquery('english', ?)",
|
||||
^trimmed_search
|
||||
),
|
||||
order_by: {
|
||||
:desc,
|
||||
fragment(
|
||||
"ts_rank_cd(search, websearch_to_tsquery('english', ?), 4)",
|
||||
^trimmed_search
|
||||
)
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of public contexts for viewing.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_public_contexts()
|
||||
[%Context{}, ...]
|
||||
|
||||
iex> list_public_contexts("my context")
|
||||
[%Context{slug: "my context"}, ...]
|
||||
|
||||
"""
|
||||
@spec list_public_contexts() :: [Context.t()]
|
||||
@spec list_public_contexts(search :: String.t() | nil) :: [Context.t()]
|
||||
def list_public_contexts(search \\ nil)
|
||||
|
||||
def list_public_contexts(search) when search |> is_nil() or search == "" do
|
||||
Repo.all(from c in Context, where: c.visibility == :public, order_by: c.slug)
|
||||
end
|
||||
|
||||
def list_public_contexts(search) when search |> is_binary() do
|
||||
trimmed_search = String.trim(search)
|
||||
|
||||
Repo.all(
|
||||
from c in Context,
|
||||
where: c.visibility == :public,
|
||||
where:
|
||||
fragment(
|
||||
"search @@ websearch_to_tsquery('english', ?)",
|
||||
^trimmed_search
|
||||
),
|
||||
order_by: {
|
||||
:desc,
|
||||
fragment(
|
||||
"ts_rank_cd(search, websearch_to_tsquery('english', ?), 4)",
|
||||
^trimmed_search
|
||||
)
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
@ -28,31 +95,78 @@ defmodule Memex.Contexts do
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_context!(123)
|
||||
iex> get_context!(123, %User{id: 123})
|
||||
%Context{}
|
||||
|
||||
iex> get_context!(456)
|
||||
iex> get_context!(456, %User{id: 123})
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_context!(id), do: Repo.get!(Context, id)
|
||||
@spec get_context!(Context.id(), User.t()) :: Context.t()
|
||||
def get_context!(id, %{id: user_id}) do
|
||||
Repo.one!(
|
||||
from c in Context,
|
||||
where: c.id == ^id,
|
||||
where: c.user_id == ^user_id or c.visibility in [:public, :unlisted]
|
||||
)
|
||||
end
|
||||
|
||||
def get_context!(id, _invalid_user) do
|
||||
Repo.one!(
|
||||
from c in Context,
|
||||
where: c.id == ^id,
|
||||
where: c.visibility in [:public, :unlisted]
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single context by a slug.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Context does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_context_by_slug("my-context", %User{id: 123})
|
||||
%Context{}
|
||||
|
||||
iex> get_context_by_slug("my-context", %User{id: 123})
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
@spec get_context_by_slug(Context.slug(), User.t()) :: Context.t() | nil
|
||||
def get_context_by_slug(slug, %{id: user_id}) do
|
||||
Repo.one(
|
||||
from c in Context,
|
||||
where: c.slug == ^slug,
|
||||
where: c.user_id == ^user_id or c.visibility in [:public, :unlisted]
|
||||
)
|
||||
end
|
||||
|
||||
def get_context_by_slug(slug, _invalid_user) do
|
||||
Repo.one(
|
||||
from c in Context,
|
||||
where: c.slug == ^slug,
|
||||
where: c.visibility in [:public, :unlisted]
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a context.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_context(%{field: value})
|
||||
iex> create_context(%{field: value}, %User{id: 123})
|
||||
{:ok, %Context{}}
|
||||
|
||||
iex> create_context(%{field: bad_value})
|
||||
iex> create_context(%{field: bad_value}, %User{id: 123})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def create_context(attrs \\ %{}) do
|
||||
%Context{}
|
||||
|> Context.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
@spec create_context(User.t()) :: {:ok, Context.t()} | {:error, Context.changeset()}
|
||||
@spec create_context(attrs :: map(), User.t()) ::
|
||||
{:ok, Context.t()} | {:error, Context.changeset()}
|
||||
def create_context(attrs \\ %{}, user) do
|
||||
Context.create_changeset(attrs, user) |> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
@ -60,16 +174,18 @@ defmodule Memex.Contexts do
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_context(context, %{field: new_value})
|
||||
iex> update_context(context, %{field: new_value}, %User{id: 123})
|
||||
{:ok, %Context{}}
|
||||
|
||||
iex> update_context(context, %{field: bad_value})
|
||||
iex> update_context(context, %{field: bad_value}, %User{id: 123})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_context(%Context{} = context, attrs) do
|
||||
@spec update_context(Context.t(), attrs :: map(), User.t()) ::
|
||||
{:ok, Context.t()} | {:error, Context.changeset()}
|
||||
def update_context(%Context{} = context, attrs, user) do
|
||||
context
|
||||
|> Context.changeset(attrs)
|
||||
|> Context.update_changeset(attrs, user)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@ -78,15 +194,24 @@ defmodule Memex.Contexts do
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_context(context)
|
||||
iex> delete_context(%Context{user_id: 123}, %User{id: 123})
|
||||
{:ok, %Context{}}
|
||||
|
||||
iex> delete_context(context)
|
||||
iex> delete_context(%Context{user_id: 123}, %User{role: :admin})
|
||||
{:ok, %Context{}}
|
||||
|
||||
iex> delete_context(%Context{}, %User{id: 123})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def delete_context(%Context{} = context) do
|
||||
Repo.delete(context)
|
||||
@spec delete_context(Context.t(), User.t()) ::
|
||||
{:ok, Context.t()} | {:error, Context.changeset()}
|
||||
def delete_context(%Context{user_id: user_id} = context, %{id: user_id}) do
|
||||
context |> Repo.delete()
|
||||
end
|
||||
|
||||
def delete_context(%Context{} = context, %{role: :admin}) do
|
||||
context |> Repo.delete()
|
||||
end
|
||||
|
||||
@doc """
|
||||
@ -98,7 +223,9 @@ defmodule Memex.Contexts do
|
||||
%Ecto.Changeset{data: %Context{}}
|
||||
|
||||
"""
|
||||
def change_context(%Context{} = context, attrs \\ %{}) do
|
||||
Context.changeset(context, attrs)
|
||||
@spec change_context(Context.t(), User.t()) :: Context.changeset()
|
||||
@spec change_context(Context.t(), attrs :: map(), User.t()) :: Context.changeset()
|
||||
def change_context(%Context{} = context, attrs \\ %{}, user) do
|
||||
context |> Context.update_changeset(attrs, user)
|
||||
end
|
||||
end
|
||||
|
@ -1,22 +1,112 @@
|
||||
defmodule Memex.Contexts.Context do
|
||||
@moduledoc """
|
||||
Represents a document that synthesizes multiple concepts as defined by notes
|
||||
into a single consideration
|
||||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
import MemexWeb.Gettext
|
||||
alias Ecto.{Changeset, UUID}
|
||||
alias Memex.{Accounts.User, Repo}
|
||||
|
||||
@derive {Jason.Encoder,
|
||||
only: [
|
||||
:slug,
|
||||
:content,
|
||||
:tags,
|
||||
:visibility,
|
||||
:inserted_at,
|
||||
:updated_at
|
||||
]}
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "contexts" do
|
||||
field :slug, :string
|
||||
field :content, :string
|
||||
field :tag, {:array, :string}
|
||||
field :title, :string
|
||||
field :tags, {:array, :string}
|
||||
field :tags_string, :string, virtual: true
|
||||
field :visibility, Ecto.Enum, values: [:public, :private, :unlisted]
|
||||
|
||||
belongs_to :user, User
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
slug: slug(),
|
||||
content: String.t(),
|
||||
tags: [String.t()] | nil,
|
||||
tags_string: String.t() | nil,
|
||||
visibility: :public | :private | :unlisted,
|
||||
user: User.t() | Ecto.Association.NotLoaded.t(),
|
||||
user_id: User.id(),
|
||||
inserted_at: NaiveDateTime.t(),
|
||||
updated_at: NaiveDateTime.t()
|
||||
}
|
||||
@type id :: UUID.t()
|
||||
@type slug :: String.t()
|
||||
@type changeset :: Changeset.t(t())
|
||||
|
||||
@doc false
|
||||
def changeset(context, attrs) do
|
||||
context
|
||||
|> cast(attrs, [:title, :content, :tag, :visibility])
|
||||
|> validate_required([:title, :content, :tag, :visibility])
|
||||
@spec create_changeset(attrs :: map(), User.t()) :: changeset()
|
||||
def create_changeset(attrs, %User{id: user_id}) do
|
||||
%__MODULE__{}
|
||||
|> cast(attrs, [:slug, :content, :tags, :visibility])
|
||||
|> change(user_id: user_id)
|
||||
|> cast_tags_string(attrs)
|
||||
|> validate_format(:slug, ~r/^[\p{L}\p{N}\-]+$/,
|
||||
message: dgettext("errors", "invalid format: only numbers, letters and hyphen are accepted")
|
||||
)
|
||||
|> validate_required([:slug, :content, :user_id, :visibility])
|
||||
|> unique_constraint(:slug)
|
||||
|> unsafe_validate_unique(:slug, Repo)
|
||||
end
|
||||
|
||||
@spec update_changeset(t(), attrs :: map(), User.t()) :: changeset()
|
||||
def update_changeset(%{user_id: user_id} = note, attrs, %User{id: user_id}) do
|
||||
note
|
||||
|> cast(attrs, [:slug, :content, :tags, :visibility])
|
||||
|> cast_tags_string(attrs)
|
||||
|> validate_format(:slug, ~r/^[\p{L}\p{N}\-]+$/,
|
||||
message: dgettext("errors", "invalid format: only numbers, letters and hyphen are accepted")
|
||||
)
|
||||
|> validate_required([:slug, :content, :visibility])
|
||||
|> unique_constraint(:slug)
|
||||
|> unsafe_validate_unique(:slug, Repo)
|
||||
end
|
||||
|
||||
defp cast_tags_string(changeset, attrs) do
|
||||
changeset
|
||||
|> put_change(:tags_string, changeset |> get_field(:tags) |> get_tags_string())
|
||||
|> cast(attrs, [:tags_string])
|
||||
|> validate_format(:tags_string, ~r/^[\p{L}\p{N}\-\,]+$/,
|
||||
message:
|
||||
dgettext(
|
||||
"errors",
|
||||
"invalid format: only numbers, letters and hyphen are accepted. tags must be comma-delimited"
|
||||
)
|
||||
)
|
||||
|> cast_tags()
|
||||
end
|
||||
|
||||
defp cast_tags(%{valid?: false} = changeset), do: changeset
|
||||
|
||||
defp cast_tags(%{valid?: true} = changeset) do
|
||||
tags = changeset |> get_field(:tags_string) |> process_tags()
|
||||
changeset |> put_change(:tags, tags)
|
||||
end
|
||||
|
||||
defp process_tags(tags_string) when tags_string |> is_binary() do
|
||||
tags_string
|
||||
|> String.split(",", trim: true)
|
||||
|> Enum.map(fn str -> str |> String.trim() end)
|
||||
|> Enum.reject(fn str -> str |> is_nil() end)
|
||||
|> Enum.sort()
|
||||
end
|
||||
|
||||
defp process_tags(_other_tags_string), do: []
|
||||
|
||||
@spec get_tags_string([String.t()] | nil) :: String.t()
|
||||
def get_tags_string(nil), do: ""
|
||||
def get_tags_string(tags) when tags |> is_list(), do: tags |> Enum.join(",")
|
||||
end
|
||||
|
@ -1,20 +0,0 @@
|
||||
defmodule Memex.Contexts.ContextNote do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "context_notes" do
|
||||
field :context_id, :binary_id
|
||||
field :note_id, :binary_id
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(context_note, attrs) do
|
||||
context_note
|
||||
|> cast(attrs, [])
|
||||
|> validate_required([])
|
||||
end
|
||||
end
|
@ -1,173 +0,0 @@
|
||||
defmodule Memex.Invites do
|
||||
@moduledoc """
|
||||
The Invites context.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Ecto.Changeset
|
||||
alias Memex.{Accounts.User, Invites.Invite, Repo}
|
||||
|
||||
@invite_token_length 20
|
||||
|
||||
@doc """
|
||||
Returns the list of invites.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_invites(%User{id: 123, role: :admin})
|
||||
[%Invite{}, ...]
|
||||
|
||||
"""
|
||||
@spec list_invites(User.t()) :: [Invite.t()]
|
||||
def list_invites(%User{role: :admin}) do
|
||||
Repo.all(from i in Invite, order_by: i.name)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single invite.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Invite does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_invite!(123, %User{id: 123, role: :admin})
|
||||
%Invite{}
|
||||
|
||||
iex> get_invite!(456, %User{id: 123, role: :admin})
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
@spec get_invite!(Invite.id(), User.t()) :: Invite.t()
|
||||
def get_invite!(id, %User{role: :admin}) do
|
||||
Repo.get!(Invite, id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a valid invite or nil based on the attempted token
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_invite_by_token("valid_token")
|
||||
%Invite{}
|
||||
|
||||
iex> get_invite_by_token("invalid_token")
|
||||
nil
|
||||
"""
|
||||
@spec get_invite_by_token(token :: String.t() | nil) :: Invite.t() | nil
|
||||
def get_invite_by_token(nil), do: nil
|
||||
def get_invite_by_token(""), do: nil
|
||||
|
||||
def get_invite_by_token(token) do
|
||||
Repo.one(
|
||||
from(i in Invite,
|
||||
where: i.token == ^token and i.disabled_at |> is_nil()
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Uses invite by decrementing uses_left, or marks invite invalid if it's been
|
||||
completely used.
|
||||
"""
|
||||
@spec use_invite!(Invite.t()) :: Invite.t()
|
||||
def use_invite!(%Invite{uses_left: nil} = invite), do: invite
|
||||
|
||||
def use_invite!(%Invite{uses_left: uses_left} = invite) do
|
||||
new_uses_left = uses_left - 1
|
||||
|
||||
attrs =
|
||||
if new_uses_left <= 0 do
|
||||
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
|
||||
%{"uses_left" => 0, "disabled_at" => now}
|
||||
else
|
||||
%{"uses_left" => new_uses_left}
|
||||
end
|
||||
|
||||
invite |> Invite.update_changeset(attrs) |> Repo.update!()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a invite.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_invite(%User{id: 123, role: :admin}, %{field: value})
|
||||
{:ok, %Invite{}}
|
||||
|
||||
iex> create_invite(%User{id: 123, role: :admin}, %{field: bad_value})
|
||||
{:error, %Changeset{}}
|
||||
|
||||
"""
|
||||
@spec create_invite(User.t(), attrs :: map()) ::
|
||||
{:ok, Invite.t()} | {:error, Changeset.t(Invite.new_invite())}
|
||||
def create_invite(%User{id: user_id, role: :admin}, attrs) do
|
||||
token =
|
||||
:crypto.strong_rand_bytes(@invite_token_length)
|
||||
|> Base.url_encode64()
|
||||
|> binary_part(0, @invite_token_length)
|
||||
|
||||
attrs = attrs |> Map.merge(%{"user_id" => user_id, "token" => token})
|
||||
|
||||
%Invite{} |> Invite.create_changeset(attrs) |> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a invite.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_invite(invite, %{field: new_value}, %User{id: 123, role: :admin})
|
||||
{:ok, %Invite{}}
|
||||
|
||||
iex> update_invite(invite, %{field: bad_value}, %User{id: 123, role: :admin})
|
||||
{:error, %Changeset{}}
|
||||
|
||||
"""
|
||||
@spec update_invite(Invite.t(), attrs :: map(), User.t()) ::
|
||||
{:ok, Invite.t()} | {:error, Changeset.t(Invite.t())}
|
||||
def update_invite(invite, attrs, %User{role: :admin}),
|
||||
do: invite |> Invite.update_changeset(attrs) |> Repo.update()
|
||||
|
||||
@doc """
|
||||
Deletes a invite.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_invite(invite, %User{id: 123, role: :admin})
|
||||
{:ok, %Invite{}}
|
||||
|
||||
iex> delete_invite(invite, %User{id: 123, role: :admin})
|
||||
{:error, %Changeset{}}
|
||||
|
||||
"""
|
||||
@spec delete_invite(Invite.t(), User.t()) ::
|
||||
{:ok, Invite.t()} | {:error, Changeset.t(Invite.t())}
|
||||
def delete_invite(invite, %User{role: :admin}), do: invite |> Repo.delete()
|
||||
|
||||
@doc """
|
||||
Deletes a invite.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_invite(invite, %User{id: 123, role: :admin})
|
||||
%Invite{}
|
||||
|
||||
"""
|
||||
@spec delete_invite!(Invite.t(), User.t()) :: Invite.t()
|
||||
def delete_invite!(invite, %User{role: :admin}), do: invite |> Repo.delete!()
|
||||
|
||||
@doc """
|
||||
Returns an `%Changeset{}` for tracking invite changes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_invite(invite)
|
||||
%Changeset{data: %Invite{}}
|
||||
|
||||
"""
|
||||
@spec change_invite(Invite.t() | Invite.new_invite()) ::
|
||||
Changeset.t(Invite.t() | Invite.new_invite())
|
||||
@spec change_invite(Invite.t() | Invite.new_invite(), attrs :: map()) ::
|
||||
Changeset.t(Invite.t() | Invite.new_invite())
|
||||
def change_invite(invite, attrs \\ %{}), do: invite |> Invite.update_changeset(attrs)
|
||||
end
|
63
lib/memex/logger.ex
Normal file
63
lib/memex/logger.ex
Normal file
@ -0,0 +1,63 @@
|
||||
defmodule Memex.Logger do
|
||||
@moduledoc """
|
||||
Custom logger for telemetry events
|
||||
|
||||
Oban implementation taken from
|
||||
https://hexdocs.pm/oban/Oban.html#module-reporting-errors
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
def handle_event([:oban, :job, :exception], measure, %{stacktrace: stacktrace} = meta, _config) do
|
||||
data =
|
||||
get_oban_job_data(meta, measure)
|
||||
|> Map.put(:stacktrace, Exception.format_stacktrace(stacktrace))
|
||||
|> pretty_encode()
|
||||
|
||||
Logger.error(meta.reason, data: data)
|
||||
end
|
||||
|
||||
def handle_event([:oban, :job, :start], measure, meta, _config) do
|
||||
data = get_oban_job_data(meta, measure) |> pretty_encode()
|
||||
Logger.info("Started oban job", data: data)
|
||||
end
|
||||
|
||||
def handle_event([:oban, :job, :stop], measure, meta, _config) do
|
||||
data = get_oban_job_data(meta, measure) |> pretty_encode()
|
||||
Logger.info("Finished oban job", data: data)
|
||||
end
|
||||
|
||||
def handle_event([:oban, :job, unhandled_event], measure, meta, _config) do
|
||||
data =
|
||||
get_oban_job_data(meta, measure)
|
||||
|> Map.put(:event, unhandled_event)
|
||||
|> pretty_encode()
|
||||
|
||||
Logger.warning("Unhandled oban job event", data: data)
|
||||
end
|
||||
|
||||
def handle_event(unhandled_event, measure, meta, config) do
|
||||
data =
|
||||
pretty_encode(%{
|
||||
event: unhandled_event,
|
||||
meta: meta,
|
||||
measurements: measure,
|
||||
config: config
|
||||
})
|
||||
|
||||
Logger.warning("Unhandled telemetry event", data: data)
|
||||
end
|
||||
|
||||
defp get_oban_job_data(%{job: job}, measure) do
|
||||
%{
|
||||
job: job |> Map.take([:id, :args, :meta, :queue, :worker]),
|
||||
measurements: measure
|
||||
}
|
||||
end
|
||||
|
||||
defp pretty_encode(data) do
|
||||
data
|
||||
|> Jason.encode!()
|
||||
|> Jason.Formatter.pretty_print()
|
||||
end
|
||||
end
|
@ -4,21 +4,87 @@ defmodule Memex.Notes do
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Memex.Repo
|
||||
|
||||
alias Memex.Notes.Note
|
||||
alias Memex.{Accounts.User, Notes.Note, Repo}
|
||||
|
||||
@doc """
|
||||
Returns the list of notes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_notes()
|
||||
iex> list_notes(%User{id: 123})
|
||||
[%Note{}, ...]
|
||||
|
||||
iex> list_notes("my note", %User{id: 123})
|
||||
[%Note{slug: "my note"}, ...]
|
||||
|
||||
"""
|
||||
def list_notes do
|
||||
Repo.all(Note)
|
||||
@spec list_notes(User.t()) :: [Note.t()]
|
||||
@spec list_notes(search :: String.t() | nil, User.t()) :: [Note.t()]
|
||||
def list_notes(search \\ nil, user)
|
||||
|
||||
def list_notes(search, %{id: user_id}) when search |> is_nil() or search == "" do
|
||||
Repo.all(from n in Note, where: n.user_id == ^user_id, order_by: n.slug)
|
||||
end
|
||||
|
||||
def list_notes(search, %{id: user_id}) when search |> is_binary() do
|
||||
trimmed_search = String.trim(search)
|
||||
|
||||
Repo.all(
|
||||
from n in Note,
|
||||
where: n.user_id == ^user_id,
|
||||
where:
|
||||
fragment(
|
||||
"search @@ websearch_to_tsquery('english', ?)",
|
||||
^trimmed_search
|
||||
),
|
||||
order_by: {
|
||||
:desc,
|
||||
fragment(
|
||||
"ts_rank_cd(search, websearch_to_tsquery('english', ?), 4)",
|
||||
^trimmed_search
|
||||
)
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of public notes for viewing
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_public_notes()
|
||||
[%Note{}, ...]
|
||||
|
||||
iex> list_public_notes("my note")
|
||||
[%Note{slug: "my note"}, ...]
|
||||
"""
|
||||
@spec list_public_notes() :: [Note.t()]
|
||||
@spec list_public_notes(search :: String.t() | nil) :: [Note.t()]
|
||||
def list_public_notes(search \\ nil)
|
||||
|
||||
def list_public_notes(search) when search |> is_nil() or search == "" do
|
||||
Repo.all(from n in Note, where: n.visibility == :public, order_by: n.slug)
|
||||
end
|
||||
|
||||
def list_public_notes(search) when search |> is_binary() do
|
||||
trimmed_search = String.trim(search)
|
||||
|
||||
Repo.all(
|
||||
from n in Note,
|
||||
where: n.visibility == :public,
|
||||
where:
|
||||
fragment(
|
||||
"search @@ websearch_to_tsquery('english', ?)",
|
||||
^trimmed_search
|
||||
),
|
||||
order_by: {
|
||||
:desc,
|
||||
fragment(
|
||||
"ts_rank_cd(search, websearch_to_tsquery('english', ?), 4)",
|
||||
^trimmed_search
|
||||
)
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
@ -28,31 +94,77 @@ defmodule Memex.Notes do
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_note!(123)
|
||||
iex> get_note!(123, %User{id: 123})
|
||||
%Note{}
|
||||
|
||||
iex> get_note!(456)
|
||||
iex> get_note!(456, %User{id: 123})
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_note!(id), do: Repo.get!(Note, id)
|
||||
@spec get_note!(Note.id(), User.t()) :: Note.t()
|
||||
def get_note!(id, %{id: user_id}) do
|
||||
Repo.one!(
|
||||
from n in Note,
|
||||
where: n.id == ^id,
|
||||
where: n.user_id == ^user_id or n.visibility in [:public, :unlisted]
|
||||
)
|
||||
end
|
||||
|
||||
def get_note!(id, _invalid_user) do
|
||||
Repo.one!(
|
||||
from n in Note,
|
||||
where: n.id == ^id,
|
||||
where: n.visibility in [:public, :unlisted]
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single note by slug.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Note does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_note_by_slug("my-note", %User{id: 123})
|
||||
%Note{}
|
||||
|
||||
iex> get_note_by_slug("my-note", %User{id: 123})
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
@spec get_note_by_slug(Note.slug(), User.t()) :: Note.t() | nil
|
||||
def get_note_by_slug(slug, %{id: user_id}) do
|
||||
Repo.one(
|
||||
from n in Note,
|
||||
where: n.slug == ^slug,
|
||||
where: n.user_id == ^user_id or n.visibility in [:public, :unlisted]
|
||||
)
|
||||
end
|
||||
|
||||
def get_note_by_slug(slug, _invalid_user) do
|
||||
Repo.one(
|
||||
from n in Note,
|
||||
where: n.slug == ^slug,
|
||||
where: n.visibility in [:public, :unlisted]
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a note.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_note(%{field: value})
|
||||
iex> create_note(%{field: value}, %User{id: 123})
|
||||
{:ok, %Note{}}
|
||||
|
||||
iex> create_note(%{field: bad_value})
|
||||
iex> create_note(%{field: bad_value}, %User{id: 123})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def create_note(attrs \\ %{}) do
|
||||
%Note{}
|
||||
|> Note.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
@spec create_note(User.t()) :: {:ok, Note.t()} | {:error, Note.changeset()}
|
||||
@spec create_note(attrs :: map(), User.t()) :: {:ok, Note.t()} | {:error, Note.changeset()}
|
||||
def create_note(attrs \\ %{}, user) do
|
||||
Note.create_changeset(attrs, user) |> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
@ -60,16 +172,18 @@ defmodule Memex.Notes do
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_note(note, %{field: new_value})
|
||||
iex> update_note(note, %{field: new_value}, %User{id: 123})
|
||||
{:ok, %Note{}}
|
||||
|
||||
iex> update_note(note, %{field: bad_value})
|
||||
iex> update_note(note, %{field: bad_value}, %User{id: 123})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_note(%Note{} = note, attrs) do
|
||||
@spec update_note(Note.t(), attrs :: map(), User.t()) ::
|
||||
{:ok, Note.t()} | {:error, Note.changeset()}
|
||||
def update_note(%Note{} = note, attrs, user) do
|
||||
note
|
||||
|> Note.changeset(attrs)
|
||||
|> Note.update_changeset(attrs, user)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@ -78,15 +192,23 @@ defmodule Memex.Notes do
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_note(note)
|
||||
iex> delete_note(%Note{user_id: 123}, %User{id: 123})
|
||||
{:ok, %Note{}}
|
||||
|
||||
iex> delete_note(note)
|
||||
iex> delete_note(%Note{}, %User{role: :admin})
|
||||
{:ok, %Note{}}
|
||||
|
||||
iex> delete_note(%Note{}, %User{id: 123})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def delete_note(%Note{} = note) do
|
||||
Repo.delete(note)
|
||||
@spec delete_note(Note.t(), User.t()) :: {:ok, Note.t()} | {:error, Note.changeset()}
|
||||
def delete_note(%Note{user_id: user_id} = note, %{id: user_id}) do
|
||||
note |> Repo.delete()
|
||||
end
|
||||
|
||||
def delete_note(%Note{} = note, %{role: :admin}) do
|
||||
note |> Repo.delete()
|
||||
end
|
||||
|
||||
@doc """
|
||||
@ -94,11 +216,16 @@ defmodule Memex.Notes do
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_note(note)
|
||||
iex> change_note(note, %User{id: 123})
|
||||
%Ecto.Changeset{data: %Note{}}
|
||||
|
||||
iex> change_note(note, %{slug: "new slug"}, %User{id: 123})
|
||||
%Ecto.Changeset{data: %Note{}}
|
||||
|
||||
"""
|
||||
def change_note(%Note{} = note, attrs \\ %{}) do
|
||||
Note.changeset(note, attrs)
|
||||
@spec change_note(Note.t(), User.t()) :: Note.changeset()
|
||||
@spec change_note(Note.t(), attrs :: map(), User.t()) :: Note.changeset()
|
||||
def change_note(%Note{} = note, attrs \\ %{}, user) do
|
||||
note |> Note.update_changeset(attrs, user)
|
||||
end
|
||||
end
|
||||
|
@ -1,22 +1,111 @@
|
||||
defmodule Memex.Notes.Note do
|
||||
@moduledoc """
|
||||
Schema for a user-written note
|
||||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
import MemexWeb.Gettext
|
||||
alias Ecto.{Changeset, UUID}
|
||||
alias Memex.{Accounts.User, Repo}
|
||||
|
||||
@derive {Jason.Encoder,
|
||||
only: [
|
||||
:slug,
|
||||
:content,
|
||||
:tags,
|
||||
:visibility,
|
||||
:inserted_at,
|
||||
:updated_at
|
||||
]}
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "notes" do
|
||||
field :slug, :string
|
||||
field :content, :string
|
||||
field :tag, {:array, :string}
|
||||
field :title, :string
|
||||
field :tags, {:array, :string}
|
||||
field :tags_string, :string, virtual: true
|
||||
field :visibility, Ecto.Enum, values: [:public, :private, :unlisted]
|
||||
|
||||
belongs_to :user, User
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
slug: slug(),
|
||||
content: String.t(),
|
||||
tags: [String.t()] | nil,
|
||||
tags_string: String.t() | nil,
|
||||
visibility: :public | :private | :unlisted,
|
||||
user: User.t() | Ecto.Association.NotLoaded.t(),
|
||||
user_id: User.id(),
|
||||
inserted_at: NaiveDateTime.t(),
|
||||
updated_at: NaiveDateTime.t()
|
||||
}
|
||||
@type id :: UUID.t()
|
||||
@type slug :: String.t()
|
||||
@type changeset :: Changeset.t(t())
|
||||
|
||||
@doc false
|
||||
def changeset(note, attrs) do
|
||||
note
|
||||
|> cast(attrs, [:title, :content, :tag, :visibility])
|
||||
|> validate_required([:title, :content, :tag, :visibility])
|
||||
@spec create_changeset(attrs :: map(), User.t()) :: changeset()
|
||||
def create_changeset(attrs, %User{id: user_id}) do
|
||||
%__MODULE__{}
|
||||
|> cast(attrs, [:slug, :content, :tags, :visibility])
|
||||
|> change(user_id: user_id)
|
||||
|> cast_tags_string(attrs)
|
||||
|> validate_format(:slug, ~r/^[\p{L}\p{N}\-]+$/,
|
||||
message: dgettext("errors", "invalid format: only numbers, letters and hyphen are accepted")
|
||||
)
|
||||
|> validate_required([:slug, :content, :user_id, :visibility])
|
||||
|> unique_constraint(:slug)
|
||||
|> unsafe_validate_unique(:slug, Repo)
|
||||
end
|
||||
|
||||
@spec update_changeset(t(), attrs :: map(), User.t()) :: changeset()
|
||||
def update_changeset(%{user_id: user_id} = note, attrs, %User{id: user_id}) do
|
||||
note
|
||||
|> cast(attrs, [:slug, :content, :tags, :visibility])
|
||||
|> cast_tags_string(attrs)
|
||||
|> validate_format(:slug, ~r/^[\p{L}\p{N}\-]+$/,
|
||||
message: dgettext("errors", "invalid format: only numbers, letters and hyphen are accepted")
|
||||
)
|
||||
|> validate_required([:slug, :content, :visibility])
|
||||
|> unique_constraint(:slug)
|
||||
|> unsafe_validate_unique(:slug, Repo)
|
||||
end
|
||||
|
||||
defp cast_tags_string(changeset, attrs) do
|
||||
changeset
|
||||
|> put_change(:tags_string, changeset |> get_field(:tags) |> get_tags_string())
|
||||
|> cast(attrs, [:tags_string])
|
||||
|> validate_format(:tags_string, ~r/^[\p{L}\p{N}\-\,]+$/,
|
||||
message:
|
||||
dgettext(
|
||||
"errors",
|
||||
"invalid format: only numbers, letters and hyphen are accepted. tags must be comma-delimited"
|
||||
)
|
||||
)
|
||||
|> cast_tags()
|
||||
end
|
||||
|
||||
defp cast_tags(%{valid?: false} = changeset), do: changeset
|
||||
|
||||
defp cast_tags(%{valid?: true} = changeset) do
|
||||
tags = changeset |> get_field(:tags_string) |> process_tags()
|
||||
changeset |> put_change(:tags, tags)
|
||||
end
|
||||
|
||||
defp process_tags(tags_string) when tags_string |> is_binary() do
|
||||
tags_string
|
||||
|> String.split(",", trim: true)
|
||||
|> Enum.map(fn str -> str |> String.trim() end)
|
||||
|> Enum.reject(fn str -> str |> is_nil() end)
|
||||
|> Enum.sort()
|
||||
end
|
||||
|
||||
defp process_tags(_other_tags_string), do: []
|
||||
|
||||
@spec get_tags_string([String.t()] | nil) :: String.t()
|
||||
def get_tags_string(nil), do: ""
|
||||
def get_tags_string(tags) when tags |> is_list(), do: tags |> Enum.join(",")
|
||||
end
|
||||
|
@ -4,21 +4,87 @@ defmodule Memex.Pipelines do
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Memex.Repo
|
||||
|
||||
alias Memex.Pipelines.Pipeline
|
||||
alias Memex.{Accounts.User, Pipelines.Pipeline, Repo}
|
||||
|
||||
@doc """
|
||||
Returns the list of pipelines.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_pipelines()
|
||||
iex> list_pipelines(%User{id: 123})
|
||||
[%Pipeline{}, ...]
|
||||
|
||||
iex> list_pipelines("my pipeline", %User{id: 123})
|
||||
[%Pipeline{slug: "my pipeline"}, ...]
|
||||
|
||||
"""
|
||||
def list_pipelines do
|
||||
Repo.all(Pipeline)
|
||||
@spec list_pipelines(User.t()) :: [Pipeline.t()]
|
||||
@spec list_pipelines(search :: String.t() | nil, User.t()) :: [Pipeline.t()]
|
||||
def list_pipelines(search \\ nil, user)
|
||||
|
||||
def list_pipelines(search, %{id: user_id}) when search |> is_nil() or search == "" do
|
||||
Repo.all(from p in Pipeline, where: p.user_id == ^user_id, order_by: p.slug)
|
||||
end
|
||||
|
||||
def list_pipelines(search, %{id: user_id}) when search |> is_binary() do
|
||||
trimmed_search = String.trim(search)
|
||||
|
||||
Repo.all(
|
||||
from p in Pipeline,
|
||||
where: p.user_id == ^user_id,
|
||||
where:
|
||||
fragment(
|
||||
"search @@ websearch_to_tsquery('english', ?)",
|
||||
^trimmed_search
|
||||
),
|
||||
order_by: {
|
||||
:desc,
|
||||
fragment(
|
||||
"ts_rank_cd(search, websearch_to_tsquery('english', ?), 4)",
|
||||
^trimmed_search
|
||||
)
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of public pipelines for viewing
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_public_pipelines()
|
||||
[%Pipeline{}, ...]
|
||||
|
||||
iex> list_public_pipelines("my pipeline")
|
||||
[%Pipeline{slug: "my pipeline"}, ...]
|
||||
"""
|
||||
@spec list_public_pipelines() :: [Pipeline.t()]
|
||||
@spec list_public_pipelines(search :: String.t() | nil) :: [Pipeline.t()]
|
||||
def list_public_pipelines(search \\ nil)
|
||||
|
||||
def list_public_pipelines(search) when search |> is_nil() or search == "" do
|
||||
Repo.all(from p in Pipeline, where: p.visibility == :public, order_by: p.slug)
|
||||
end
|
||||
|
||||
def list_public_pipelines(search) when search |> is_binary() do
|
||||
trimmed_search = String.trim(search)
|
||||
|
||||
Repo.all(
|
||||
from p in Pipeline,
|
||||
where: p.visibility == :public,
|
||||
where:
|
||||
fragment(
|
||||
"search @@ websearch_to_tsquery('english', ?)",
|
||||
^trimmed_search
|
||||
),
|
||||
order_by: {
|
||||
:desc,
|
||||
fragment(
|
||||
"ts_rank_cd(search, websearch_to_tsquery('english', ?), 4)",
|
||||
^trimmed_search
|
||||
)
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
@ -28,31 +94,78 @@ defmodule Memex.Pipelines do
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_pipeline!(123)
|
||||
iex> get_pipeline!(123, %User{id: 123})
|
||||
%Pipeline{}
|
||||
|
||||
iex> get_pipeline!(456)
|
||||
iex> get_pipeline!(456, %User{id: 123})
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_pipeline!(id), do: Repo.get!(Pipeline, id)
|
||||
@spec get_pipeline!(Pipeline.id(), User.t()) :: Pipeline.t()
|
||||
def get_pipeline!(id, %{id: user_id}) do
|
||||
Repo.one!(
|
||||
from p in Pipeline,
|
||||
where: p.id == ^id,
|
||||
where: p.user_id == ^user_id or p.visibility in [:public, :unlisted]
|
||||
)
|
||||
end
|
||||
|
||||
def get_pipeline!(id, _invalid_user) do
|
||||
Repo.one!(
|
||||
from p in Pipeline,
|
||||
where: p.id == ^id,
|
||||
where: p.visibility in [:public, :unlisted]
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single pipeline by it's slug.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Pipeline does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_pipeline_by_slug("my-pipeline", %User{id: 123})
|
||||
%Pipeline{}
|
||||
|
||||
iex> get_pipeline_by_slug("my-pipeline", %User{id: 123})
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
@spec get_pipeline_by_slug(Pipeline.slug(), User.t()) :: Pipeline.t() | nil
|
||||
def get_pipeline_by_slug(slug, %{id: user_id}) do
|
||||
Repo.one(
|
||||
from p in Pipeline,
|
||||
where: p.slug == ^slug,
|
||||
where: p.user_id == ^user_id or p.visibility in [:public, :unlisted]
|
||||
)
|
||||
end
|
||||
|
||||
def get_pipeline_by_slug(slug, _invalid_user) do
|
||||
Repo.one(
|
||||
from p in Pipeline,
|
||||
where: p.slug == ^slug,
|
||||
where: p.visibility in [:public, :unlisted]
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a pipeline.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_pipeline(%{field: value})
|
||||
iex> create_pipeline(%{field: value}, %User{id: 123})
|
||||
{:ok, %Pipeline{}}
|
||||
|
||||
iex> create_pipeline(%{field: bad_value})
|
||||
iex> create_pipeline(%{field: bad_value}, %User{id: 123})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def create_pipeline(attrs \\ %{}) do
|
||||
%Pipeline{}
|
||||
|> Pipeline.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
@spec create_pipeline(User.t()) :: {:ok, Pipeline.t()} | {:error, Pipeline.changeset()}
|
||||
@spec create_pipeline(attrs :: map(), User.t()) ::
|
||||
{:ok, Pipeline.t()} | {:error, Pipeline.changeset()}
|
||||
def create_pipeline(attrs \\ %{}, user) do
|
||||
Pipeline.create_changeset(attrs, user) |> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
@ -60,16 +173,18 @@ defmodule Memex.Pipelines do
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_pipeline(pipeline, %{field: new_value})
|
||||
iex> update_pipeline(pipeline, %{field: new_value}, %User{id: 123})
|
||||
{:ok, %Pipeline{}}
|
||||
|
||||
iex> update_pipeline(pipeline, %{field: bad_value})
|
||||
iex> update_pipeline(pipeline, %{field: bad_value}, %User{id: 123})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_pipeline(%Pipeline{} = pipeline, attrs) do
|
||||
@spec update_pipeline(Pipeline.t(), attrs :: map(), User.t()) ::
|
||||
{:ok, Pipeline.t()} | {:error, Pipeline.changeset()}
|
||||
def update_pipeline(%Pipeline{} = pipeline, attrs, user) do
|
||||
pipeline
|
||||
|> Pipeline.changeset(attrs)
|
||||
|> Pipeline.update_changeset(attrs, user)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@ -78,15 +193,24 @@ defmodule Memex.Pipelines do
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_pipeline(pipeline)
|
||||
iex> delete_pipeline(%Pipeline{user_id: 123}, %User{id: 123})
|
||||
{:ok, %Pipeline{}}
|
||||
|
||||
iex> delete_pipeline(pipeline)
|
||||
iex> delete_pipeline(%Pipeline{}, %User{role: :admin})
|
||||
{:ok, %Pipeline{}}
|
||||
|
||||
iex> delete_pipeline(%Pipeline{}, %User{id: 123})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def delete_pipeline(%Pipeline{} = pipeline) do
|
||||
Repo.delete(pipeline)
|
||||
@spec delete_pipeline(Pipeline.t(), User.t()) ::
|
||||
{:ok, Pipeline.t()} | {:error, Pipeline.changeset()}
|
||||
def delete_pipeline(%Pipeline{user_id: user_id} = pipeline, %{id: user_id}) do
|
||||
pipeline |> Repo.delete()
|
||||
end
|
||||
|
||||
def delete_pipeline(%Pipeline{} = pipeline, %{role: :admin}) do
|
||||
pipeline |> Repo.delete()
|
||||
end
|
||||
|
||||
@doc """
|
||||
@ -94,11 +218,16 @@ defmodule Memex.Pipelines do
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_pipeline(pipeline)
|
||||
iex> change_pipeline(pipeline, %User{id: 123})
|
||||
%Ecto.Changeset{data: %Pipeline{}}
|
||||
|
||||
iex> change_pipeline(pipeline, %{slug: "new slug"}, %User{id: 123})
|
||||
%Ecto.Changeset{data: %Pipeline{}}
|
||||
|
||||
"""
|
||||
def change_pipeline(%Pipeline{} = pipeline, attrs \\ %{}) do
|
||||
Pipeline.changeset(pipeline, attrs)
|
||||
@spec change_pipeline(Pipeline.t(), User.t()) :: Pipeline.changeset()
|
||||
@spec change_pipeline(Pipeline.t(), attrs :: map(), User.t()) :: Pipeline.changeset()
|
||||
def change_pipeline(%Pipeline{} = pipeline, attrs \\ %{}, user) do
|
||||
pipeline |> Pipeline.update_changeset(attrs, user)
|
||||
end
|
||||
end
|
||||
|
@ -1,21 +1,114 @@
|
||||
defmodule Memex.Pipelines.Pipeline do
|
||||
@moduledoc """
|
||||
Represents a chain of considerations to take to accomplish a task
|
||||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
import MemexWeb.Gettext
|
||||
alias Ecto.{Changeset, UUID}
|
||||
alias Memex.{Accounts.User, Pipelines.Steps.Step, Repo}
|
||||
|
||||
@derive {Jason.Encoder,
|
||||
only: [
|
||||
:slug,
|
||||
:description,
|
||||
:tags,
|
||||
:visibility,
|
||||
:inserted_at,
|
||||
:steps,
|
||||
:updated_at
|
||||
]}
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "pipelines" do
|
||||
field :slug, :string
|
||||
field :description, :string
|
||||
field :title, :string
|
||||
field :tags, {:array, :string}
|
||||
field :tags_string, :string, virtual: true
|
||||
field :visibility, Ecto.Enum, values: [:public, :private, :unlisted]
|
||||
|
||||
belongs_to :user, User
|
||||
|
||||
has_many :steps, Step, preload_order: [asc: :position]
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
slug: slug(),
|
||||
description: String.t(),
|
||||
tags: [String.t()] | nil,
|
||||
tags_string: String.t() | nil,
|
||||
visibility: :public | :private | :unlisted,
|
||||
user: User.t() | Ecto.Association.NotLoaded.t(),
|
||||
user_id: User.id(),
|
||||
inserted_at: NaiveDateTime.t(),
|
||||
updated_at: NaiveDateTime.t()
|
||||
}
|
||||
@type id :: UUID.t()
|
||||
@type slug :: String.t()
|
||||
@type changeset :: Changeset.t(t())
|
||||
|
||||
@doc false
|
||||
def changeset(pipeline, attrs) do
|
||||
pipeline
|
||||
|> cast(attrs, [:title, :description, :visibility])
|
||||
|> validate_required([:title, :description, :visibility])
|
||||
@spec create_changeset(attrs :: map(), User.t()) :: changeset()
|
||||
def create_changeset(attrs, %User{id: user_id}) do
|
||||
%__MODULE__{}
|
||||
|> cast(attrs, [:slug, :description, :tags, :visibility])
|
||||
|> change(user_id: user_id)
|
||||
|> cast_tags_string(attrs)
|
||||
|> validate_format(:slug, ~r/^[\p{L}\p{N}\-]+$/,
|
||||
message: dgettext("errors", "invalid format: only numbers, letters and hyphen are accepted")
|
||||
)
|
||||
|> validate_required([:slug, :user_id, :visibility])
|
||||
|> unique_constraint(:slug)
|
||||
|> unsafe_validate_unique(:slug, Repo)
|
||||
end
|
||||
|
||||
@spec update_changeset(t(), attrs :: map(), User.t()) :: changeset()
|
||||
def update_changeset(%{user_id: user_id} = pipeline, attrs, %User{id: user_id}) do
|
||||
pipeline
|
||||
|> cast(attrs, [:slug, :description, :tags, :visibility])
|
||||
|> cast_tags_string(attrs)
|
||||
|> validate_format(:slug, ~r/^[\p{L}\p{N}\-]+$/,
|
||||
message: dgettext("errors", "invalid format: only numbers, letters and hyphen are accepted")
|
||||
)
|
||||
|> validate_required([:slug, :visibility])
|
||||
|> unique_constraint(:slug)
|
||||
|> unsafe_validate_unique(:slug, Repo)
|
||||
end
|
||||
|
||||
defp cast_tags_string(changeset, attrs) do
|
||||
changeset
|
||||
|> put_change(:tags_string, changeset |> get_field(:tags) |> get_tags_string())
|
||||
|> cast(attrs, [:tags_string])
|
||||
|> validate_format(:tags_string, ~r/^[\p{L}\p{N}\-\,]+$/,
|
||||
message:
|
||||
dgettext(
|
||||
"errors",
|
||||
"invalid format: only numbers, letters and hyphen are accepted. tags must be comma-delimited"
|
||||
)
|
||||
)
|
||||
|> cast_tags()
|
||||
end
|
||||
|
||||
defp cast_tags(%{valid?: false} = changeset), do: changeset
|
||||
|
||||
defp cast_tags(%{valid?: true} = changeset) do
|
||||
tags = changeset |> get_field(:tags_string) |> process_tags()
|
||||
changeset |> put_change(:tags, tags)
|
||||
end
|
||||
|
||||
defp process_tags(tags_string) when tags_string |> is_binary() do
|
||||
tags_string
|
||||
|> String.split(",", trim: true)
|
||||
|> Enum.map(fn str -> str |> String.trim() end)
|
||||
|> Enum.reject(fn str -> str |> is_nil() end)
|
||||
|> Enum.sort()
|
||||
end
|
||||
|
||||
defp process_tags(_other_tags_string), do: []
|
||||
|
||||
@spec get_tags_string([String.t()] | nil) :: String.t()
|
||||
def get_tags_string(nil), do: ""
|
||||
def get_tags_string(tags) when tags |> is_list(), do: tags |> Enum.join(",")
|
||||
end
|
||||
|
79
lib/memex/pipelines/step.ex
Normal file
79
lib/memex/pipelines/step.ex
Normal file
@ -0,0 +1,79 @@
|
||||
defmodule Memex.Pipelines.Steps.Step do
|
||||
@moduledoc """
|
||||
Represents a step taken while executing a pipeline
|
||||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
alias Ecto.{Changeset, UUID}
|
||||
alias Memex.{Accounts.User, Pipelines.Pipeline}
|
||||
|
||||
@derive {Jason.Encoder,
|
||||
only: [
|
||||
:title,
|
||||
:content,
|
||||
:position,
|
||||
:inserted_at,
|
||||
:updated_at
|
||||
]}
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "steps" do
|
||||
field :title, :string
|
||||
field :content, :string
|
||||
field :position, :integer
|
||||
|
||||
belongs_to :pipeline, Pipeline
|
||||
belongs_to :user, User
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
title: String.t(),
|
||||
content: String.t(),
|
||||
position: non_neg_integer(),
|
||||
pipeline: Pipeline.t() | Ecto.Association.NotLoaded.t(),
|
||||
pipeline_id: Pipeline.id(),
|
||||
user: User.t() | Ecto.Association.NotLoaded.t(),
|
||||
user_id: User.id(),
|
||||
inserted_at: NaiveDateTime.t(),
|
||||
updated_at: NaiveDateTime.t()
|
||||
}
|
||||
@type id :: UUID.t()
|
||||
@type changeset :: Changeset.t(t())
|
||||
|
||||
@doc false
|
||||
@spec create_changeset(attrs :: map(), position :: non_neg_integer(), Pipeline.t(), User.t()) ::
|
||||
changeset()
|
||||
def create_changeset(attrs, position, %Pipeline{id: pipeline_id, user_id: user_id}, %User{
|
||||
id: user_id
|
||||
}) do
|
||||
%__MODULE__{}
|
||||
|> cast(attrs, [:title, :content])
|
||||
|> change(pipeline_id: pipeline_id, user_id: user_id, position: position)
|
||||
|> validate_required([:title, :content, :user_id, :position])
|
||||
end
|
||||
|
||||
@spec update_changeset(t(), attrs :: map(), User.t()) ::
|
||||
changeset()
|
||||
def update_changeset(
|
||||
%{user_id: user_id} = step,
|
||||
attrs,
|
||||
%User{id: user_id}
|
||||
) do
|
||||
step
|
||||
|> cast(attrs, [:title, :content])
|
||||
|> validate_required([:title, :content, :user_id, :position])
|
||||
end
|
||||
|
||||
@spec position_changeset(t(), position :: non_neg_integer(), User.t()) :: changeset()
|
||||
def position_changeset(
|
||||
%{user_id: user_id} = step,
|
||||
position,
|
||||
%User{id: user_id}
|
||||
) do
|
||||
step
|
||||
|> change(position: position)
|
||||
|> validate_required([:title, :content, :user_id, :position])
|
||||
end
|
||||
end
|
238
lib/memex/pipelines/steps.ex
Normal file
238
lib/memex/pipelines/steps.ex
Normal file
@ -0,0 +1,238 @@
|
||||
defmodule Memex.Pipelines.Steps do
|
||||
@moduledoc """
|
||||
The context for steps within a pipeline
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Ecto.Multi
|
||||
alias Memex.{Accounts.User, Repo}
|
||||
alias Memex.Pipelines.{Pipeline, Steps.Step}
|
||||
|
||||
@doc """
|
||||
Returns the list of steps.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_steps(%User{id: 123})
|
||||
[%Step{}, ...]
|
||||
|
||||
iex> list_steps("my step", %User{id: 123})
|
||||
[%Step{title: "my step"}, ...]
|
||||
|
||||
"""
|
||||
@spec list_steps(Pipeline.t(), User.t()) :: [Step.t()]
|
||||
def list_steps(%{id: pipeline_id}, %{id: user_id}) do
|
||||
Repo.all(
|
||||
from s in Step,
|
||||
where: s.pipeline_id == ^pipeline_id,
|
||||
where: s.user_id == ^user_id,
|
||||
order_by: s.position
|
||||
)
|
||||
end
|
||||
|
||||
def list_steps(%{id: pipeline_id, visibility: visibility}, _invalid_user)
|
||||
when visibility in [:unlisted, :public] do
|
||||
Repo.all(
|
||||
from s in Step,
|
||||
where: s.pipeline_id == ^pipeline_id,
|
||||
order_by: s.position
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Preloads the `:steps` field on a Memex.Pipelines.Pipeline
|
||||
"""
|
||||
@spec preload_steps(Pipeline.t(), User.t()) :: Pipeline.t()
|
||||
def preload_steps(pipeline, user) do
|
||||
%{pipeline | steps: list_steps(pipeline, user)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single step.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Step does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_step!(123, %User{id: 123})
|
||||
%Step{}
|
||||
|
||||
iex> get_step!(456, %User{id: 123})
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
@spec get_step!(Step.id(), User.t()) :: Step.t()
|
||||
def get_step!(id, %{id: user_id}) do
|
||||
Repo.one!(from n in Step, where: n.id == ^id, where: n.user_id == ^user_id)
|
||||
end
|
||||
|
||||
def get_step!(id, _invalid_user) do
|
||||
Repo.one!(
|
||||
from n in Step,
|
||||
where: n.id == ^id,
|
||||
where: n.visibility in [:public, :unlisted]
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a step.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_step(%{field: value}, %User{id: 123})
|
||||
{:ok, %Step{}}
|
||||
|
||||
iex> create_step(%{field: bad_value}, %User{id: 123})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
@spec create_step(position :: non_neg_integer(), Pipeline.t(), User.t()) ::
|
||||
{:ok, Step.t()} | {:error, Step.changeset()}
|
||||
@spec create_step(attrs :: map(), position :: non_neg_integer(), Pipeline.t(), User.t()) ::
|
||||
{:ok, Step.t()} | {:error, Step.changeset()}
|
||||
def create_step(attrs \\ %{}, position, pipeline, user) do
|
||||
Step.create_changeset(attrs, position, pipeline, user) |> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a step.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_step(step, %{field: new_value}, %User{id: 123})
|
||||
{:ok, %Step{}}
|
||||
|
||||
iex> update_step(step, %{field: bad_value}, %User{id: 123})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
@spec update_step(Step.t(), attrs :: map(), User.t()) ::
|
||||
{:ok, Step.t()} | {:error, Step.changeset()}
|
||||
def update_step(%Step{} = step, attrs, user) do
|
||||
step
|
||||
|> Step.update_changeset(attrs, user)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a step.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_step(%Step{user_id: 123}, %User{id: 123})
|
||||
{:ok, %Step{}}
|
||||
|
||||
iex> delete_step(%Step{}, %User{role: :admin})
|
||||
{:ok, %Step{}}
|
||||
|
||||
iex> delete_step(%Step{}, %User{id: 123})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
@spec delete_step(Step.t(), User.t()) :: {:ok, Step.t()} | {:error, Step.changeset()}
|
||||
def delete_step(%Step{user_id: user_id} = step, %{id: user_id}) do
|
||||
delete_step(step)
|
||||
end
|
||||
|
||||
def delete_step(%Step{} = step, %{role: :admin}) do
|
||||
delete_step(step)
|
||||
end
|
||||
|
||||
defp delete_step(step) do
|
||||
Multi.new()
|
||||
|> Multi.delete(:delete_step, step)
|
||||
|> Multi.update_all(
|
||||
:reorder_steps,
|
||||
fn %{delete_step: %{position: position, pipeline_id: pipeline_id}} ->
|
||||
from s in Step,
|
||||
where: s.pipeline_id == ^pipeline_id,
|
||||
where: s.position > ^position,
|
||||
update: [set: [position: s.position - 1]]
|
||||
end,
|
||||
[]
|
||||
)
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{delete_step: step}} -> {:ok, step}
|
||||
{:error, :delete_step, changeset, _changes_so_far} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking step changes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_step(step, %User{id: 123})
|
||||
%Ecto.Changeset{data: %Step{}}
|
||||
|
||||
iex> change_step(step, %{title: "new title"}, %User{id: 123})
|
||||
%Ecto.Changeset{data: %Step{}}
|
||||
|
||||
"""
|
||||
@spec change_step(Step.t(), User.t()) :: Step.changeset()
|
||||
@spec change_step(Step.t(), attrs :: map(), User.t()) :: Step.changeset()
|
||||
def change_step(%Step{} = step, attrs \\ %{}, user) do
|
||||
step |> Step.update_changeset(attrs, user)
|
||||
end
|
||||
|
||||
@spec reorder_step(Step.t(), :up | :down, User.t()) ::
|
||||
{:ok, Step.t()} | {:error, Step.changeset()}
|
||||
def reorder_step(%Step{position: 0} = step, :up, _user), do: {:error, step}
|
||||
|
||||
def reorder_step(
|
||||
%Step{position: position, pipeline_id: pipeline_id, user_id: user_id} = step,
|
||||
:up,
|
||||
%{id: user_id} = user
|
||||
) do
|
||||
Multi.new()
|
||||
|> Multi.update_all(
|
||||
:reorder_steps,
|
||||
from(s in Step,
|
||||
where: s.pipeline_id == ^pipeline_id,
|
||||
where: s.position == ^position - 1,
|
||||
update: [set: [position: ^position]]
|
||||
),
|
||||
[]
|
||||
)
|
||||
|> Multi.update(
|
||||
:update_step,
|
||||
step |> Step.position_changeset(position - 1, user)
|
||||
)
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{update_step: step}} -> {:ok, step}
|
||||
{:error, :update_step, changeset, _changes_so_far} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
def reorder_step(
|
||||
%Step{pipeline_id: pipeline_id, position: position, user_id: user_id} = step,
|
||||
:down,
|
||||
%{id: user_id} = user
|
||||
) do
|
||||
Multi.new()
|
||||
|> Multi.one(
|
||||
:step_count,
|
||||
from(s in Step, where: s.pipeline_id == ^pipeline_id, distinct: true, select: count(s.id))
|
||||
)
|
||||
|> Multi.update_all(
|
||||
:reorder_steps,
|
||||
from(s in Step,
|
||||
where: s.pipeline_id == ^pipeline_id,
|
||||
where: s.position == ^position + 1,
|
||||
update: [set: [position: ^position]]
|
||||
),
|
||||
[]
|
||||
)
|
||||
|> Multi.update(:update_step, fn %{step_count: step_count} ->
|
||||
new_position = if position >= step_count - 1, do: position, else: position + 1
|
||||
step |> Step.position_changeset(new_position, user)
|
||||
end)
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{update_step: step}} -> {:ok, step}
|
||||
{:error, :update_step, changeset, _changes_so_far} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
end
|
@ -7,7 +7,9 @@ defmodule Memex.Release do
|
||||
|
||||
def rollback(repo, version) do
|
||||
load_app()
|
||||
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
|
||||
|
||||
{:ok, _fun_return, _apps} =
|
||||
Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
|
||||
end
|
||||
|
||||
defp load_app do
|
||||
@ -18,7 +20,8 @@ defmodule Memex.Release do
|
||||
load_app()
|
||||
|
||||
for repo <- Application.fetch_env!(@app, :ecto_repos) do
|
||||
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
|
||||
{:ok, _fun_return, _apps} =
|
||||
Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -6,17 +6,20 @@ defmodule Memex.Repo.Migrator do
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
def start_link(_) do
|
||||
def start_link(_opts) do
|
||||
GenServer.start_link(__MODULE__, [], [])
|
||||
end
|
||||
|
||||
def init(_) do
|
||||
migrate!()
|
||||
{:ok, nil}
|
||||
def init(_opts) do
|
||||
{:ok, if(automigrate_enabled?(), do: migrate!())}
|
||||
end
|
||||
|
||||
def migrate! do
|
||||
path = Application.app_dir(:memex, "priv/repo/migrations")
|
||||
Ecto.Migrator.run(Memex.Repo, path, :up, all: true)
|
||||
end
|
||||
|
||||
defp automigrate_enabled? do
|
||||
Application.get_env(:memex, Memex.Application, automigrate: false)[:automigrate]
|
||||
end
|
||||
end
|
||||
|
@ -1,104 +0,0 @@
|
||||
defmodule Memex.Steps do
|
||||
@moduledoc """
|
||||
The Steps context.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Memex.Repo
|
||||
|
||||
alias Memex.Steps.Step
|
||||
|
||||
@doc """
|
||||
Returns the list of steps.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_steps()
|
||||
[%Step{}, ...]
|
||||
|
||||
"""
|
||||
def list_steps do
|
||||
Repo.all(Step)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single step.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Step does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_step!(123)
|
||||
%Step{}
|
||||
|
||||
iex> get_step!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_step!(id), do: Repo.get!(Step, id)
|
||||
|
||||
@doc """
|
||||
Creates a step.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_step(%{field: value})
|
||||
{:ok, %Step{}}
|
||||
|
||||
iex> create_step(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def create_step(attrs \\ %{}) do
|
||||
%Step{}
|
||||
|> Step.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a step.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_step(step, %{field: new_value})
|
||||
{:ok, %Step{}}
|
||||
|
||||
iex> update_step(step, %{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_step(%Step{} = step, attrs) do
|
||||
step
|
||||
|> Step.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a step.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_step(step)
|
||||
{:ok, %Step{}}
|
||||
|
||||
iex> delete_step(step)
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def delete_step(%Step{} = step) do
|
||||
Repo.delete(step)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking step changes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_step(step)
|
||||
%Ecto.Changeset{data: %Step{}}
|
||||
|
||||
"""
|
||||
def change_step(%Step{} = step, attrs \\ %{}) do
|
||||
Step.changeset(step, attrs)
|
||||
end
|
||||
end
|
@ -1,22 +0,0 @@
|
||||
defmodule Memex.Steps.Step do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "steps" do
|
||||
field :description, :string
|
||||
field :position, :integer
|
||||
field :title, :string
|
||||
field :pipeline_id, :binary_id
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(step, attrs) do
|
||||
step
|
||||
|> cast(attrs, [:title, :description, :position])
|
||||
|> validate_required([:title, :description, :position])
|
||||
end
|
||||
end
|
@ -1,20 +0,0 @@
|
||||
defmodule Memex.Steps.StepContext do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "step_contexts" do
|
||||
field :step_id, :binary_id
|
||||
field :context_id, :binary_id
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(step_context, attrs) do
|
||||
step_context
|
||||
|> cast(attrs, [])
|
||||
|> validate_required([])
|
||||
end
|
||||
end
|
@ -23,6 +23,7 @@ defmodule MemexWeb do
|
||||
|
||||
import Plug.Conn
|
||||
import MemexWeb.Gettext
|
||||
alias MemexWeb.Endpoint
|
||||
alias MemexWeb.Router.Helpers, as: Routes
|
||||
end
|
||||
end
|
||||
@ -45,7 +46,7 @@ defmodule MemexWeb do
|
||||
def live_view do
|
||||
quote do
|
||||
use Phoenix.LiveView,
|
||||
layout: {MemexWeb.LayoutView, :live}
|
||||
layout: {MemexWeb.LayoutView, "live.html"}
|
||||
|
||||
on_mount MemexWeb.InitAssigns
|
||||
unquote(view_helpers())
|
||||
@ -72,15 +73,16 @@ defmodule MemexWeb do
|
||||
quote do
|
||||
use Phoenix.Router
|
||||
|
||||
import Phoenix.{Controller, LiveView.Router}
|
||||
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
|
||||
import Plug.Conn
|
||||
import Phoenix.Controller
|
||||
import Phoenix.LiveView.Router
|
||||
end
|
||||
end
|
||||
|
||||
def channel do
|
||||
quote do
|
||||
use Phoenix.Channel
|
||||
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
|
||||
import MemexWeb.Gettext
|
||||
end
|
||||
end
|
||||
@ -88,16 +90,16 @@ defmodule MemexWeb do
|
||||
defp view_helpers do
|
||||
quote do
|
||||
# Use all HTML functionality (forms, tags, etc)
|
||||
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
|
||||
use Phoenix.HTML
|
||||
|
||||
# Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc)
|
||||
import Phoenix.Component
|
||||
import MemexWeb.LiveHelpers
|
||||
|
||||
# Import LiveView and .heex helpers (live_render, link, <.form>, etc)
|
||||
# Import basic rendering functionality (render, render_layout, etc)
|
||||
import Phoenix.View
|
||||
|
||||
import Phoenix.{Component, View}
|
||||
import MemexWeb.{ErrorHelpers, Gettext, LiveHelpers, ViewHelpers}
|
||||
|
||||
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
|
||||
alias MemexWeb.Endpoint
|
||||
alias MemexWeb.Router.Helpers, as: Routes
|
||||
end
|
||||
end
|
||||
|
44
lib/memex_web/components/context_content.ex
Normal file
44
lib/memex_web/components/context_content.ex
Normal file
@ -0,0 +1,44 @@
|
||||
defmodule MemexWeb.Components.ContextContent do
|
||||
@moduledoc """
|
||||
Display the content for a context
|
||||
"""
|
||||
use MemexWeb, :component
|
||||
alias Memex.Contexts.Context
|
||||
alias Phoenix.HTML
|
||||
|
||||
attr :context, Context, required: true
|
||||
|
||||
def context_content(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id={"show-context-content-#{@context.id}"}
|
||||
class="input input-primary h-128 min-h-128 inline-block whitespace-pre-wrap overflow-x-hidden overflow-y-auto"
|
||||
phx-hook="MaintainAttrs"
|
||||
phx-update="ignore"
|
||||
readonly
|
||||
phx-no-format
|
||||
><p class="inline"><%= add_links_to_content(@context.content) %></p></div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp add_links_to_content(content) do
|
||||
Regex.replace(
|
||||
~r/\[\[([\p{L}\p{N}\-]+)\]\]/,
|
||||
content,
|
||||
fn _whole_match, slug ->
|
||||
link =
|
||||
HTML.Link.link(
|
||||
"[[#{slug}]]",
|
||||
to: Routes.note_show_path(Endpoint, :show, slug),
|
||||
class: "link inline",
|
||||
data: [qa: "context-note-#{slug}"]
|
||||
)
|
||||
|> HTML.Safe.to_iodata()
|
||||
|> IO.iodata_to_binary()
|
||||
|
||||
"</p>#{link}<p class=\"inline\">"
|
||||
end
|
||||
)
|
||||
|> HTML.raw()
|
||||
end
|
||||
end
|
134
lib/memex_web/components/contexts_table_component.ex
Normal file
134
lib/memex_web/components/contexts_table_component.ex
Normal file
@ -0,0 +1,134 @@
|
||||
defmodule MemexWeb.Components.ContextsTableComponent do
|
||||
@moduledoc """
|
||||
A component that displays a list of contexts
|
||||
"""
|
||||
use MemexWeb, :live_component
|
||||
alias Ecto.UUID
|
||||
alias Memex.{Accounts.User, Contexts.Context}
|
||||
alias Phoenix.LiveView.{Rendered, Socket}
|
||||
|
||||
@impl true
|
||||
@spec update(
|
||||
%{
|
||||
required(:id) => UUID.t(),
|
||||
required(:current_user) => User.t(),
|
||||
required(:contexts) => [Context.t()],
|
||||
optional(any()) => any()
|
||||
},
|
||||
Socket.t()
|
||||
) :: {:ok, Socket.t()}
|
||||
def update(%{id: _id, contexts: _contexts, current_user: _current_user} = assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:actions, fn -> [] end)
|
||||
|> display_contexts()
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
defp display_contexts(
|
||||
%{
|
||||
assigns: %{
|
||||
contexts: contexts,
|
||||
current_user: current_user,
|
||||
actions: actions
|
||||
}
|
||||
} = socket
|
||||
) do
|
||||
columns =
|
||||
if actions == [] or current_user |> is_nil() do
|
||||
[]
|
||||
else
|
||||
[%{label: nil, key: :actions, sortable: false}]
|
||||
end
|
||||
|
||||
columns = [
|
||||
%{label: gettext("slug"), key: :slug},
|
||||
%{label: gettext("tags"), key: :tags},
|
||||
%{label: gettext("visibility"), key: :visibility}
|
||||
| columns
|
||||
]
|
||||
|
||||
rows =
|
||||
contexts
|
||||
|> Enum.map(fn context ->
|
||||
context
|
||||
|> get_row_data_for_context(%{
|
||||
columns: columns,
|
||||
current_user: current_user,
|
||||
actions: actions
|
||||
})
|
||||
end)
|
||||
|
||||
socket |> assign(columns: columns, rows: rows)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="w-full">
|
||||
<.live_component
|
||||
module={MemexWeb.Components.TableComponent}
|
||||
id={@id}
|
||||
columns={@columns}
|
||||
rows={@rows}
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@spec get_row_data_for_context(Context.t(), additional_data :: map()) :: map()
|
||||
defp get_row_data_for_context(context, %{columns: columns} = additional_data) do
|
||||
columns
|
||||
|> Map.new(fn %{key: key} ->
|
||||
{key, get_value_for_key(key, context, additional_data)}
|
||||
end)
|
||||
end
|
||||
|
||||
@spec get_value_for_key(atom(), Context.t(), additional_data :: map()) ::
|
||||
any() | {any(), Rendered.t()}
|
||||
defp get_value_for_key(:slug, %{slug: slug}, _additional_data) do
|
||||
assigns = %{slug: slug}
|
||||
|
||||
slug_block = ~H"""
|
||||
<.link
|
||||
navigate={Routes.context_show_path(Endpoint, :show, @slug)}
|
||||
class="link"
|
||||
data-qa={"context-show-#{@slug}"}
|
||||
>
|
||||
<%= @slug %>
|
||||
</.link>
|
||||
"""
|
||||
|
||||
{slug, slug_block}
|
||||
end
|
||||
|
||||
defp get_value_for_key(:tags, %{tags: tags}, _additional_data) do
|
||||
assigns = %{tags: tags}
|
||||
|
||||
~H"""
|
||||
<div class="flex flex-wrap justify-center space-x-1">
|
||||
<.link
|
||||
:for={tag <- @tags}
|
||||
patch={Routes.context_index_path(Endpoint, :search, tag)}
|
||||
class="link"
|
||||
>
|
||||
<%= tag %>
|
||||
</.link>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp get_value_for_key(:actions, context, %{actions: actions}) do
|
||||
assigns = %{actions: actions, context: context}
|
||||
|
||||
~H"""
|
||||
<div class="flex justify-center items-center space-x-4">
|
||||
<%= render_slot(@actions, @context) %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp get_value_for_key(key, context, _additional_data), do: context |> Map.get(key)
|
||||
end
|
@ -4,11 +4,23 @@ defmodule MemexWeb.Components.InviteCard do
|
||||
"""
|
||||
|
||||
use MemexWeb, :component
|
||||
alias Memex.Accounts.{Invite, Invites, User}
|
||||
alias MemexWeb.Endpoint
|
||||
|
||||
def invite_card(assigns) do
|
||||
attr :invite, Invite, required: true
|
||||
attr :current_user, User, required: true
|
||||
slot(:inner_block)
|
||||
slot(:code_actions)
|
||||
|
||||
def invite_card(%{invite: invite, current_user: current_user} = assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:use_count, Invites.get_use_count(invite, current_user))
|
||||
|> assign_new(:code_actions, fn -> [] end)
|
||||
|
||||
~H"""
|
||||
<div class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center space-y-4
|
||||
<div class="px-8 py-4 flex flex-col justify-center items-center space-y-4
|
||||
bg-primary-900
|
||||
border border-gray-400 rounded-lg shadow-lg hover:shadow-md
|
||||
transition-all duration-300 ease-in-out">
|
||||
<h1 class="title text-xl">
|
||||
@ -17,8 +29,14 @@ defmodule MemexWeb.Components.InviteCard do
|
||||
|
||||
<%= if @invite.disabled_at |> is_nil() do %>
|
||||
<h2 class="title text-md">
|
||||
<%= gettext("Uses Left:") %>
|
||||
<%= @invite.uses_left || gettext("unlimited") %>
|
||||
<%= if @invite.uses_left do %>
|
||||
<%= gettext(
|
||||
"uses left: %{uses_left_count}",
|
||||
uses_left_count: @invite.uses_left
|
||||
) %>
|
||||
<% else %>
|
||||
<%= gettext("uses left: unlimited") %>
|
||||
<% end %>
|
||||
</h2>
|
||||
<% else %>
|
||||
<h2 class="title text-md">
|
||||
@ -26,24 +44,28 @@ defmodule MemexWeb.Components.InviteCard do
|
||||
</h2>
|
||||
<% end %>
|
||||
|
||||
<.qr_code
|
||||
content={Routes.user_registration_url(Endpoint, :new, invite: @invite.token)}
|
||||
filename={@invite.name}
|
||||
/>
|
||||
|
||||
<h2 :if={@use_count != 0} class="title text-md">
|
||||
<%= gettext("uses: %{uses_count}", uses_count: @use_count) %>
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-row flex-wrap justify-center items-center">
|
||||
<code
|
||||
id={"code-#{@invite.id}"}
|
||||
class="mx-2 my-1 text-xs px-4 py-2 rounded-lg text-center break-all text-gray-100 bg-primary-800"
|
||||
>
|
||||
<%= Routes.user_registration_url(Endpoint, :new, invite: @invite.token) %>
|
||||
</code>
|
||||
|
||||
<%= if @code_actions do %>
|
||||
<%= render_slot(@code_actions) %>
|
||||
<% end %>
|
||||
class="mx-2 my-1 text-xs px-4 py-2 rounded-lg text-center break-all
|
||||
text-primary-400 bg-primary-800"
|
||||
phx-no-format
|
||||
><%= Routes.user_registration_url(Endpoint, :new, invite: @invite.token) %></code>
|
||||
<%= render_slot(@code_actions) %>
|
||||
</div>
|
||||
|
||||
<%= if @inner_block do %>
|
||||
<div class="flex space-x-4 justify-center items-center">
|
||||
<%= render_slot(@inner_block) %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div :if={@inner_block} class="flex space-x-4 justify-center items-center">
|
||||
<%= render_slot(@inner_block) %>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
44
lib/memex_web/components/note_content.ex
Normal file
44
lib/memex_web/components/note_content.ex
Normal file
@ -0,0 +1,44 @@
|
||||
defmodule MemexWeb.Components.NoteContent do
|
||||
@moduledoc """
|
||||
Display the content for a note
|
||||
"""
|
||||
use MemexWeb, :component
|
||||
alias Memex.Notes.Note
|
||||
alias Phoenix.HTML
|
||||
|
||||
attr :note, Note, required: true
|
||||
|
||||
def note_content(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id={"show-note-content-#{@note.id}"}
|
||||
class="input input-primary h-128 min-h-128 inline-block whitespace-pre-wrap overflow-x-hidden overflow-y-auto"
|
||||
phx-hook="MaintainAttrs"
|
||||
phx-update="ignore"
|
||||
readonly
|
||||
phx-no-format
|
||||
><p class="inline"><%= add_links_to_content(@note.content) %></p></div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp add_links_to_content(content) do
|
||||
Regex.replace(
|
||||
~r/\[\[([\p{L}\p{N}\-]+)\]\]/,
|
||||
content,
|
||||
fn _whole_match, slug ->
|
||||
link =
|
||||
HTML.Link.link(
|
||||
"[[#{slug}]]",
|
||||
to: Routes.note_show_path(Endpoint, :show, slug),
|
||||
class: "link inline",
|
||||
data: [qa: "note-link-#{slug}"]
|
||||
)
|
||||
|> HTML.Safe.to_iodata()
|
||||
|> IO.iodata_to_binary()
|
||||
|
||||
"</p>#{link}<p class=\"inline\">"
|
||||
end
|
||||
)
|
||||
|> HTML.raw()
|
||||
end
|
||||
end
|
130
lib/memex_web/components/notes_table_component.ex
Normal file
130
lib/memex_web/components/notes_table_component.ex
Normal file
@ -0,0 +1,130 @@
|
||||
defmodule MemexWeb.Components.NotesTableComponent do
|
||||
@moduledoc """
|
||||
A component that displays a list of notes
|
||||
"""
|
||||
use MemexWeb, :live_component
|
||||
alias Ecto.UUID
|
||||
alias Memex.{Accounts.User, Notes.Note}
|
||||
alias Phoenix.LiveView.{Rendered, Socket}
|
||||
|
||||
@impl true
|
||||
@spec update(
|
||||
%{
|
||||
required(:id) => UUID.t(),
|
||||
required(:current_user) => User.t(),
|
||||
required(:notes) => [Note.t()],
|
||||
optional(any()) => any()
|
||||
},
|
||||
Socket.t()
|
||||
) :: {:ok, Socket.t()}
|
||||
def update(%{id: _id, notes: _notes, current_user: _current_user} = assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:actions, fn -> [] end)
|
||||
|> display_notes()
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
defp display_notes(
|
||||
%{
|
||||
assigns: %{
|
||||
notes: notes,
|
||||
current_user: current_user,
|
||||
actions: actions
|
||||
}
|
||||
} = socket
|
||||
) do
|
||||
columns =
|
||||
if actions == [] or current_user |> is_nil() do
|
||||
[]
|
||||
else
|
||||
[%{label: nil, key: :actions, sortable: false}]
|
||||
end
|
||||
|
||||
columns = [
|
||||
%{label: gettext("slug"), key: :slug},
|
||||
%{label: gettext("tags"), key: :tags},
|
||||
%{label: gettext("visibility"), key: :visibility}
|
||||
| columns
|
||||
]
|
||||
|
||||
rows =
|
||||
notes
|
||||
|> Enum.map(fn note ->
|
||||
note
|
||||
|> get_row_data_for_note(%{
|
||||
columns: columns,
|
||||
current_user: current_user,
|
||||
actions: actions
|
||||
})
|
||||
end)
|
||||
|
||||
socket |> assign(columns: columns, rows: rows)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="w-full">
|
||||
<.live_component
|
||||
module={MemexWeb.Components.TableComponent}
|
||||
id={@id}
|
||||
columns={@columns}
|
||||
rows={@rows}
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@spec get_row_data_for_note(Note.t(), additional_data :: map()) :: map()
|
||||
defp get_row_data_for_note(note, %{columns: columns} = additional_data) do
|
||||
columns
|
||||
|> Map.new(fn %{key: key} ->
|
||||
{key, get_value_for_key(key, note, additional_data)}
|
||||
end)
|
||||
end
|
||||
|
||||
@spec get_value_for_key(atom(), Note.t(), additional_data :: map()) ::
|
||||
any() | {any(), Rendered.t()}
|
||||
defp get_value_for_key(:slug, %{slug: slug}, _additional_data) do
|
||||
assigns = %{slug: slug}
|
||||
|
||||
slug_block = ~H"""
|
||||
<.link
|
||||
navigate={Routes.note_show_path(Endpoint, :show, @slug)}
|
||||
class="link"
|
||||
data-qa={"note-show-#{@slug}"}
|
||||
>
|
||||
<%= @slug %>
|
||||
</.link>
|
||||
"""
|
||||
|
||||
{slug, slug_block}
|
||||
end
|
||||
|
||||
defp get_value_for_key(:tags, %{tags: tags}, _additional_data) do
|
||||
assigns = %{tags: tags}
|
||||
|
||||
~H"""
|
||||
<div class="flex flex-wrap justify-center space-x-1">
|
||||
<.link :for={tag <- @tags} patch={Routes.note_index_path(Endpoint, :search, tag)} class="link">
|
||||
<%= tag %>
|
||||
</.link>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp get_value_for_key(:actions, note, %{actions: actions}) do
|
||||
assigns = %{actions: actions, note: note}
|
||||
|
||||
~H"""
|
||||
<div class="flex justify-center items-center space-x-4">
|
||||
<%= render_slot(@actions, @note) %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp get_value_for_key(key, note, _additional_data), do: note |> Map.get(key)
|
||||
end
|
147
lib/memex_web/components/pipelines_table_component.ex
Normal file
147
lib/memex_web/components/pipelines_table_component.ex
Normal file
@ -0,0 +1,147 @@
|
||||
defmodule MemexWeb.Components.PipelinesTableComponent do
|
||||
@moduledoc """
|
||||
A component that displays a list of pipelines
|
||||
"""
|
||||
use MemexWeb, :live_component
|
||||
alias Ecto.UUID
|
||||
alias Memex.{Accounts.User, Pipelines.Pipeline}
|
||||
alias Phoenix.LiveView.{Rendered, Socket}
|
||||
|
||||
@impl true
|
||||
@spec update(
|
||||
%{
|
||||
required(:id) => UUID.t(),
|
||||
required(:current_user) => User.t(),
|
||||
required(:pipelines) => [Pipeline.t()],
|
||||
optional(any()) => any()
|
||||
},
|
||||
Socket.t()
|
||||
) :: {:ok, Socket.t()}
|
||||
def update(%{id: _id, pipelines: _pipelines, current_user: _current_user} = assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:actions, fn -> [] end)
|
||||
|> display_pipelines()
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
defp display_pipelines(
|
||||
%{
|
||||
assigns: %{
|
||||
pipelines: pipelines,
|
||||
current_user: current_user,
|
||||
actions: actions
|
||||
}
|
||||
} = socket
|
||||
) do
|
||||
columns =
|
||||
if actions == [] or current_user |> is_nil() do
|
||||
[]
|
||||
else
|
||||
[%{label: nil, key: :actions, sortable: false}]
|
||||
end
|
||||
|
||||
columns = [
|
||||
%{label: gettext("slug"), key: :slug},
|
||||
%{label: gettext("description"), key: :description},
|
||||
%{label: gettext("tags"), key: :tags},
|
||||
%{label: gettext("visibility"), key: :visibility}
|
||||
| columns
|
||||
]
|
||||
|
||||
rows =
|
||||
pipelines
|
||||
|> Enum.map(fn pipeline ->
|
||||
pipeline
|
||||
|> get_row_data_for_pipeline(%{
|
||||
columns: columns,
|
||||
current_user: current_user,
|
||||
actions: actions
|
||||
})
|
||||
end)
|
||||
|
||||
socket |> assign(columns: columns, rows: rows)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="w-full">
|
||||
<.live_component
|
||||
module={MemexWeb.Components.TableComponent}
|
||||
id={@id}
|
||||
columns={@columns}
|
||||
rows={@rows}
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@spec get_row_data_for_pipeline(Pipeline.t(), additional_data :: map()) :: map()
|
||||
defp get_row_data_for_pipeline(pipeline, %{columns: columns} = additional_data) do
|
||||
columns
|
||||
|> Map.new(fn %{key: key} ->
|
||||
{key, get_value_for_key(key, pipeline, additional_data)}
|
||||
end)
|
||||
end
|
||||
|
||||
@spec get_value_for_key(atom(), Pipeline.t(), additional_data :: map()) ::
|
||||
any() | {any(), Rendered.t()}
|
||||
defp get_value_for_key(:slug, %{slug: slug}, _additional_data) do
|
||||
assigns = %{slug: slug}
|
||||
|
||||
slug_block = ~H"""
|
||||
<.link
|
||||
navigate={Routes.pipeline_show_path(Endpoint, :show, @slug)}
|
||||
class="link"
|
||||
data-qa={"pipeline-show-#{@slug}"}
|
||||
>
|
||||
<%= @slug %>
|
||||
</.link>
|
||||
"""
|
||||
|
||||
{slug, slug_block}
|
||||
end
|
||||
|
||||
defp get_value_for_key(:description, %{description: description}, _additional_data) do
|
||||
assigns = %{description: description}
|
||||
|
||||
description_block = ~H"""
|
||||
<div class="truncate max-w-sm">
|
||||
<%= @description %>
|
||||
</div>
|
||||
"""
|
||||
|
||||
{description, description_block}
|
||||
end
|
||||
|
||||
defp get_value_for_key(:tags, %{tags: tags}, _additional_data) do
|
||||
assigns = %{tags: tags}
|
||||
|
||||
~H"""
|
||||
<div class="flex flex-wrap justify-center space-x-1">
|
||||
<.link
|
||||
:for={tag <- @tags}
|
||||
patch={Routes.pipeline_index_path(Endpoint, :search, tag)}
|
||||
class="link"
|
||||
>
|
||||
<%= tag %>
|
||||
</.link>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp get_value_for_key(:actions, pipeline, %{actions: actions}) do
|
||||
assigns = %{actions: actions, pipeline: pipeline}
|
||||
|
||||
~H"""
|
||||
<div class="flex justify-center items-center space-x-4">
|
||||
<%= render_slot(@actions, @pipeline) %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp get_value_for_key(key, pipeline, _additional_data), do: pipeline |> Map.get(key)
|
||||
end
|
44
lib/memex_web/components/step_content.ex
Normal file
44
lib/memex_web/components/step_content.ex
Normal file
@ -0,0 +1,44 @@
|
||||
defmodule MemexWeb.Components.StepContent do
|
||||
@moduledoc """
|
||||
Display the content for a step
|
||||
"""
|
||||
use MemexWeb, :component
|
||||
alias Memex.Pipelines.Steps.Step
|
||||
alias Phoenix.HTML
|
||||
|
||||
attr :step, Step, required: true
|
||||
|
||||
def step_content(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id={"show-step-content-#{@step.id}"}
|
||||
class="input input-primary h-32 min-h-32 inline-block whitespace-pre-wrap overflow-x-hidden overflow-y-auto"
|
||||
phx-hook="MaintainAttrs"
|
||||
phx-update="ignore"
|
||||
readonly
|
||||
phx-no-format
|
||||
><p class="inline"><%= add_links_to_content(@step.content) %></p></div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp add_links_to_content(content) do
|
||||
Regex.replace(
|
||||
~r/\[\[([\p{L}\p{N}\-]+)\]\]/,
|
||||
content,
|
||||
fn _whole_match, slug ->
|
||||
link =
|
||||
HTML.Link.link(
|
||||
"[[#{slug}]]",
|
||||
to: Routes.context_show_path(Endpoint, :show, slug),
|
||||
class: "link inline",
|
||||
data: [qa: "step-context-#{slug}"]
|
||||
)
|
||||
|> HTML.Safe.to_iodata()
|
||||
|> IO.iodata_to_binary()
|
||||
|
||||
"</p>#{link}<p class=\"inline\">"
|
||||
end
|
||||
)
|
||||
|> HTML.raw()
|
||||
end
|
||||
end
|
@ -4,14 +4,15 @@
|
||||
<tr>
|
||||
<%= for %{key: key, label: label} = column <- @columns do %>
|
||||
<%= if column |> Map.get(:sortable, true) do %>
|
||||
<th class={"p-2 #{column[:class]}"}>
|
||||
<th class={["p-2", column[:class]]}>
|
||||
<span
|
||||
class="cursor-pointer flex justify-center items-center space-x-2"
|
||||
phx-click="sort_by"
|
||||
phx-value-sort-key={key}
|
||||
phx-target={@myself}
|
||||
>
|
||||
<span><%= label %></span>
|
||||
<i class="w-0 float-right fas fa-sm fa-chevron-up opacity-0"></i>
|
||||
<span class={if @last_sort_key == key, do: "underline"}><%= label %></span>
|
||||
<%= if @last_sort_key == key do %>
|
||||
<%= case @sort_mode do %>
|
||||
<% :asc -> %>
|
||||
@ -25,7 +26,7 @@
|
||||
</span>
|
||||
</th>
|
||||
<% else %>
|
||||
<th class={"p-2 #{column[:class]}"}>
|
||||
<th class={["p-2 cursor-not-allowed", column[:class]]}>
|
||||
<%= label %>
|
||||
</th>
|
||||
<% end %>
|
||||
@ -33,20 +34,19 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= for {values, i} <- @rows |> Enum.with_index() do %>
|
||||
<tr class={if i |> Integer.is_even(), do: @row_class, else: @alternate_row_class}>
|
||||
<%= for %{key: key} = value <- @columns do %>
|
||||
<td class={"p-2 #{value[:class]}"}>
|
||||
<%= case values |> Map.get(key) do %>
|
||||
<% {_custom_sort_value, value} -> %>
|
||||
<%= value %>
|
||||
<% value -> %>
|
||||
<%= value %>
|
||||
<% end %>
|
||||
</td>
|
||||
<tr
|
||||
:for={{values, i} <- @rows |> Enum.with_index()}
|
||||
class={if i |> Integer.is_even(), do: @row_class, else: @alternate_row_class}
|
||||
>
|
||||
<td :for={%{key: key} = value <- @columns} class={["p-2", value[:class]]}>
|
||||
<%= case values |> Map.get(key) do %>
|
||||
<% {_custom_sort_value, value} -> %>
|
||||
<%= value %>
|
||||
<% value -> %>
|
||||
<%= value %>
|
||||
<% end %>
|
||||
</tr>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
@ -6,7 +6,7 @@ defmodule MemexWeb.Components.Topbar do
|
||||
use MemexWeb, :component
|
||||
|
||||
alias Memex.Accounts
|
||||
alias MemexWeb.{Endpoint, HomeLive}
|
||||
alias MemexWeb.HomeLive
|
||||
|
||||
def topbar(assigns) do
|
||||
assigns =
|
||||
@ -20,7 +20,7 @@ defmodule MemexWeb.Components.Topbar do
|
||||
navigate={Routes.live_path(Endpoint, HomeLive)}
|
||||
class="mx-2 my-1 leading-5 text-xl text-primary-400 hover:underline"
|
||||
>
|
||||
<%= gettext("memex") %>
|
||||
<%= gettext("memEx") %>
|
||||
</.link>
|
||||
|
||||
<%= if @title_content do %>
|
||||
@ -35,47 +35,45 @@ defmodule MemexWeb.Components.Topbar do
|
||||
|
||||
<ul class="flex flex-row flex-wrap justify-center items-center
|
||||
text-lg text-primary-400 text-ellipsis">
|
||||
<li class="mx-2 my-1">
|
||||
<.link
|
||||
navigate={Routes.note_index_path(Endpoint, :index)}
|
||||
class="text-primary-400 text-primary-400 hover:underline truncate"
|
||||
>
|
||||
<%= gettext("notes") %>
|
||||
</.link>
|
||||
</li>
|
||||
|
||||
<li class="mx-2 my-1">
|
||||
<.link
|
||||
navigate={Routes.context_index_path(Endpoint, :index)}
|
||||
class="text-primary-400 text-primary-400 hover:underline truncate"
|
||||
>
|
||||
<%= gettext("contexts") %>
|
||||
</.link>
|
||||
</li>
|
||||
|
||||
<li class="mx-2 my-1">
|
||||
<.link
|
||||
navigate={Routes.pipeline_index_path(Endpoint, :index)}
|
||||
class="text-primary-400 text-primary-400 hover:underline truncate"
|
||||
>
|
||||
<%= gettext("pipelines") %>
|
||||
</.link>
|
||||
</li>
|
||||
|
||||
<li class="mx-2 my-1 border-left border border-primary-700"></li>
|
||||
|
||||
<%= if @current_user do %>
|
||||
<li class="mx-2 my-1">
|
||||
<li :if={@current_user |> Accounts.is_already_admin?()} class="mx-2 my-1">
|
||||
<.link
|
||||
navigate={Routes.note_index_path(Endpoint, :index)}
|
||||
class="text-primary-400 text-primary-400 hover:underline truncate"
|
||||
navigate={Routes.invite_index_path(Endpoint, :index)}
|
||||
class="text-primary-400 text-primary-400 hover:underline"
|
||||
>
|
||||
<%= gettext("notes") %>
|
||||
<%= gettext("invites") %>
|
||||
</.link>
|
||||
</li>
|
||||
|
||||
<li class="mx-2 my-1">
|
||||
<.link
|
||||
navigate={Routes.context_index_path(Endpoint, :index)}
|
||||
class="text-primary-400 text-primary-400 hover:underline truncate"
|
||||
>
|
||||
<%= gettext("contexts") %>
|
||||
</.link>
|
||||
</li>
|
||||
|
||||
<li class="mx-2 my-1">
|
||||
<.link
|
||||
navigate={Routes.pipeline_index_path(Endpoint, :index)}
|
||||
class="text-primary-400 text-primary-400 hover:underline truncate"
|
||||
>
|
||||
<%= gettext("pipelines") %>
|
||||
</.link>
|
||||
</li>
|
||||
|
||||
<li class="mx-2 my-1 border-left border border-primary-700"></li>
|
||||
|
||||
<%= if @current_user.role == :admin do %>
|
||||
<li class="mx-2 my-1">
|
||||
<.link
|
||||
navigate={Routes.invite_index_path(Endpoint, :index)}
|
||||
class="text-primary-400 text-primary-400 hover:underline"
|
||||
>
|
||||
<%= gettext("invites") %>
|
||||
</.link>
|
||||
</li>
|
||||
<% end %>
|
||||
|
||||
<li class="mx-2 my-1">
|
||||
<.link
|
||||
navigate={Routes.user_settings_path(Endpoint, :edit)}
|
||||
@ -84,7 +82,6 @@ defmodule MemexWeb.Components.Topbar do
|
||||
<%= @current_user.email %>
|
||||
</.link>
|
||||
</li>
|
||||
|
||||
<li class="mx-2 my-1">
|
||||
<.link
|
||||
href={Routes.user_session_path(Endpoint, :delete)}
|
||||
@ -94,32 +91,32 @@ defmodule MemexWeb.Components.Topbar do
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
</.link>
|
||||
</li>
|
||||
|
||||
<%= if @current_user.role == :admin and function_exported?(Routes, :live_dashboard_path, 2) do %>
|
||||
<li class="mx-2 my-1">
|
||||
<.link
|
||||
navigate={Routes.live_dashboard_path(Endpoint, :home)}
|
||||
class="text-primary-400 text-primary-400 hover:underline"
|
||||
>
|
||||
<i class="fas fa-gauge"></i>
|
||||
</.link>
|
||||
</li>
|
||||
<% end %>
|
||||
<li
|
||||
:if={
|
||||
@current_user.role == :admin and function_exported?(Routes, :live_dashboard_path, 2)
|
||||
}
|
||||
class="mx-2 my-1"
|
||||
>
|
||||
<.link
|
||||
navigate={Routes.live_dashboard_path(Endpoint, :home)}
|
||||
class="text-primary-400 text-primary-400 hover:underline"
|
||||
>
|
||||
<i class="fas fa-gauge"></i>
|
||||
</.link>
|
||||
</li>
|
||||
<% else %>
|
||||
<%= if Accounts.allow_registration?() do %>
|
||||
<li class="mx-2 my-1">
|
||||
<.link
|
||||
navigate={Routes.user_registration_path(Endpoint, :new)}
|
||||
class="text-primary-400 text-primary-400 hover:underline truncate"
|
||||
>
|
||||
<%= dgettext("actions", "register") %>
|
||||
</.link>
|
||||
</li>
|
||||
<% end %>
|
||||
<li :if={Accounts.allow_registration?()} class="mx-2 my-1">
|
||||
<.link
|
||||
href={Routes.user_registration_path(Endpoint, :new)}
|
||||
class="text-primary-400 text-primary-400 hover:underline truncate"
|
||||
>
|
||||
<%= dgettext("actions", "register") %>
|
||||
</.link>
|
||||
</li>
|
||||
|
||||
<li class="mx-2 my-1">
|
||||
<.link
|
||||
navigate={Routes.user_session_path(Endpoint, :new)}
|
||||
href={Routes.user_session_path(Endpoint, :new)}
|
||||
class="text-primary-400 text-primary-400 hover:underline truncate"
|
||||
>
|
||||
<%= dgettext("actions", "log in") %>
|
||||
|
@ -4,14 +4,19 @@ defmodule MemexWeb.Components.UserCard do
|
||||
"""
|
||||
|
||||
use MemexWeb, :component
|
||||
alias Memex.Accounts.User
|
||||
|
||||
attr :user, User, required: true
|
||||
slot(:inner_block, required: true)
|
||||
|
||||
def user_card(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id={"user-#{@user.id}"}
|
||||
class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center text-center
|
||||
border border-gray-400 rounded-lg shadow-lg hover:shadow-md
|
||||
transition-all duration-300 ease-in-out"
|
||||
class="px-8 py-4 flex flex-col justify-center items-center text-center
|
||||
bg-primary-900
|
||||
border border-gray-400 rounded-lg shadow-lg hover:shadow-md
|
||||
transition-all duration-300 ease-in-out"
|
||||
>
|
||||
<h1 class="px-4 py-2 rounded-lg title text-xl break-all">
|
||||
<%= @user.email %>
|
||||
@ -19,27 +24,29 @@ defmodule MemexWeb.Components.UserCard do
|
||||
|
||||
<h3 class="px-4 py-2 rounded-lg title text-lg">
|
||||
<p>
|
||||
<%= if @user.confirmed_at |> is_nil() do %>
|
||||
<%= gettext("email unconfirmed") %>
|
||||
<% else %>
|
||||
<%= if @user.confirmed_at do %>
|
||||
<%= gettext(
|
||||
"user was confirmed at %{relative_datetime}",
|
||||
relative_datetime: @user.confirmed_at |> display_datetime()
|
||||
"user confirmed on%{confirmed_datetime}",
|
||||
confirmed_datetime: ""
|
||||
) %>
|
||||
<.datetime datetime={@user.confirmed_at} />
|
||||
<% else %>
|
||||
<%= gettext("email unconfirmed") %>
|
||||
<% end %>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<%= gettext("User registered on") %>
|
||||
<%= @user.inserted_at |> display_datetime() %>
|
||||
<%= gettext(
|
||||
"user registered on%{registered_datetime}",
|
||||
registered_datetime: ""
|
||||
) %>
|
||||
<.datetime datetime={@user.inserted_at} />
|
||||
</p>
|
||||
</h3>
|
||||
|
||||
<%= if @inner_block do %>
|
||||
<div class="px-4 py-2 flex space-x-4 justify-center items-center">
|
||||
<%= render_slot(@inner_block) %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div :if={@inner_block} class="px-4 py-2 flex space-x-4 justify-center items-center">
|
||||
<%= render_slot(@inner_block) %>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
17
lib/memex_web/controllers/export_controller.ex
Normal file
17
lib/memex_web/controllers/export_controller.ex
Normal file
@ -0,0 +1,17 @@
|
||||
defmodule MemexWeb.ExportController do
|
||||
use MemexWeb, :controller
|
||||
alias Memex.{Contexts, Notes, Pipelines, Pipelines.Steps}
|
||||
|
||||
def export(%{assigns: %{current_user: current_user}} = conn, %{"mode" => "json"}) do
|
||||
pipelines =
|
||||
Pipelines.list_pipelines(current_user)
|
||||
|> Enum.map(fn pipeline -> Steps.preload_steps(pipeline, current_user) end)
|
||||
|
||||
json(conn, %{
|
||||
user: current_user,
|
||||
notes: Notes.list_notes(current_user),
|
||||
contexts: Contexts.list_contexts(current_user),
|
||||
pipelines: pipelines
|
||||
})
|
||||
end
|
||||
end
|
@ -1,15 +1,12 @@
|
||||
defmodule MemexWeb.UserRegistrationController do
|
||||
use MemexWeb, :controller
|
||||
import MemexWeb.Gettext
|
||||
alias Memex.{Accounts, Invites}
|
||||
alias Memex.Accounts.User
|
||||
alias MemexWeb.{Endpoint, HomeLive}
|
||||
alias Memex.{Accounts, Accounts.Invites}
|
||||
alias MemexWeb.HomeLive
|
||||
|
||||
def new(conn, %{"invite" => invite_token}) do
|
||||
invite = Invites.get_invite_by_token(invite_token)
|
||||
|
||||
if invite do
|
||||
conn |> render_new(invite)
|
||||
if Invites.valid_invite_token?(invite_token) do
|
||||
conn |> render_new(invite_token)
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
|
||||
@ -28,19 +25,17 @@ defmodule MemexWeb.UserRegistrationController do
|
||||
end
|
||||
|
||||
# renders new user registration page
|
||||
defp render_new(conn, invite \\ nil) do
|
||||
defp render_new(conn, invite_token \\ nil) do
|
||||
render(conn, "new.html",
|
||||
changeset: Accounts.change_user_registration(%User{}),
|
||||
invite: invite,
|
||||
changeset: Accounts.change_user_registration(),
|
||||
invite_token: invite_token,
|
||||
page_title: gettext("register")
|
||||
)
|
||||
end
|
||||
|
||||
def create(conn, %{"user" => %{"invite_token" => invite_token}} = attrs) do
|
||||
invite = Invites.get_invite_by_token(invite_token)
|
||||
|
||||
if invite do
|
||||
conn |> create_user(attrs, invite)
|
||||
if Invites.valid_invite_token?(invite_token) do
|
||||
conn |> create_user(attrs, invite_token)
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
|
||||
@ -58,24 +53,25 @@ defmodule MemexWeb.UserRegistrationController do
|
||||
end
|
||||
end
|
||||
|
||||
defp create_user(conn, %{"user" => user_params}, invite \\ nil) do
|
||||
case Accounts.register_user(user_params) do
|
||||
defp create_user(conn, %{"user" => user_params}, invite_token \\ nil) do
|
||||
case Accounts.register_user(user_params, invite_token) do
|
||||
{:ok, user} ->
|
||||
unless invite |> is_nil() do
|
||||
invite |> Invites.use_invite!()
|
||||
end
|
||||
|
||||
Accounts.deliver_user_confirmation_instructions(
|
||||
user,
|
||||
&Routes.user_confirmation_url(conn, :confirm, &1)
|
||||
)
|
||||
|
||||
conn
|
||||
|> put_flash(:info, dgettext("prompts", "Please check your email to verify your account"))
|
||||
|> put_flash(:info, dgettext("prompts", "please check your email to verify your account"))
|
||||
|> redirect(to: Routes.user_session_path(Endpoint, :new))
|
||||
|
||||
{:error, :invalid_token} ->
|
||||
conn
|
||||
|> put_flash(:error, dgettext("errors", "sorry, this invite was not found or expired"))
|
||||
|> redirect(to: Routes.live_path(Endpoint, HomeLive))
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
conn |> render("new.html", changeset: changeset, invite: invite)
|
||||
conn |> render("new.html", changeset: changeset, invite_token: invite_token)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -6,7 +6,7 @@ defmodule MemexWeb.UserResetPasswordController do
|
||||
plug :get_user_by_reset_password_token when action in [:edit, :update]
|
||||
|
||||
def new(conn, _params) do
|
||||
render(conn, "new.html", page_title: gettext("Forgot your password?"))
|
||||
render(conn, "new.html", page_title: gettext("forgot your password?"))
|
||||
end
|
||||
|
||||
def create(conn, %{"user" => %{"email" => email}}) do
|
||||
|
@ -20,7 +20,7 @@ defmodule MemexWeb.UserSessionController do
|
||||
|
||||
def delete(conn, _params) do
|
||||
conn
|
||||
|> put_flash(:info, dgettext("prompts", "Logged out successfully."))
|
||||
|> put_flash(:info, dgettext("prompts", "logged out successfully."))
|
||||
|> UserAuth.log_out_user()
|
||||
end
|
||||
end
|
||||
|
@ -7,7 +7,7 @@ defmodule MemexWeb.UserSettingsController do
|
||||
plug :assign_email_and_password_changesets
|
||||
|
||||
def edit(conn, _params) do
|
||||
render(conn, "edit.html", page_title: gettext("Settings"))
|
||||
render(conn, "edit.html", page_title: gettext("settings"))
|
||||
end
|
||||
|
||||
def update(%{assigns: %{current_user: user}} = conn, %{
|
||||
@ -28,7 +28,7 @@ defmodule MemexWeb.UserSettingsController do
|
||||
:info,
|
||||
dgettext(
|
||||
"prompts",
|
||||
"A link to confirm your email change has been sent to the new address."
|
||||
"a link to confirm your email change has been sent to the new address."
|
||||
)
|
||||
)
|
||||
|> redirect(to: Routes.user_settings_path(conn, :edit))
|
||||
@ -46,7 +46,7 @@ defmodule MemexWeb.UserSettingsController do
|
||||
case Accounts.update_user_password(user, password, user_params) do
|
||||
{:ok, user} ->
|
||||
conn
|
||||
|> put_flash(:info, dgettext("prompts", "Password updated successfully."))
|
||||
|> put_flash(:info, dgettext("prompts", "password updated successfully."))
|
||||
|> put_session(:user_return_to, Routes.user_settings_path(conn, :edit))
|
||||
|> UserAuth.log_in_user(user)
|
||||
|
||||
@ -62,7 +62,7 @@ defmodule MemexWeb.UserSettingsController do
|
||||
case Accounts.update_user_locale(user, locale) do
|
||||
{:ok, _user} ->
|
||||
conn
|
||||
|> put_flash(:info, dgettext("prompts", "Language updated successfully."))
|
||||
|> put_flash(:info, dgettext("prompts", "language updated successfully."))
|
||||
|> redirect(to: Routes.user_settings_path(conn, :edit))
|
||||
|
||||
{:error, changeset} ->
|
||||
@ -74,14 +74,14 @@ defmodule MemexWeb.UserSettingsController do
|
||||
case Accounts.update_user_email(user, token) do
|
||||
:ok ->
|
||||
conn
|
||||
|> put_flash(:info, dgettext("prompts", "Email changed successfully."))
|
||||
|> put_flash(:info, dgettext("prompts", "email changed successfully."))
|
||||
|> redirect(to: Routes.user_settings_path(conn, :edit))
|
||||
|
||||
:error ->
|
||||
conn
|
||||
|> put_flash(
|
||||
:error,
|
||||
dgettext("errors", "Email change link is invalid or it has expired.")
|
||||
dgettext("errors", "email change link is invalid or it has expired.")
|
||||
)
|
||||
|> redirect(to: Routes.user_settings_path(conn, :edit))
|
||||
end
|
||||
@ -92,11 +92,11 @@ defmodule MemexWeb.UserSettingsController do
|
||||
current_user |> Accounts.delete_user!(current_user)
|
||||
|
||||
conn
|
||||
|> put_flash(:error, dgettext("prompts", "Your account has been deleted"))
|
||||
|> put_flash(:error, dgettext("prompts", "your account has been deleted"))
|
||||
|> redirect(to: Routes.live_path(conn, HomeLive))
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, dgettext("errors", "Unable to delete user"))
|
||||
|> put_flash(:error, dgettext("errors", "unable to delete user"))
|
||||
|> redirect(to: Routes.user_settings_path(conn, :edit))
|
||||
end
|
||||
end
|
||||
|
@ -4,8 +4,8 @@ defmodule MemexWeb.ContextLive.FormComponent do
|
||||
alias Memex.Contexts
|
||||
|
||||
@impl true
|
||||
def update(%{context: context} = assigns, socket) do
|
||||
changeset = Contexts.change_context(context)
|
||||
def update(%{context: context, current_user: current_user} = assigns, socket) do
|
||||
changeset = Contexts.change_context(context, current_user)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
@ -14,39 +14,52 @@ defmodule MemexWeb.ContextLive.FormComponent do
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"context" => context_params}, socket) do
|
||||
def handle_event(
|
||||
"validate",
|
||||
%{"context" => context_params},
|
||||
%{assigns: %{context: context, current_user: current_user}} = socket
|
||||
) do
|
||||
changeset =
|
||||
socket.assigns.context
|
||||
|> Contexts.change_context(context_params)
|
||||
context
|
||||
|> Contexts.change_context(context_params, current_user)
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
{:noreply, assign(socket, :changeset, changeset)}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"context" => context_params}, socket) do
|
||||
save_context(socket, socket.assigns.action, context_params)
|
||||
def handle_event("save", %{"context" => context_params}, %{assigns: %{action: action}} = socket) do
|
||||
save_context(socket, action, context_params)
|
||||
end
|
||||
|
||||
defp save_context(socket, :edit, context_params) do
|
||||
case Contexts.update_context(socket.assigns.context, context_params) do
|
||||
{:ok, _context} ->
|
||||
defp save_context(
|
||||
%{assigns: %{context: context, return_to: return_to, current_user: current_user}} =
|
||||
socket,
|
||||
:edit,
|
||||
context_params
|
||||
) do
|
||||
case Contexts.update_context(context, context_params, current_user) do
|
||||
{:ok, %{slug: slug}} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "context updated successfully")
|
||||
|> push_navigate(to: socket.assigns.return_to)}
|
||||
|> put_flash(:info, gettext("%{slug} saved", slug: slug))
|
||||
|> push_navigate(to: return_to)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :changeset, changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_context(socket, :new, context_params) do
|
||||
case Contexts.create_context(context_params) do
|
||||
{:ok, _context} ->
|
||||
defp save_context(
|
||||
%{assigns: %{return_to: return_to, current_user: current_user}} = socket,
|
||||
:new,
|
||||
context_params
|
||||
) do
|
||||
case Contexts.create_context(context_params, current_user) do
|
||||
{:ok, %{slug: slug}} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "context created successfully")
|
||||
|> push_navigate(to: socket.assigns.return_to)}
|
||||
|> put_flash(:info, gettext("%{slug} created", slug: slug))
|
||||
|> push_navigate(to: return_to)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, changeset: changeset)}
|
||||
|
@ -1,6 +1,4 @@
|
||||
<div>
|
||||
<h2><%= @title %></h2>
|
||||
|
||||
<div class="h-full flex flex-col justify-start items-stretch space-y-4">
|
||||
<.form
|
||||
:let={f}
|
||||
for={@changeset}
|
||||
@ -8,27 +6,42 @@
|
||||
phx-target={@myself}
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
phx-debounce="300"
|
||||
class="flex flex-col justify-start items-stretch space-y-4"
|
||||
>
|
||||
<%= label(f, :title) %>
|
||||
<%= text_input(f, :title) %>
|
||||
<%= error_tag(f, :title) %>
|
||||
<%= text_input(f, :slug,
|
||||
class: "input input-primary",
|
||||
placeholder: gettext("slug")
|
||||
) %>
|
||||
<%= error_tag(f, :slug) %>
|
||||
|
||||
<%= label(f, :content) %>
|
||||
<%= textarea(f, :content) %>
|
||||
<%= textarea(f, :content,
|
||||
id: "context-form-content",
|
||||
class: "input input-primary h-64 min-h-64",
|
||||
phx_hook: "MaintainAttrs",
|
||||
phx_update: "ignore",
|
||||
placeholder: gettext("use [[note-slug]] to link to a note")
|
||||
) %>
|
||||
<%= error_tag(f, :content) %>
|
||||
|
||||
<%= label(f, :tag) %>
|
||||
<%= multiple_select(f, :tag, "Option 1": "option1", "Option 2": "option2") %>
|
||||
<%= error_tag(f, :tag) %>
|
||||
|
||||
<%= label(f, :visibility) %>
|
||||
<%= select(f, :visibility, Ecto.Enum.values(Memex.Contexts.Context, :visibility),
|
||||
prompt: "Choose a value"
|
||||
<%= text_input(f, :tags_string,
|
||||
id: "tags-input",
|
||||
class: "input input-primary",
|
||||
placeholder: gettext("tag1,tag2")
|
||||
) %>
|
||||
<%= error_tag(f, :visibility) %>
|
||||
<%= error_tag(f, :tags_string) %>
|
||||
|
||||
<div>
|
||||
<%= submit("Save", phx_disable_with: "Saving...") %>
|
||||
<div class="flex justify-center items-stretch space-x-4">
|
||||
<%= select(f, :visibility, Ecto.Enum.values(Memex.Contexts.Context, :visibility),
|
||||
class: "grow input input-primary",
|
||||
prompt: gettext("select privacy")
|
||||
) %>
|
||||
|
||||
<%= submit(dgettext("actions", "save"),
|
||||
phx_disable_with: gettext("saving..."),
|
||||
class: "mx-auto btn btn-primary"
|
||||
) %>
|
||||
</div>
|
||||
<%= error_tag(f, :visibility) %>
|
||||
</.form>
|
||||
</div>
|
||||
|
@ -1,46 +1,89 @@
|
||||
defmodule MemexWeb.ContextLive.Index do
|
||||
use MemexWeb, :live_view
|
||||
|
||||
alias Memex.Contexts
|
||||
alias Memex.Contexts.Context
|
||||
alias Memex.{Accounts.User, Contexts, Contexts.Context}
|
||||
|
||||
@impl true
|
||||
def mount(%{"search" => search}, _session, socket) do
|
||||
{:ok, socket |> assign(search: search) |> display_contexts()}
|
||||
end
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, assign(socket, :contexts, list_contexts())}
|
||||
{:ok, socket |> assign(search: nil) |> display_contexts()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
||||
def handle_params(params, _url, %{assigns: %{live_action: live_action}} = socket) do
|
||||
{:noreply, apply_action(socket, live_action, params)}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||
defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"slug" => slug}) do
|
||||
%{slug: slug} = context = Contexts.get_context_by_slug(slug, current_user)
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "edit context")
|
||||
|> assign(:context, Contexts.get_context!(id))
|
||||
|> assign(page_title: gettext("edit %{slug}", slug: slug))
|
||||
|> assign(context: context)
|
||||
end
|
||||
|
||||
defp apply_action(socket, :new, _params) do
|
||||
defp apply_action(%{assigns: %{current_user: %{id: current_user_id}}} = socket, :new, _params) do
|
||||
socket
|
||||
|> assign(:page_title, "new context")
|
||||
|> assign(:context, %Context{})
|
||||
|> assign(page_title: gettext("new context"))
|
||||
|> assign(context: %Context{visibility: :private, user_id: current_user_id})
|
||||
end
|
||||
|
||||
defp apply_action(socket, :index, _params) do
|
||||
socket
|
||||
|> assign(:page_title, "listing contexts")
|
||||
|> assign(:context, nil)
|
||||
|> assign(page_title: gettext("contexts"))
|
||||
|> assign(search: nil)
|
||||
|> assign(context: nil)
|
||||
|> display_contexts()
|
||||
end
|
||||
|
||||
defp apply_action(socket, :search, %{"search" => search}) do
|
||||
socket
|
||||
|> assign(page_title: gettext("contexts"))
|
||||
|> assign(search: search)
|
||||
|> assign(context: nil)
|
||||
|> display_contexts()
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
context = Contexts.get_context!(id)
|
||||
{:ok, _} = Contexts.delete_context(context)
|
||||
def handle_event("delete", %{"id" => id}, %{assigns: %{current_user: current_user}} = socket) do
|
||||
context = Contexts.get_context!(id, current_user)
|
||||
{:ok, %{slug: slug}} = Contexts.delete_context(context, current_user)
|
||||
|
||||
{:noreply, assign(socket, :contexts, list_contexts())}
|
||||
socket =
|
||||
socket
|
||||
|> assign(contexts: Contexts.list_contexts(current_user))
|
||||
|> put_flash(:info, gettext("%{slug} deleted", slug: slug))
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp list_contexts do
|
||||
Contexts.list_contexts()
|
||||
@impl true
|
||||
def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do
|
||||
{:noreply, socket |> push_patch(to: Routes.context_index_path(Endpoint, :index))}
|
||||
end
|
||||
|
||||
def handle_event("search", %{"search" => %{"search_term" => search_term}}, socket) do
|
||||
{:noreply,
|
||||
socket |> push_patch(to: Routes.context_index_path(Endpoint, :search, search_term))}
|
||||
end
|
||||
|
||||
defp display_contexts(%{assigns: %{current_user: current_user, search: search}} = socket)
|
||||
when not (current_user |> is_nil()) do
|
||||
socket |> assign(contexts: Contexts.list_contexts(search, current_user))
|
||||
end
|
||||
|
||||
defp display_contexts(%{assigns: %{search: search}} = socket) do
|
||||
socket |> assign(contexts: Contexts.list_public_contexts(search))
|
||||
end
|
||||
|
||||
@spec is_owner_or_admin?(Context.t(), User.t()) :: boolean()
|
||||
defp is_owner_or_admin?(%{user_id: user_id}, %{id: user_id}), do: true
|
||||
defp is_owner_or_admin?(_context, %{role: :admin}), do: true
|
||||
defp is_owner_or_admin?(_context, _other_user), do: false
|
||||
|
||||
@spec is_owner?(Context.t(), User.t()) :: boolean()
|
||||
defp is_owner?(%{user_id: user_id}, %{id: user_id}), do: true
|
||||
defp is_owner?(_context, _other_user), do: false
|
||||
end
|
||||
|
@ -1,66 +1,73 @@
|
||||
<h1>listing contexts</h1>
|
||||
<div class="mx-auto flex flex-col justify-center items-start space-y-4 max-w-3xl">
|
||||
<h1 class="text-xl">
|
||||
<%= gettext("contexts") %>
|
||||
</h1>
|
||||
|
||||
<%= if @live_action in [:new, :edit] do %>
|
||||
<.modal return_to={Routes.context_index_path(@socket, :index)}>
|
||||
<.form
|
||||
:let={f}
|
||||
for={:search}
|
||||
phx-change="search"
|
||||
phx-submit="search"
|
||||
class="self-stretch flex flex-col items-stretch"
|
||||
>
|
||||
<%= text_input(f, :search_term,
|
||||
class: "input input-primary",
|
||||
value: @search,
|
||||
phx_debounce: 300,
|
||||
placeholder: gettext("search")
|
||||
) %>
|
||||
</.form>
|
||||
|
||||
<%= if @contexts |> Enum.empty?() do %>
|
||||
<h1 class="self-center text-primary-500">
|
||||
<%= gettext("no contexts found") %>
|
||||
</h1>
|
||||
<% else %>
|
||||
<.live_component
|
||||
module={MemexWeb.ContextLive.FormComponent}
|
||||
id={@context.id || :new}
|
||||
title={@page_title}
|
||||
action={@live_action}
|
||||
context={@context}
|
||||
return_to={Routes.context_index_path(@socket, :index)}
|
||||
/>
|
||||
</.modal>
|
||||
<% end %>
|
||||
module={MemexWeb.Components.ContextsTableComponent}
|
||||
id="contexts-index-table"
|
||||
current_user={@current_user}
|
||||
contexts={@contexts}
|
||||
>
|
||||
<:actions :let={context}>
|
||||
<.link
|
||||
:if={is_owner?(context, @current_user)}
|
||||
patch={Routes.context_index_path(@socket, :edit, context.slug)}
|
||||
data-qa={"context-edit-#{context.id}"}
|
||||
>
|
||||
<%= dgettext("actions", "edit") %>
|
||||
</.link>
|
||||
<.link
|
||||
:if={is_owner_or_admin?(context, @current_user)}
|
||||
href="#"
|
||||
phx-click="delete"
|
||||
phx-value-id={context.id}
|
||||
data-confirm={dgettext("prompts", "are you sure?")}
|
||||
data-qa={"delete-context-#{context.id}"}
|
||||
>
|
||||
<%= dgettext("actions", "delete") %>
|
||||
</.link>
|
||||
</:actions>
|
||||
</.live_component>
|
||||
<% end %>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Content</th>
|
||||
<th>Tag</th>
|
||||
<th>Visibility</th>
|
||||
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="contexts">
|
||||
<%= for context <- @contexts do %>
|
||||
<tr id={"context-#{context.id}"}>
|
||||
<td><%= context.title %></td>
|
||||
<td><%= context.content %></td>
|
||||
<td><%= context.tag %></td>
|
||||
<td><%= context.visibility %></td>
|
||||
|
||||
<td>
|
||||
<span>
|
||||
<.link navigate={Routes.context_show_path(@socket, :show, context)}>
|
||||
<%= dgettext("actions", "show") %>
|
||||
</.link>
|
||||
</span>
|
||||
<span>
|
||||
<.link patch={Routes.context_index_path(@socket, :edit, context)}>
|
||||
<%= dgettext("actions", "edit") %>
|
||||
</.link>
|
||||
</span>
|
||||
<span>
|
||||
<.link
|
||||
href="#"
|
||||
phx-click="delete"
|
||||
phx-value-id={context.id}
|
||||
data-confirm={dgettext("prompts", "are you sure?")}
|
||||
>
|
||||
<%= dgettext("actions", "delete") %>
|
||||
</.link>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<span>
|
||||
<.link patch={Routes.context_index_path(@socket, :new)}>
|
||||
<.link
|
||||
:if={@current_user}
|
||||
patch={Routes.context_index_path(@socket, :new)}
|
||||
class="self-end btn btn-primary"
|
||||
>
|
||||
<%= dgettext("actions", "new context") %>
|
||||
</.link>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<.modal :if={@live_action in [:new, :edit]} return_to={Routes.context_index_path(@socket, :index)}>
|
||||
<.live_component
|
||||
module={MemexWeb.ContextLive.FormComponent}
|
||||
id={@context.id || :new}
|
||||
current_user={@current_user}
|
||||
title={@page_title}
|
||||
action={@live_action}
|
||||
context={@context}
|
||||
return_to={Routes.context_index_path(@socket, :index)}
|
||||
/>
|
||||
</.modal>
|
||||
|
@ -1,7 +1,7 @@
|
||||
defmodule MemexWeb.ContextLive.Show do
|
||||
use MemexWeb, :live_view
|
||||
|
||||
alias Memex.Contexts
|
||||
import MemexWeb.Components.ContextContent
|
||||
alias Memex.{Accounts.User, Contexts, Contexts.Context}
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
@ -9,13 +9,50 @@ defmodule MemexWeb.ContextLive.Show do
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(%{"id" => id}, _, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, page_title(socket.assigns.live_action))
|
||||
|> assign(:context, Contexts.get_context!(id))}
|
||||
def handle_params(
|
||||
%{"slug" => slug},
|
||||
_params,
|
||||
%{assigns: %{live_action: live_action, current_user: current_user}} = socket
|
||||
) do
|
||||
context =
|
||||
case Contexts.get_context_by_slug(slug, current_user) do
|
||||
nil -> raise MemexWeb.NotFoundError, gettext("%{slug} could not be found", slug: slug)
|
||||
context -> context
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, page_title(live_action, context))
|
||||
|> assign(:context, context)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp page_title(:show), do: "show context"
|
||||
defp page_title(:edit), do: "edit context"
|
||||
@impl true
|
||||
def handle_event(
|
||||
"delete",
|
||||
_params,
|
||||
%{assigns: %{context: context, current_user: current_user}} = socket
|
||||
) do
|
||||
{:ok, %{slug: slug}} = Contexts.delete_context(context, current_user)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> put_flash(:info, gettext("%{slug} deleted", slug: slug))
|
||||
|> push_navigate(to: Routes.context_index_path(Endpoint, :index))
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp page_title(:show, %{slug: slug}), do: slug
|
||||
defp page_title(:edit, %{slug: slug}), do: gettext("edit %{slug}", slug: slug)
|
||||
|
||||
@spec is_owner_or_admin?(Context.t(), User.t()) :: boolean()
|
||||
defp is_owner_or_admin?(%{user_id: user_id}, %{id: user_id}), do: true
|
||||
defp is_owner_or_admin?(_context, %{role: :admin}), do: true
|
||||
defp is_owner_or_admin?(_context, _other_user), do: false
|
||||
|
||||
@spec is_owner?(Context.t(), User.t()) :: boolean()
|
||||
defp is_owner?(%{user_id: user_id}, %{id: user_id}), do: true
|
||||
defp is_owner?(_context, _other_user), do: false
|
||||
end
|
||||
|
@ -1,48 +1,59 @@
|
||||
<h1>show context</h1>
|
||||
<div class="mx-auto flex flex-col justify-center items-stretch space-y-4 max-w-3xl">
|
||||
<h1 class="text-xl">
|
||||
<%= @context.slug %>
|
||||
</h1>
|
||||
|
||||
<%= if @live_action in [:edit] do %>
|
||||
<.modal return_to={Routes.context_show_path(@socket, :show, @context)}>
|
||||
<.live_component
|
||||
module={MemexWeb.ContextLive.FormComponent}
|
||||
id={@context.id}
|
||||
title={@page_title}
|
||||
action={@live_action}
|
||||
context={@context}
|
||||
return_to={Routes.context_show_path(@socket, :show, @context)}
|
||||
/>
|
||||
</.modal>
|
||||
<% end %>
|
||||
<div class="flex flex-wrap space-x-1">
|
||||
<.link
|
||||
:for={tag <- @context.tags}
|
||||
navigate={Routes.context_index_path(Endpoint, :search, tag)}
|
||||
class="link"
|
||||
>
|
||||
<%= tag %>
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Title:</strong>
|
||||
<%= @context.title %>
|
||||
</li>
|
||||
<.context_content context={@context} />
|
||||
|
||||
<li>
|
||||
<strong>Content:</strong>
|
||||
<%= @context.content %>
|
||||
</li>
|
||||
<p class="self-end">
|
||||
<%= gettext("Visibility: %{visibility}", visibility: @context.visibility) %>
|
||||
</p>
|
||||
|
||||
<li>
|
||||
<strong>Tag:</strong>
|
||||
<%= @context.tag %>
|
||||
</li>
|
||||
<div class="self-end flex space-x-4">
|
||||
<.link class="btn btn-primary" navigate={Routes.context_index_path(@socket, :index)}>
|
||||
<%= dgettext("actions", "back") %>
|
||||
</.link>
|
||||
<.link
|
||||
:if={is_owner?(@context, @current_user)}
|
||||
class="btn btn-primary"
|
||||
patch={Routes.context_show_path(@socket, :edit, @context.slug)}
|
||||
>
|
||||
<%= dgettext("actions", "edit") %>
|
||||
</.link>
|
||||
<button
|
||||
:if={is_owner_or_admin?(@context, @current_user)}
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
phx-click="delete"
|
||||
data-confirm={dgettext("prompts", "are you sure?")}
|
||||
data-qa={"delete-context-#{@context.id}"}
|
||||
>
|
||||
<%= dgettext("actions", "delete") %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<li>
|
||||
<strong>Visibility:</strong>
|
||||
<%= @context.visibility %>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<span>
|
||||
<.link patch={Routes.context_show_path(@socket, :edit, @context)} class="button">
|
||||
<%= dgettext("actions", "edit") %>
|
||||
</.link>
|
||||
</span>
|
||||
|
|
||||
<span>
|
||||
<.link navigate={Routes.context_index_path(@socket, :index)}>
|
||||
<%= dgettext("actions", "Back") %>
|
||||
</.link>
|
||||
</span>
|
||||
<.modal
|
||||
:if={@live_action == :edit}
|
||||
return_to={Routes.context_show_path(@socket, :show, @context.slug)}
|
||||
>
|
||||
<.live_component
|
||||
module={MemexWeb.ContextLive.FormComponent}
|
||||
id={@context.id}
|
||||
current_user={@current_user}
|
||||
title={@page_title}
|
||||
action={@live_action}
|
||||
context={@context}
|
||||
return_to={Routes.context_show_path(@socket, :show, @context.slug)}
|
||||
/>
|
||||
</.modal>
|
||||
|
12
lib/memex_web/live/faq_live.ex
Normal file
12
lib/memex_web/live/faq_live.ex
Normal file
@ -0,0 +1,12 @@
|
||||
defmodule MemexWeb.FaqLive do
|
||||
@moduledoc """
|
||||
Liveview for the faq page
|
||||
"""
|
||||
|
||||
use MemexWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, socket |> assign(page_title: gettext("faq"))}
|
||||
end
|
||||
end
|
128
lib/memex_web/live/faq_live.html.heex
Normal file
128
lib/memex_web/live/faq_live.html.heex
Normal file
@ -0,0 +1,128 @@
|
||||
<div class="mx-auto flex flex-col justify-center items-stretch space-y-8 text-center max-w-3xl">
|
||||
<h1 class="title text-primary-400 text-xl text-left">
|
||||
<%= gettext("faq") %>
|
||||
</h1>
|
||||
|
||||
<hr class="hr" />
|
||||
|
||||
<ul class="flex flex-col justify-center items-stretch space-y-8">
|
||||
<li class="flex flex-col justify-center items-stretch space-y-2">
|
||||
<b class="whitespace-nowrap text-left">
|
||||
<%= gettext("what is this?") %>
|
||||
</b>
|
||||
<p>
|
||||
<%= gettext(
|
||||
"this is a memex, used to document not just your notes, but also your perspectives and processes."
|
||||
) %>
|
||||
<%= gettext("some things that this memex is very loosely inspired by:") %>
|
||||
</p>
|
||||
|
||||
<ul class="list-disc flex flex-col justify-center items-center space-y-2">
|
||||
<li>
|
||||
<.link
|
||||
href="https://en.wikipedia.org/wiki/Memex"
|
||||
class="flex flex-row justify-center items-center space-x-2 link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<%= gettext("memex") %>
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
href="https://en.wikipedia.org/wiki/Zettelkasten"
|
||||
class="flex flex-row justify-center items-center space-x-2 link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<%= gettext("zettelkasten") %>
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link
|
||||
href="https://en.wikipedia.org/wiki/Org-mode"
|
||||
class="flex flex-row justify-center items-center space-x-2 link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<%= gettext("org-mode") %>
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li class="flex flex-col justify-center items-stretch space-y-2">
|
||||
<b class="whitespace-nowrap text-left">
|
||||
<%= gettext("why split up into notes, contexts and pipelines?") %>
|
||||
</b>
|
||||
<p>
|
||||
<%= gettext(
|
||||
"i really admired the idea of a zettelkasten, especially with org-mode backlinks, however I felt like my notes would immediately become too messy by just putting everything into a single hierarchy."
|
||||
) %>
|
||||
<%= gettext(
|
||||
"i wanted to separate between a personal dictionary of concepts and then my thought processes that are built off of my experiences and life lessons. these are notes, and contexts, respectively."
|
||||
) %>
|
||||
<%= gettext(
|
||||
"finally, i wanted to externalize the processes for common situations that use these thought processes at discrete steps. these are pipelines!"
|
||||
) %>
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li class="flex flex-col justify-center items-stretch space-y-2">
|
||||
<b class="whitespace-nowrap text-left">
|
||||
<%= gettext("what should my notes be like?") %>
|
||||
</b>
|
||||
<p>
|
||||
<%= gettext(
|
||||
"in my opinion, notes should be written by any of the discrete objects or concepts that are meaningful to you in your life."
|
||||
) %>
|
||||
<%= gettext(
|
||||
"spoons? probably not. a particular brand of spoons that you really like? why not :)"
|
||||
) %>
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li class="flex flex-col justify-center items-stretch space-y-2">
|
||||
<b class="whitespace-nowrap text-left">
|
||||
<%= gettext("what should my contexts be like?") %>
|
||||
</b>
|
||||
<p>
|
||||
<%= gettext("in my opinion, contexts should be like single-topic blog posts.") %>
|
||||
<%= gettext(
|
||||
"for instance, a good context could be what makes some physical designs spark joy for you, and in that context you could backlink to the spoon note as an example of how it fits nicely into your hand."
|
||||
) %>
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li class="flex flex-col justify-center items-stretch space-y-2">
|
||||
<b class="whitespace-nowrap text-left">
|
||||
<%= gettext("what should my pipelines be like?") %>
|
||||
</b>
|
||||
<p>
|
||||
<%= gettext(
|
||||
"in my opinion, pipelines should be pretty lightweight, and just backlink to contexts to provide most of the heavy lifting."
|
||||
) %>
|
||||
<%= gettext(
|
||||
"for instance, a pipeline for buying an object could have a step where you consider how much it sparks joy, and it could backlink to the physical designs context, maybe with some notes about how it applies in this case."
|
||||
) %>
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li class="flex flex-col justify-center items-stretch space-y-2">
|
||||
<b class="whitespace-nowrap text-left">
|
||||
<%= gettext("how many people should i invite?") %>
|
||||
</b>
|
||||
<p>
|
||||
<%= gettext(
|
||||
"while memEx fully supports multiple users, each memEx instance should be treated as a single cohesive and collaborative document."
|
||||
) %>
|
||||
<%= gettext(
|
||||
"note, context and pipeline slugs must be unique, and you are free to backlink to notes not written by you."
|
||||
) %>
|
||||
<%= gettext(
|
||||
"so, i'd recommend inviting anyone you'd like to work on your collective memEx. however, when in doubt, hopefully setting up a new instance is easy enough. if it isn't, then feel free to let me know :)"
|
||||
) %>
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
@ -5,41 +5,13 @@ defmodule MemexWeb.HomeLive do
|
||||
|
||||
use MemexWeb, :live_view
|
||||
alias Memex.Accounts
|
||||
alias MemexWeb.FaqLive
|
||||
|
||||
@version Mix.Project.config()[:version]
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
admins = Accounts.list_users_by_role(:admin)
|
||||
{:ok, socket |> assign(page_title: gettext("Home"), query: "", results: %{}, admins: admins)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("suggest", %{"q" => query}, socket) do
|
||||
{:noreply, socket |> assign(results: search(query), query: query)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("search", %{"q" => query}, socket) do
|
||||
case search(query) do
|
||||
%{^query => vsn} ->
|
||||
{:noreply, socket |> redirect(external: "https://hexdocs.pm/#{query}/#{vsn}")}
|
||||
|
||||
_ ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "No dependencies found matching \"#{query}\"")
|
||||
|> assign(results: %{}, query: query)}
|
||||
end
|
||||
end
|
||||
|
||||
defp search(query) do
|
||||
if not MemexWeb.Endpoint.config(:code_reloader) do
|
||||
raise "action disabled when not in development"
|
||||
end
|
||||
|
||||
for {app, desc, vsn} <- Application.started_applications(),
|
||||
app = to_string(app),
|
||||
String.starts_with?(app, query) and not List.starts_with?(desc, ~c"ERTS"),
|
||||
into: %{},
|
||||
do: {app, vsn}
|
||||
{:ok, socket |> assign(page_title: gettext("home"), admins: admins, version: @version)}
|
||||
end
|
||||
end
|
||||
|
@ -1,13 +1,10 @@
|
||||
<div class="flex flex-col justify-center items-center text-center space-y-4">
|
||||
<h1 class="title text-primary-400 text-2xl">
|
||||
<%= gettext("memex") %>
|
||||
<div class="mx-auto flex flex-col justify-center items-stretch space-y-4 max-w-lg">
|
||||
<h1 class="title text-primary-400 text-xl">
|
||||
<%= gettext("memEx") %>
|
||||
</h1>
|
||||
|
||||
<hr class="hr" />
|
||||
|
||||
<ul class="flex flex-col space-y-4 text-center">
|
||||
<li class="flex flex-col justify-center items-center
|
||||
space-y-2">
|
||||
<ul class="flex flex-col space-y-4">
|
||||
<li class="flex flex-col justify-center items-center space-y-2">
|
||||
<b class="whitespace-nowrap">
|
||||
<%= gettext("notes:") %>
|
||||
</b>
|
||||
@ -16,8 +13,7 @@
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li class="flex flex-col justify-center items-center
|
||||
space-y-2">
|
||||
<li class="flex flex-col justify-center items-center space-y-2">
|
||||
<b class="whitespace-nowrap">
|
||||
<%= gettext("contexts:") %>
|
||||
</b>
|
||||
@ -26,8 +22,7 @@
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li class="flex flex-col justify-center items-center
|
||||
space-y-2">
|
||||
<li class="flex flex-col justify-center items-center space-y-2">
|
||||
<b class="whitespace-nowrap">
|
||||
<%= gettext("pipelines:") %>
|
||||
</b>
|
||||
@ -35,17 +30,22 @@
|
||||
<%= gettext("document your processes, attaching contexts to each step") %>
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li class="flex flex-col justify-center items-center text-right space-y-2">
|
||||
<.link navigate={Routes.live_path(Endpoint, FaqLive)} class="btn btn-primary">
|
||||
<%= gettext("read more on how to use memEx") %>
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<hr class="hr" />
|
||||
|
||||
<ul class="flex flex-col space-y-4 text-center">
|
||||
<ul class="flex flex-col space-y-4">
|
||||
<h2 class="title text-primary-400 text-lg">
|
||||
<%= gettext("features") %>
|
||||
</h2>
|
||||
|
||||
<li class="flex flex-col justify-center items-center
|
||||
space-y-2">
|
||||
<li class="flex flex-col justify-center items-center space-y-2">
|
||||
<b class="whitespace-nowrap">
|
||||
<%= gettext("multi-user:") %>
|
||||
</b>
|
||||
@ -54,8 +54,7 @@
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li class="flex flex-col justify-center items-center
|
||||
space-y-2">
|
||||
<li class="flex flex-col justify-center items-center space-y-2">
|
||||
<b class="whitespace-nowrap">
|
||||
<%= gettext("privacy:") %>
|
||||
</b>
|
||||
@ -64,8 +63,7 @@
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li class="flex flex-col justify-center items-center
|
||||
space-y-2">
|
||||
<li class="flex flex-col justify-center items-center space-y-2">
|
||||
<b class="whitespace-nowrap">
|
||||
<%= gettext("convenient:") %>
|
||||
</b>
|
||||
@ -77,55 +75,47 @@
|
||||
|
||||
<hr class="hr" />
|
||||
|
||||
<ul class="flex flex-col space-y-4 text-center">
|
||||
<ul class="flex flex-col justify-center space-y-4">
|
||||
<h2 class="title text-primary-400 text-lg">
|
||||
<%= gettext("instance information") %>
|
||||
</h2>
|
||||
|
||||
<li class="flex flex-col justify-center space-x-2">
|
||||
<li class="flex flex-col justify-center items-center space-y-2">
|
||||
<b>
|
||||
<%= gettext("admins:") %>
|
||||
</b>
|
||||
<p>
|
||||
<p class="flex flex-col justify-center items-center space-y-2">
|
||||
<%= if @admins |> Enum.empty?() do %>
|
||||
<.link
|
||||
href={Routes.user_registration_path(MemexWeb.Endpoint, :new)}
|
||||
class="hover:underline"
|
||||
>
|
||||
<%= dgettext("prompts", "register to setup %{name}", name: "memex") %>
|
||||
<.link href={Routes.user_registration_path(Endpoint, :new)} class="link">
|
||||
<%= dgettext("prompts", "register to setup memEx") %>
|
||||
</.link>
|
||||
<% else %>
|
||||
<div class="flex flex-wrap justify-center space-x-2">
|
||||
<%= for admin <- @admins do %>
|
||||
<a class="hover:underline" href={"mailto:#{admin.email}"}>
|
||||
<%= admin.email %>
|
||||
</a>
|
||||
<% end %>
|
||||
</div>
|
||||
<a :for={%{email: email} <- @admins} class="link" href={"mailto:#{email}"}>
|
||||
<%= email %>
|
||||
</a>
|
||||
<% end %>
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li class="flex flex-row justify-center space-x-2">
|
||||
<li class="flex flex-col justify-center items-center space-y-2">
|
||||
<b><%= gettext("registration:") %></b>
|
||||
<p>
|
||||
<%= Application.get_env(:memex, MemexWeb.Endpoint)[:registration]
|
||||
|> case do
|
||||
<%= case Application.get_env(:memex, Memex.Accounts)[:registration] do
|
||||
"public" -> gettext("public signups")
|
||||
_ -> gettext("invite only")
|
||||
end %>
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li class="flex flex-row justify-center items-center space-x-2">
|
||||
<li class="flex flex-col justify-center items-center space-y-2">
|
||||
<b><%= gettext("version:") %></b>
|
||||
<.link
|
||||
href="https://gitea.bubbletea.dev/shibao/memex/src/branch/stable/CHANGELOG.md"
|
||||
class="flex flex-row justify-center items-center space-x-2 hover:underline"
|
||||
href="https://gitea.bubbletea.dev/shibao/memEx/src/branch/stable/changelog.md"
|
||||
class="flex flex-row justify-center items-center space-x-2 link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<p>0.1.0</p>
|
||||
<p><%= @version %></p>
|
||||
<i class="fas fa-md fa-info-circle"></i>
|
||||
</.link>
|
||||
</li>
|
||||
@ -133,15 +123,15 @@
|
||||
|
||||
<hr class="hr" />
|
||||
|
||||
<ul class="flex flex-col space-y-2 text-center justify-center">
|
||||
<ul class="flex flex-col space-y-2">
|
||||
<h2 class="title text-primary-400 text-lg">
|
||||
<%= gettext("get involved!") %>
|
||||
<%= gettext("get involved") %>
|
||||
</h2>
|
||||
|
||||
<li class="flex flex-col justify-center space-x-2">
|
||||
<li class="flex flex-col justify-center items-center space-y-2">
|
||||
<.link
|
||||
href="https://gitea.bubbletea.dev/shibao/memex"
|
||||
class="flex flex-row justify-center items-center space-x-2 hover:underline"
|
||||
href="https://gitea.bubbletea.dev/shibao/memEx"
|
||||
class="flex flex-row justify-center items-center space-x-2 link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
@ -149,10 +139,10 @@
|
||||
<i class="fas fa-md fa-code"></i>
|
||||
</.link>
|
||||
</li>
|
||||
<li class="flex flex-col justify-center space-x-2">
|
||||
<li class="flex flex-col justify-center items-center space-y-2">
|
||||
<.link
|
||||
href="https://weblate.bubbletea.dev/engage/memex"
|
||||
class="flex flex-row justify-center items-center space-x-2 hover:underline"
|
||||
href="https://weblate.bubbletea.dev/engage/memEx"
|
||||
class="flex flex-row justify-center items-center space-x-2 link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
@ -160,10 +150,10 @@
|
||||
<i class="fas fa-md fa-language"></i>
|
||||
</.link>
|
||||
</li>
|
||||
<li class="flex flex-col justify-center space-x-2">
|
||||
<li class="flex flex-col justify-center items-center space-y-2">
|
||||
<.link
|
||||
href="https://gitea.bubbletea.dev/shibao/memex/issues/new"
|
||||
class="flex flex-row justify-center items-center space-x-2 hover:underline"
|
||||
href="https://gitea.bubbletea.dev/shibao/memEx/issues/new"
|
||||
class="flex flex-row justify-center items-center space-x-2 link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
@ -1,11 +1,11 @@
|
||||
defmodule MemexWeb.InviteLive.FormComponent do
|
||||
@moduledoc """
|
||||
Livecomponent that can update or create an Memex.Invites.Invite
|
||||
Livecomponent that can update or create an Memex.Accounts.Invite
|
||||
"""
|
||||
|
||||
use MemexWeb, :live_component
|
||||
alias Ecto.Changeset
|
||||
alias Memex.{Accounts.User, Invites, Invites.Invite}
|
||||
alias Memex.Accounts.{Invite, Invites, User}
|
||||
alias Phoenix.LiveView.Socket
|
||||
|
||||
@impl true
|
||||
@ -13,23 +13,44 @@ defmodule MemexWeb.InviteLive.FormComponent do
|
||||
%{:invite => Invite.t(), :current_user => User.t(), optional(any) => any},
|
||||
Socket.t()
|
||||
) :: {:ok, Socket.t()}
|
||||
def update(%{invite: invite} = assigns, socket) do
|
||||
{:ok, socket |> assign(assigns) |> assign(:changeset, Invites.change_invite(invite))}
|
||||
def update(%{invite: _invite} = assigns, socket) do
|
||||
{:ok, socket |> assign(assigns) |> assign_changeset(%{})}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(
|
||||
"validate",
|
||||
%{"invite" => invite_params},
|
||||
%{assigns: %{invite: invite}} = socket
|
||||
) do
|
||||
{:noreply, socket |> assign(:changeset, invite |> Invites.change_invite(invite_params))}
|
||||
def handle_event("validate", %{"invite" => invite_params}, socket) do
|
||||
{:noreply, socket |> assign_changeset(invite_params)}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"invite" => invite_params}, %{assigns: %{action: action}} = socket) do
|
||||
save_invite(socket, action, invite_params)
|
||||
end
|
||||
|
||||
defp assign_changeset(
|
||||
%{assigns: %{action: action, current_user: user, invite: invite}} = socket,
|
||||
invite_params
|
||||
) do
|
||||
changeset_action =
|
||||
case action do
|
||||
:new -> :insert
|
||||
:edit -> :update
|
||||
end
|
||||
|
||||
changeset =
|
||||
case action do
|
||||
:new -> Invite.create_changeset(user, "example_token", invite_params)
|
||||
:edit -> invite |> Invite.update_changeset(invite_params)
|
||||
end
|
||||
|
||||
changeset =
|
||||
case changeset |> Changeset.apply_action(changeset_action) do
|
||||
{:ok, _data} -> changeset
|
||||
{:error, changeset} -> changeset
|
||||
end
|
||||
|
||||
socket |> assign(:changeset, changeset)
|
||||
end
|
||||
|
||||
defp save_invite(
|
||||
%{assigns: %{current_user: current_user, invite: invite, return_to: return_to}} = socket,
|
||||
:edit,
|
||||
@ -38,9 +59,7 @@ defmodule MemexWeb.InviteLive.FormComponent do
|
||||
socket =
|
||||
case invite |> Invites.update_invite(invite_params, current_user) do
|
||||
{:ok, %{name: invite_name}} ->
|
||||
prompt =
|
||||
dgettext("prompts", "%{invite_name} updated successfully", invite_name: invite_name)
|
||||
|
||||
prompt = dgettext("prompts", "%{name} updated successfully", name: invite_name)
|
||||
socket |> put_flash(:info, prompt) |> push_navigate(to: return_to)
|
||||
|
||||
{:error, %Changeset{} = changeset} ->
|
||||
@ -58,9 +77,7 @@ defmodule MemexWeb.InviteLive.FormComponent do
|
||||
socket =
|
||||
case current_user |> Invites.create_invite(invite_params) do
|
||||
{:ok, %{name: invite_name}} ->
|
||||
prompt =
|
||||
dgettext("prompts", "%{invite_name} created successfully", invite_name: invite_name)
|
||||
|
||||
prompt = dgettext("prompts", "%{name} created successfully", name: invite_name)
|
||||
socket |> put_flash(:info, prompt) |> push_navigate(to: return_to)
|
||||
|
||||
{:error, %Changeset{} = changeset} ->
|
||||
|
@ -11,19 +11,23 @@
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
>
|
||||
<%= if @changeset.action && not @changeset.valid? do %>
|
||||
<div class="invalid-feedback col-span-3 text-center">
|
||||
<%= changeset_errors(@changeset) %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div
|
||||
:if={@changeset.action && not @changeset.valid?()}
|
||||
class="invalid-feedback col-span-3 text-center"
|
||||
>
|
||||
<%= changeset_errors(@changeset) %>
|
||||
</div>
|
||||
|
||||
<%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-400") %>
|
||||
<%= label(f, :name, gettext("name"), class: "title text-lg text-primary-400") %>
|
||||
<%= text_input(f, :name, class: "input input-primary col-span-2") %>
|
||||
<%= error_tag(f, :name, "col-span-3") %>
|
||||
|
||||
<%= label(f, :uses_left, gettext("Uses left"), class: "title text-lg text-primary-400") %>
|
||||
<%= label(f, :uses_left, gettext("uses left"), class: "title text-lg text-primary-400") %>
|
||||
<%= number_input(f, :uses_left, min: 0, class: "input input-primary col-span-2") %>
|
||||
<%= error_tag(f, :uses_left, "col-span-3") %>
|
||||
<span class="col-span-3 text-primary-500 italic text-center">
|
||||
<%= gettext(~s/Leave "Uses left" blank to make invite unlimited/) %>
|
||||
</span>
|
||||
|
||||
<%= submit(dgettext("actions", "Save"),
|
||||
class: "mx-auto btn btn-primary col-span-3",
|
||||
|
@ -1,12 +1,13 @@
|
||||
defmodule MemexWeb.InviteLive.Index do
|
||||
@moduledoc """
|
||||
Liveview to show a Memex.Invites.Invite index
|
||||
Liveview to show a Memex.Accounts.Invite index
|
||||
"""
|
||||
|
||||
use MemexWeb, :live_view
|
||||
import MemexWeb.Components.{InviteCard, UserCard}
|
||||
alias Memex.{Accounts, Invites, Invites.Invite}
|
||||
alias MemexWeb.{Endpoint, HomeLive}
|
||||
alias Memex.Accounts
|
||||
alias Memex.Accounts.{Invite, Invites}
|
||||
alias MemexWeb.HomeLive
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
@impl true
|
||||
@ -15,9 +16,9 @@ defmodule MemexWeb.InviteLive.Index do
|
||||
if current_user |> Map.get(:role) == :admin do
|
||||
socket |> display_invites()
|
||||
else
|
||||
prompt = dgettext("errors", "You are not authorized to view this page")
|
||||
prompt = dgettext("errors", "you are not authorized to view this page")
|
||||
return_to = Routes.live_path(Endpoint, HomeLive)
|
||||
socket |> put_flash(:error, prompt) |> push_navigate(to: return_to)
|
||||
socket |> put_flash(:error, prompt) |> push_redirect(to: return_to)
|
||||
end
|
||||
|
||||
{:ok, socket}
|
||||
@ -61,7 +62,7 @@ defmodule MemexWeb.InviteLive.Index do
|
||||
) do
|
||||
socket =
|
||||
Invites.get_invite!(id, current_user)
|
||||
|> Invites.update_invite(%{"uses_left" => nil}, current_user)
|
||||
|> Invites.update_invite(%{uses_left: nil}, current_user)
|
||||
|> case do
|
||||
{:ok, %{name: invite_name}} ->
|
||||
prompt =
|
||||
@ -83,7 +84,7 @@ defmodule MemexWeb.InviteLive.Index do
|
||||
) do
|
||||
socket =
|
||||
Invites.get_invite!(id, current_user)
|
||||
|> Invites.update_invite(%{"uses_left" => nil, "disabled_at" => nil}, current_user)
|
||||
|> Invites.update_invite(%{uses_left: nil, disabled_at: nil}, current_user)
|
||||
|> case do
|
||||
{:ok, %{name: invite_name}} ->
|
||||
prompt =
|
||||
@ -107,7 +108,7 @@ defmodule MemexWeb.InviteLive.Index do
|
||||
|
||||
socket =
|
||||
Invites.get_invite!(id, current_user)
|
||||
|> Invites.update_invite(%{"uses_left" => 0, "disabled_at" => now}, current_user)
|
||||
|> Invites.update_invite(%{uses_left: 0, disabled_at: now}, current_user)
|
||||
|> case do
|
||||
{:ok, %{name: invite_name}} ->
|
||||
prompt =
|
||||
@ -123,8 +124,8 @@ defmodule MemexWeb.InviteLive.Index do
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("copy_to_clipboard", _, socket) do
|
||||
prompt = dgettext("prompts", "Copied to clipboard")
|
||||
def handle_event("copy_to_clipboard", _params, socket) do
|
||||
prompt = dgettext("prompts", "copied to clipboard")
|
||||
{:noreply, socket |> put_flash(:info, prompt)}
|
||||
end
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
<div class="w-full flex flex-col space-y-8 justify-center items-center">
|
||||
<h1 class="title text-2xl title-primary-500">
|
||||
<div class="mx-auto flex flex-col justify-center items-stretch space-y-4 max-w-3xl">
|
||||
<h1 class="title text-xl title-primary-500">
|
||||
<%= gettext("invites") %>
|
||||
</h1>
|
||||
|
||||
@ -11,149 +11,143 @@
|
||||
<.link patch={Routes.invite_index_path(Endpoint, :new)} class="btn btn-primary">
|
||||
<%= dgettext("actions", "invite someone new!") %>
|
||||
</.link>
|
||||
<% else %>
|
||||
<.link patch={Routes.invite_index_path(Endpoint, :new)} class="btn btn-primary">
|
||||
<%= dgettext("actions", "create invite") %>
|
||||
</.link>
|
||||
<% end %>
|
||||
|
||||
<div class="w-full flex flex-row flex-wrap justify-center items-center">
|
||||
<%= for invite <- @invites do %>
|
||||
<.invite_card invite={invite}>
|
||||
<:code_actions>
|
||||
<form phx-submit="copy_to_clipboard">
|
||||
<button
|
||||
type="submit"
|
||||
class="mx-2 my-1 btn btn-primary"
|
||||
phx-click={JS.dispatch("memex:clipcopy", to: "#code-#{invite.id}")}
|
||||
>
|
||||
<%= dgettext("actions", "Copy to clipboard") %>
|
||||
</button>
|
||||
</form>
|
||||
</:code_actions>
|
||||
<.link
|
||||
patch={Routes.invite_index_path(Endpoint, :edit, invite)}
|
||||
class="text-primary-400 link"
|
||||
data-qa={"edit-#{invite.id}"}
|
||||
>
|
||||
<i class="fa-fw fa-lg fas fa-edit"></i>
|
||||
</.link>
|
||||
|
||||
<.link
|
||||
href="#"
|
||||
class="text-primary-400 link"
|
||||
phx-click="delete_invite"
|
||||
phx-value-id={invite.id}
|
||||
data-confirm={
|
||||
dgettext("prompts", "are you sure you want to delete the invite for %{invite_name}?",
|
||||
invite_name: invite.name
|
||||
)
|
||||
}
|
||||
data-qa={"delete-#{invite.id}"}
|
||||
>
|
||||
<i class="fa-fw fa-lg fas fa-trash"></i>
|
||||
</.link>
|
||||
|
||||
<%= if invite.disabled_at |> is_nil() do %>
|
||||
<a href="#" class="btn btn-primary" phx-click="disable_invite" phx-value-id={invite.id}>
|
||||
<%= gettext("disable") %>
|
||||
</a>
|
||||
<% else %>
|
||||
<a href="#" class="btn btn-primary" phx-click="enable_invite" phx-value-id={invite.id}>
|
||||
<%= gettext("enable") %>
|
||||
</a>
|
||||
<% end %>
|
||||
|
||||
<%= if invite.disabled_at |> is_nil() and not (invite.uses_left |> is_nil()) do %>
|
||||
<a
|
||||
href="#"
|
||||
class="btn btn-primary"
|
||||
phx-click="set_unlimited"
|
||||
phx-value-id={invite.id}
|
||||
data-confirm={
|
||||
dgettext("prompts", "are you sure you want to make %{invite_name} unlimited?",
|
||||
invite_name: invite.name
|
||||
)
|
||||
}
|
||||
<div class="flex flex-col justify-center items-stretch space-y-4">
|
||||
<.invite_card :for={invite <- @invites} invite={invite} current_user={@current_user}>
|
||||
<:code_actions>
|
||||
<form phx-submit="copy_to_clipboard">
|
||||
<button
|
||||
type="submit"
|
||||
class="mx-2 my-1 btn btn-secondary"
|
||||
phx-click={JS.dispatch("memex:clipcopy", to: "#code-#{invite.id}")}
|
||||
>
|
||||
<%= gettext("set unlimited") %>
|
||||
</a>
|
||||
<% end %>
|
||||
</.invite_card>
|
||||
<% end %>
|
||||
<%= dgettext("actions", "copy") %>
|
||||
</button>
|
||||
</form>
|
||||
</:code_actions>
|
||||
<.link
|
||||
patch={Routes.invite_index_path(Endpoint, :edit, invite)}
|
||||
class="text-primary-400 link"
|
||||
data-qa={"edit-#{invite.id}"}
|
||||
>
|
||||
<i class="fa-fw fa-lg fas fa-edit"></i>
|
||||
</.link>
|
||||
|
||||
<.link
|
||||
href="#"
|
||||
class="text-primary-400 link"
|
||||
phx-click="delete_invite"
|
||||
phx-value-id={invite.id}
|
||||
data-confirm={
|
||||
dgettext("prompts", "are you sure you want to delete the invite for %{invite_name}?",
|
||||
invite_name: invite.name
|
||||
)
|
||||
}
|
||||
data-qa={"delete-#{invite.id}"}
|
||||
>
|
||||
<i class="fa-fw fa-lg fas fa-trash"></i>
|
||||
</.link>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
class="btn btn-secondary"
|
||||
phx-click={if invite.disabled_at, do: "enable_invite", else: "disable_invite"}
|
||||
phx-value-id={invite.id}
|
||||
>
|
||||
<%= if invite.disabled_at, do: gettext("enable"), else: gettext("disable") %>
|
||||
</a>
|
||||
|
||||
<a
|
||||
:if={invite.disabled_at |> is_nil() and not (invite.uses_left |> is_nil())}
|
||||
href="#"
|
||||
class="btn btn-secondary"
|
||||
phx-click="set_unlimited"
|
||||
phx-value-id={invite.id}
|
||||
data-confirm={
|
||||
dgettext("prompts", "are you sure you want to make %{invite_name} unlimited?",
|
||||
invite_name: invite.name
|
||||
)
|
||||
}
|
||||
>
|
||||
<%= gettext("set unlimited") %>
|
||||
</a>
|
||||
</.invite_card>
|
||||
|
||||
<.link
|
||||
:if={@invites != []}
|
||||
patch={Routes.invite_index_path(Endpoint, :new)}
|
||||
class="btn btn-primary ml-auto"
|
||||
>
|
||||
<%= dgettext("actions", "create invite") %>
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<%= unless @admins |> Enum.empty?() do %>
|
||||
<hr class="hr" />
|
||||
|
||||
<h1 class="title text-2xl text-primary-400">
|
||||
<%= gettext("Admins") %>
|
||||
<h1 class="title text-xl text-primary-400">
|
||||
<%= gettext("admins") %>
|
||||
</h1>
|
||||
|
||||
<div class="w-full flex flex-row flex-wrap justify-center items-center">
|
||||
<%= for admin <- @admins do %>
|
||||
<.user_card user={admin}>
|
||||
<.link
|
||||
href="#"
|
||||
class="text-primary-400 link"
|
||||
phx-click="delete_user"
|
||||
phx-value-id={admin.id}
|
||||
data-confirm={
|
||||
dgettext(
|
||||
"prompts",
|
||||
"are you sure you want to delete %{email}? This action is permanent!",
|
||||
email: admin.email
|
||||
)
|
||||
}
|
||||
>
|
||||
<i class="fa-fw fa-lg fas fa-trash"></i>
|
||||
</.link>
|
||||
</.user_card>
|
||||
<% end %>
|
||||
<div class="flex flex-col justify-center items-stretch space-y-4">
|
||||
<.user_card :for={admin <- @admins} user={admin}>
|
||||
<.link
|
||||
href="#"
|
||||
class="text-primary-400 link"
|
||||
phx-click="delete_user"
|
||||
phx-value-id={admin.id}
|
||||
data-confirm={
|
||||
dgettext(
|
||||
"prompts",
|
||||
"are you sure you want to delete %{email}? this action is permanent!",
|
||||
email: admin.email
|
||||
)
|
||||
}
|
||||
>
|
||||
<i class="fa-fw fa-lg fas fa-trash"></i>
|
||||
</.link>
|
||||
</.user_card>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= unless @users |> Enum.empty?() do %>
|
||||
<hr class="hr" />
|
||||
|
||||
<h1 class="title text-2xl text-primary-400">
|
||||
<h1 class="title text-xl text-primary-400">
|
||||
<%= gettext("users") %>
|
||||
</h1>
|
||||
|
||||
<div class="w-full flex flex-row flex-wrap justify-center items-center">
|
||||
<%= for user <- @users do %>
|
||||
<.user_card user={user}>
|
||||
<.link
|
||||
href="#"
|
||||
class="text-primary-400 link"
|
||||
phx-click="delete_user"
|
||||
phx-value-id={user.id}
|
||||
data-confirm={
|
||||
dgettext(
|
||||
"prompts",
|
||||
"are you sure you want to delete %{email}? This action is permanent!",
|
||||
email: user.email
|
||||
)
|
||||
}
|
||||
>
|
||||
<i class="fa-fw fa-lg fas fa-trash"></i>
|
||||
</.link>
|
||||
</.user_card>
|
||||
<% end %>
|
||||
<div class="flex flex-col justify-center items-stretch space-y-4">
|
||||
<.user_card :for={user <- @users} user={user}>
|
||||
<.link
|
||||
href="#"
|
||||
class="text-primary-400 link"
|
||||
phx-click="delete_user"
|
||||
phx-value-id={user.id}
|
||||
data-confirm={
|
||||
dgettext(
|
||||
"prompts",
|
||||
"are you sure you want to delete %{email}? this action is permanent!",
|
||||
email: user.email
|
||||
)
|
||||
}
|
||||
>
|
||||
<i class="fa-fw fa-lg fas fa-trash"></i>
|
||||
</.link>
|
||||
</.user_card>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if @live_action in [:new, :edit] do %>
|
||||
<.modal return_to={Routes.invite_index_path(Endpoint, :index)}>
|
||||
<.live_component
|
||||
module={MemexWeb.InviteLive.FormComponent}
|
||||
id={@invite.id || :new}
|
||||
title={@page_title}
|
||||
action={@live_action}
|
||||
invite={@invite}
|
||||
return_to={Routes.invite_index_path(Endpoint, :index)}
|
||||
current_user={@current_user}
|
||||
/>
|
||||
</.modal>
|
||||
<% end %>
|
||||
<.modal :if={@live_action in [:new, :edit]} return_to={Routes.invite_index_path(Endpoint, :index)}>
|
||||
<.live_component
|
||||
module={MemexWeb.InviteLive.FormComponent}
|
||||
id={@invite.id || :new}
|
||||
title={@page_title}
|
||||
action={@live_action}
|
||||
invite={@invite}
|
||||
return_to={Routes.invite_index_path(Endpoint, :index)}
|
||||
current_user={@current_user}
|
||||
/>
|
||||
</.modal>
|
||||
|
@ -1,6 +1,6 @@
|
||||
defmodule MemexWeb.LiveHelpers do
|
||||
@moduledoc """
|
||||
Contains resuable methods for all liveviews
|
||||
Contains common helper functions for liveviews
|
||||
"""
|
||||
|
||||
import Phoenix.Component
|
||||
@ -28,11 +28,11 @@ defmodule MemexWeb.LiveHelpers do
|
||||
def modal(assigns) do
|
||||
~H"""
|
||||
<.link
|
||||
patch={@return_to}
|
||||
id="modal-bg"
|
||||
patch={@return_to}
|
||||
class="fade-in fixed z-10 left-0 top-0
|
||||
w-screen h-screen overflow-hidden
|
||||
p-8 flex flex-col justify-center items-center cursor-auto"
|
||||
w-full h-full overflow-hidden
|
||||
p-8 flex flex-col justify-center items-center cursor-auto"
|
||||
style="background-color: rgba(0,0,0,0.4);"
|
||||
phx-remove={hide_modal()}
|
||||
>
|
||||
@ -42,28 +42,28 @@ defmodule MemexWeb.LiveHelpers do
|
||||
<div
|
||||
id="modal"
|
||||
class="fixed z-10 left-0 top-0 pointer-events-none
|
||||
w-screen h-screen overflow-hidden
|
||||
w-full h-full overflow-hidden
|
||||
p-4 sm:p-8 flex flex-col justify-center items-center"
|
||||
>
|
||||
<div
|
||||
id="modal-content"
|
||||
class="fade-in-scale max-w-3xl max-h-3xl relative w-full
|
||||
pointer-events-auto overflow-hidden
|
||||
px-8 py-4 sm:py-8 flex flex-col justify-start items-stretch
|
||||
bg-primary-800 text-primary-400 border-primary-900 border-2 rounded-lg"
|
||||
pointer-events-auto overflow-hidden
|
||||
px-8 py-4 sm:py-8 flex flex-col justify-start items-stretch
|
||||
bg-primary-800 text-primary-400 border-primary-900 border-2 rounded-lg"
|
||||
>
|
||||
<.link
|
||||
patch={@return_to}
|
||||
id="close"
|
||||
class="absolute top-8 right-10
|
||||
text-gray-500 hover:text-gray-800
|
||||
transition-all duration-500 ease-in-out"
|
||||
text-gray-500 hover:text-gray-800
|
||||
transition-all duration-500 ease-in-out"
|
||||
phx-remove={hide_modal()}
|
||||
>
|
||||
<i class="fa-fw fa-lg fas fa-times"></i>
|
||||
</.link>
|
||||
|
||||
<div class="overflow-x-hidden overflow-y-visible p-8 flex flex-col space-y-4 justify-start items-stretch">
|
||||
<div class="overflow-x-hidden overflow-y-auto w-full p-8 flex flex-col space-y-4 justify-start items-stretch">
|
||||
<%= render_slot(@inner_block) %>
|
||||
</div>
|
||||
</div>
|
||||
@ -71,10 +71,57 @@ defmodule MemexWeb.LiveHelpers do
|
||||
"""
|
||||
end
|
||||
|
||||
def hide_modal(js \\ %JS{}) do
|
||||
defp hide_modal(js \\ %JS{}) do
|
||||
js
|
||||
|> JS.hide(to: "#modal", transition: "fade-out")
|
||||
|> JS.hide(to: "#modal-bg", transition: "fade-out")
|
||||
|> JS.hide(to: "#modal-content", transition: "fade-out-scale")
|
||||
end
|
||||
|
||||
@doc """
|
||||
A toggle button element that can be directed to a liveview or a
|
||||
live_component's `handle_event/3`.
|
||||
|
||||
## Examples
|
||||
|
||||
<.toggle_button action="my_liveview_action" value={@some_value}>
|
||||
<span>Toggle me!</span>
|
||||
</.toggle_button>
|
||||
<.toggle_button action="my_live_component_action" target={@myself} value={@some_value}>
|
||||
<span>Whatever you want</span>
|
||||
</.toggle_button>
|
||||
"""
|
||||
def toggle_button(assigns) do
|
||||
assigns = assigns |> assign_new(:id, fn -> assigns.action end)
|
||||
|
||||
~H"""
|
||||
<label for={@id} class="inline-flex relative items-center cursor-pointer">
|
||||
<input
|
||||
id={@id}
|
||||
type="checkbox"
|
||||
value={@value}
|
||||
checked={@value}
|
||||
class="sr-only peer"
|
||||
data-qa={@id}
|
||||
{
|
||||
if assigns |> Map.has_key?(:target),
|
||||
do: %{"phx-click": @action, "phx-value-value": @value, "phx-target": @target},
|
||||
else: %{"phx-click": @action, "phx-value-value": @value}
|
||||
}
|
||||
/>
|
||||
<div class="w-11 h-6 bg-gray-300 rounded-full peer
|
||||
peer-focus:ring-4 peer-focus:ring-teal-300 dark:peer-focus:ring-teal-800
|
||||
peer-checked:bg-gray-600
|
||||
peer-checked:after:translate-x-full peer-checked:after:border-white
|
||||
after:content-[''] after:absolute after:top-1 after:left-[2px] after:bg-white after:border-gray-300
|
||||
after:border after:rounded-full after:h-5 after:w-5
|
||||
after:transition-all after:duration-250 after:ease-in-out
|
||||
transition-colors duration-250 ease-in-out">
|
||||
</div>
|
||||
<span class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
<%= render_slot(@inner_block) %>
|
||||
</span>
|
||||
</label>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
@ -4,8 +4,8 @@ defmodule MemexWeb.NoteLive.FormComponent do
|
||||
alias Memex.Notes
|
||||
|
||||
@impl true
|
||||
def update(%{note: note} = assigns, socket) do
|
||||
changeset = Notes.change_note(note)
|
||||
def update(%{note: note, current_user: current_user} = assigns, socket) do
|
||||
changeset = Notes.change_note(note, current_user)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
@ -14,39 +14,51 @@ defmodule MemexWeb.NoteLive.FormComponent do
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"note" => note_params}, socket) do
|
||||
def handle_event(
|
||||
"validate",
|
||||
%{"note" => note_params},
|
||||
%{assigns: %{note: note, current_user: current_user}} = socket
|
||||
) do
|
||||
changeset =
|
||||
socket.assigns.note
|
||||
|> Notes.change_note(note_params)
|
||||
note
|
||||
|> Notes.change_note(note_params, current_user)
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
{:noreply, assign(socket, :changeset, changeset)}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"note" => note_params}, socket) do
|
||||
save_note(socket, socket.assigns.action, note_params)
|
||||
def handle_event("save", %{"note" => note_params}, %{assigns: %{action: action}} = socket) do
|
||||
save_note(socket, action, note_params)
|
||||
end
|
||||
|
||||
defp save_note(socket, :edit, note_params) do
|
||||
case Notes.update_note(socket.assigns.note, note_params) do
|
||||
{:ok, _note} ->
|
||||
defp save_note(
|
||||
%{assigns: %{note: note, return_to: return_to, current_user: current_user}} = socket,
|
||||
:edit,
|
||||
note_params
|
||||
) do
|
||||
case Notes.update_note(note, note_params, current_user) do
|
||||
{:ok, %{slug: slug}} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, gettext("%{title} saved", title: title))
|
||||
|> push_navigate(to: socket.assigns.return_to)}
|
||||
|> put_flash(:info, gettext("%{slug} saved", slug: slug))
|
||||
|> push_navigate(to: return_to)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :changeset, changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_note(socket, :new, note_params) do
|
||||
case Notes.create_note(note_params) do
|
||||
{:ok, _note} ->
|
||||
defp save_note(
|
||||
%{assigns: %{return_to: return_to, current_user: current_user}} = socket,
|
||||
:new,
|
||||
note_params
|
||||
) do
|
||||
case Notes.create_note(note_params, current_user) do
|
||||
{:ok, %{slug: slug}} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, gettext("%{title} created", title: title))
|
||||
|> push_navigate(to: socket.assigns.return_to)}
|
||||
|> put_flash(:info, gettext("%{slug} created", slug: slug))
|
||||
|> push_navigate(to: return_to)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, changeset: changeset)}
|
||||
|
@ -1,6 +1,4 @@
|
||||
<div>
|
||||
<h2><%= @title %></h2>
|
||||
|
||||
<div class="h-full flex flex-col justify-start items-stretch space-y-4">
|
||||
<.form
|
||||
:let={f}
|
||||
for={@changeset}
|
||||
@ -8,27 +6,42 @@
|
||||
phx-target={@myself}
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
phx-debounce="300"
|
||||
class="flex flex-col justify-start items-stretch space-y-4"
|
||||
>
|
||||
<%= label(f, :title) %>
|
||||
<%= text_input(f, :title) %>
|
||||
<%= error_tag(f, :title) %>
|
||||
<%= text_input(f, :slug,
|
||||
class: "input input-primary",
|
||||
placeholder: gettext("slug")
|
||||
) %>
|
||||
<%= error_tag(f, :slug) %>
|
||||
|
||||
<%= label(f, :content) %>
|
||||
<%= textarea(f, :content) %>
|
||||
<%= textarea(f, :content,
|
||||
id: "note-form-content",
|
||||
class: "input input-primary h-64 min-h-64",
|
||||
phx_hook: "MaintainAttrs",
|
||||
phx_update: "ignore",
|
||||
placeholder: gettext("content")
|
||||
) %>
|
||||
<%= error_tag(f, :content) %>
|
||||
|
||||
<%= label(f, :tag) %>
|
||||
<%= multiple_select(f, :tag, "Option 1": "option1", "Option 2": "option2") %>
|
||||
<%= error_tag(f, :tag) %>
|
||||
|
||||
<%= label(f, :visibility) %>
|
||||
<%= select(f, :visibility, Ecto.Enum.values(Memex.Notes.Note, :visibility),
|
||||
prompt: "Choose a value"
|
||||
<%= text_input(f, :tags_string,
|
||||
id: "tags-input",
|
||||
class: "input input-primary",
|
||||
placeholder: gettext("tag1,tag2")
|
||||
) %>
|
||||
<%= error_tag(f, :visibility) %>
|
||||
<%= error_tag(f, :tags_string) %>
|
||||
|
||||
<div>
|
||||
<%= submit("Save", phx_disable_with: "Saving...") %>
|
||||
<div class="flex justify-center items-stretch space-x-4">
|
||||
<%= select(f, :visibility, Ecto.Enum.values(Memex.Notes.Note, :visibility),
|
||||
class: "grow input input-primary",
|
||||
prompt: gettext("select privacy")
|
||||
) %>
|
||||
|
||||
<%= submit(dgettext("actions", "save"),
|
||||
phx_disable_with: gettext("saving..."),
|
||||
class: "mx-auto btn btn-primary"
|
||||
) %>
|
||||
</div>
|
||||
<%= error_tag(f, :visibility) %>
|
||||
</.form>
|
||||
</div>
|
||||
|
@ -1,45 +1,88 @@
|
||||
defmodule MemexWeb.NoteLive.Index do
|
||||
use MemexWeb, :live_view
|
||||
|
||||
alias Memex.Notes
|
||||
alias Memex.Notes.Note
|
||||
alias Memex.{Accounts.User, Notes, Notes.Note}
|
||||
|
||||
@impl true
|
||||
def mount(%{"search" => search}, _session, socket) do
|
||||
{:ok, socket |> assign(search: search) |> display_notes()}
|
||||
end
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, assign(socket, :notes, list_notes())}
|
||||
{:ok, socket |> assign(search: nil) |> display_notes()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
||||
def handle_params(params, _url, %{assigns: %{live_action: live_action}} = socket) do
|
||||
{:noreply, apply_action(socket, live_action, params)}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||
defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"slug" => slug}) do
|
||||
%{slug: slug} = note = Notes.get_note_by_slug(slug, current_user)
|
||||
|
||||
socket
|
||||
|> assign(page_title: gettext("edit %{title}", title: title))
|
||||
|> assign(:note, Notes.get_note!(id))
|
||||
|> assign(page_title: gettext("edit %{slug}", slug: slug))
|
||||
|> assign(note: note)
|
||||
end
|
||||
|
||||
defp apply_action(socket, :new, _params) do
|
||||
defp apply_action(%{assigns: %{current_user: %{id: current_user_id}}} = socket, :new, _params) do
|
||||
socket
|
||||
|> assign(page_title: "new note")
|
||||
|> assign(:note, %Note{})
|
||||
|> assign(page_title: gettext("new note"))
|
||||
|> assign(note: %Note{visibility: :private, user_id: current_user_id})
|
||||
end
|
||||
|
||||
defp apply_action(socket, :index, _params) do
|
||||
socket
|
||||
|> assign(page_title: "notes")
|
||||
|> assign(:note, nil)
|
||||
|> assign(page_title: gettext("notes"))
|
||||
|> assign(search: nil)
|
||||
|> assign(note: nil)
|
||||
|> display_notes()
|
||||
end
|
||||
|
||||
defp apply_action(socket, :search, %{"search" => search}) do
|
||||
socket
|
||||
|> assign(page_title: gettext("notes"))
|
||||
|> assign(search: search)
|
||||
|> assign(note: nil)
|
||||
|> display_notes()
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
note = Notes.get_note!(id)
|
||||
{:ok, _} = Notes.delete_note(note)
|
||||
def handle_event("delete", %{"id" => id}, %{assigns: %{current_user: current_user}} = socket) do
|
||||
note = Notes.get_note!(id, current_user)
|
||||
{:ok, %{slug: slug}} = Notes.delete_note(note, current_user)
|
||||
|
||||
|> put_flash(:info, gettext("%{title} deleted", title: title))
|
||||
socket =
|
||||
socket
|
||||
|> assign(notes: Notes.list_notes(current_user))
|
||||
|> put_flash(:info, gettext("%{slug} deleted", slug: slug))
|
||||
|
||||
defp list_notes do
|
||||
Notes.list_notes()
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do
|
||||
{:noreply, socket |> push_patch(to: Routes.note_index_path(Endpoint, :index))}
|
||||
end
|
||||
|
||||
def handle_event("search", %{"search" => %{"search_term" => search_term}}, socket) do
|
||||
{:noreply, socket |> push_patch(to: Routes.note_index_path(Endpoint, :search, search_term))}
|
||||
end
|
||||
|
||||
defp display_notes(%{assigns: %{current_user: current_user, search: search}} = socket)
|
||||
when not (current_user |> is_nil()) do
|
||||
socket |> assign(notes: Notes.list_notes(search, current_user))
|
||||
end
|
||||
|
||||
defp display_notes(%{assigns: %{search: search}} = socket) do
|
||||
socket |> assign(notes: Notes.list_public_notes(search))
|
||||
end
|
||||
|
||||
@spec is_owner_or_admin?(Note.t(), User.t()) :: boolean()
|
||||
defp is_owner_or_admin?(%{user_id: user_id}, %{id: user_id}), do: true
|
||||
defp is_owner_or_admin?(_context, %{role: :admin}), do: true
|
||||
defp is_owner_or_admin?(_context, _other_user), do: false
|
||||
|
||||
@spec is_owner?(Note.t(), User.t()) :: boolean()
|
||||
defp is_owner?(%{user_id: user_id}, %{id: user_id}), do: true
|
||||
defp is_owner?(_context, _other_user), do: false
|
||||
end
|
||||
|
@ -1,66 +1,73 @@
|
||||
<h1>Listing Notes</h1>
|
||||
<div class="mx-auto flex flex-col justify-center items-start space-y-4 max-w-3xl">
|
||||
<h1 class="text-xl">
|
||||
<%= gettext("notes") %>
|
||||
</h1>
|
||||
|
||||
<%= if @live_action in [:new, :edit] do %>
|
||||
<.modal return_to={Routes.note_index_path(@socket, :index)}>
|
||||
<.form
|
||||
:let={f}
|
||||
for={:search}
|
||||
phx-change="search"
|
||||
phx-submit="search"
|
||||
class="self-stretch flex flex-col items-stretch"
|
||||
>
|
||||
<%= text_input(f, :search_term,
|
||||
class: "input input-primary",
|
||||
value: @search,
|
||||
phx_debounce: 300,
|
||||
placeholder: gettext("search")
|
||||
) %>
|
||||
</.form>
|
||||
|
||||
<%= if @notes |> Enum.empty?() do %>
|
||||
<h1 class="self-center text-primary-500">
|
||||
<%= gettext("no notes found") %>
|
||||
</h1>
|
||||
<% else %>
|
||||
<.live_component
|
||||
module={MemexWeb.NoteLive.FormComponent}
|
||||
id={@note.id || :new}
|
||||
title={@page_title}
|
||||
action={@live_action}
|
||||
note={@note}
|
||||
return_to={Routes.note_index_path(@socket, :index)}
|
||||
/>
|
||||
</.modal>
|
||||
<% end %>
|
||||
module={MemexWeb.Components.NotesTableComponent}
|
||||
id="notes-index-table"
|
||||
current_user={@current_user}
|
||||
notes={@notes}
|
||||
>
|
||||
<:actions :let={note}>
|
||||
<.link
|
||||
:if={is_owner?(note, @current_user)}
|
||||
patch={Routes.note_index_path(@socket, :edit, note.slug)}
|
||||
data-qa={"note-edit-#{note.id}"}
|
||||
>
|
||||
<%= dgettext("actions", "edit") %>
|
||||
</.link>
|
||||
<.link
|
||||
:if={is_owner_or_admin?(note, @current_user)}
|
||||
href="#"
|
||||
phx-click="delete"
|
||||
phx-value-id={note.id}
|
||||
data-confirm={dgettext("prompts", "are you sure?")}
|
||||
data-qa={"delete-note-#{note.id}"}
|
||||
>
|
||||
<%= dgettext("actions", "delete") %>
|
||||
</.link>
|
||||
</:actions>
|
||||
</.live_component>
|
||||
<% end %>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Content</th>
|
||||
<th>Tag</th>
|
||||
<th>Visibility</th>
|
||||
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="notes">
|
||||
<%= for note <- @notes do %>
|
||||
<tr id={"note-#{note.id}"}>
|
||||
<td><%= note.title %></td>
|
||||
<td><%= note.content %></td>
|
||||
<td><%= note.tag %></td>
|
||||
<td><%= note.visibility %></td>
|
||||
|
||||
<td>
|
||||
<span>
|
||||
<.link navigate={Routes.note_show_path(@socket, :show, note)}>
|
||||
<%= dgettext("actions", "Show") %>
|
||||
</.link>
|
||||
</span>
|
||||
<span>
|
||||
<.link patch={Routes.note_index_path(@socket, :edit, note)}>
|
||||
<%= dgettext("actions", "Edit") %>
|
||||
</.link>
|
||||
</span>
|
||||
<span>
|
||||
<.link
|
||||
href="#"
|
||||
phx-click="delete"
|
||||
phx-value-id={note.id}
|
||||
data-confirm={dgettext("prompts", "Are you sure?")}
|
||||
>
|
||||
<%= dgettext("actions", "Delete") %>
|
||||
</.link>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<span>
|
||||
<.link patch={Routes.note_index_path(@socket, :new)}>
|
||||
<%= dgettext("actions", "New Note") %>
|
||||
<.link
|
||||
:if={@current_user}
|
||||
patch={Routes.note_index_path(@socket, :new)}
|
||||
class="self-end btn btn-primary"
|
||||
>
|
||||
<%= dgettext("actions", "new note") %>
|
||||
</.link>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<.modal :if={@live_action in [:new, :edit]} return_to={Routes.note_index_path(@socket, :index)}>
|
||||
<.live_component
|
||||
module={MemexWeb.NoteLive.FormComponent}
|
||||
id={@note.id || :new}
|
||||
current_user={@current_user}
|
||||
title={@page_title}
|
||||
action={@live_action}
|
||||
note={@note}
|
||||
return_to={Routes.note_index_path(@socket, :index)}
|
||||
/>
|
||||
</.modal>
|
||||
|
@ -1,7 +1,7 @@
|
||||
defmodule MemexWeb.NoteLive.Show do
|
||||
use MemexWeb, :live_view
|
||||
|
||||
alias Memex.Notes
|
||||
import MemexWeb.Components.NoteContent
|
||||
alias Memex.{Accounts.User, Notes, Notes.Note}
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
@ -9,13 +9,50 @@ defmodule MemexWeb.NoteLive.Show do
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(%{"id" => id}, _, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, page_title(socket.assigns.live_action))
|
||||
|> assign(:note, Notes.get_note!(id))}
|
||||
def handle_params(
|
||||
%{"slug" => slug},
|
||||
_params,
|
||||
%{assigns: %{live_action: live_action, current_user: current_user}} = socket
|
||||
) do
|
||||
note =
|
||||
case Notes.get_note_by_slug(slug, current_user) do
|
||||
nil -> raise MemexWeb.NotFoundError, gettext("%{slug} could not be found", slug: slug)
|
||||
note -> note
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, page_title(live_action, note))
|
||||
|> assign(:note, note)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp page_title(:show), do: "show note"
|
||||
defp page_title(:edit), do: "edit note"
|
||||
@impl true
|
||||
def handle_event(
|
||||
"delete",
|
||||
_params,
|
||||
%{assigns: %{note: note, current_user: current_user}} = socket
|
||||
) do
|
||||
{:ok, %{slug: slug}} = Notes.delete_note(note, current_user)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> put_flash(:info, gettext("%{slug} deleted", slug: slug))
|
||||
|> push_navigate(to: Routes.note_index_path(Endpoint, :index))
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp page_title(:show, %{slug: slug}), do: slug
|
||||
defp page_title(:edit, %{slug: slug}), do: gettext("edit %{slug}", slug: slug)
|
||||
|
||||
@spec is_owner_or_admin?(Note.t(), User.t()) :: boolean()
|
||||
defp is_owner_or_admin?(%{user_id: user_id}, %{id: user_id}), do: true
|
||||
defp is_owner_or_admin?(_context, %{role: :admin}), do: true
|
||||
defp is_owner_or_admin?(_context, _other_user), do: false
|
||||
|
||||
@spec is_owner?(Note.t(), User.t()) :: boolean()
|
||||
defp is_owner?(%{user_id: user_id}, %{id: user_id}), do: true
|
||||
defp is_owner?(_context, _other_user), do: false
|
||||
end
|
||||
|
@ -1,48 +1,56 @@
|
||||
<h1>Show Note</h1>
|
||||
<div class="mx-auto flex flex-col justify-center items-stretch space-y-4 max-w-3xl">
|
||||
<h1 class="text-xl">
|
||||
<%= @note.slug %>
|
||||
</h1>
|
||||
|
||||
<%= if @live_action in [:edit] do %>
|
||||
<.modal return_to={Routes.note_show_path(@socket, :show, @note)}>
|
||||
<.live_component
|
||||
module={MemexWeb.NoteLive.FormComponent}
|
||||
id={@note.id}
|
||||
title={@page_title}
|
||||
action={@live_action}
|
||||
note={@note}
|
||||
return_to={Routes.note_show_path(@socket, :show, @note)}
|
||||
/>
|
||||
</.modal>
|
||||
<% end %>
|
||||
<div class="flex flex-wrap space-x-1">
|
||||
<.link
|
||||
:for={tag <- @note.tags}
|
||||
navigate={Routes.note_index_path(Endpoint, :search, tag)}
|
||||
class="link"
|
||||
>
|
||||
<%= tag %>
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Title:</strong>
|
||||
<%= @note.title %>
|
||||
</li>
|
||||
<.note_content note={@note} />
|
||||
|
||||
<li>
|
||||
<strong>Content:</strong>
|
||||
<%= @note.content %>
|
||||
</li>
|
||||
<p class="self-end">
|
||||
<%= gettext("Visibility: %{visibility}", visibility: @note.visibility) %>
|
||||
</p>
|
||||
|
||||
<li>
|
||||
<strong>Tag:</strong>
|
||||
<%= @note.tag %>
|
||||
</li>
|
||||
<div class="self-end flex space-x-4">
|
||||
<.link class="btn btn-primary" navigate={Routes.note_index_path(@socket, :index)}>
|
||||
<%= dgettext("actions", "back") %>
|
||||
</.link>
|
||||
<.link
|
||||
:if={is_owner?(@note, @current_user)}
|
||||
class="btn btn-primary"
|
||||
patch={Routes.note_show_path(@socket, :edit, @note.slug)}
|
||||
>
|
||||
<%= dgettext("actions", "edit") %>
|
||||
</.link>
|
||||
<button
|
||||
:if={is_owner_or_admin?(@note, @current_user)}
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
phx-click="delete"
|
||||
data-confirm={dgettext("prompts", "are you sure?")}
|
||||
data-qa={"delete-note-#{@note.id}"}
|
||||
>
|
||||
<%= dgettext("actions", "delete") %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<li>
|
||||
<strong>Visibility:</strong>
|
||||
<%= @note.visibility %>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<span>
|
||||
<.link patch={Routes.note_show_path(@socket, :edit, @note)} class="button">
|
||||
<%= dgettext("actions", "Edit") %>
|
||||
</.link>
|
||||
</span>
|
||||
|
|
||||
<span>
|
||||
<.link patch={Routes.note_index_path(@socket, :index)}>
|
||||
<%= dgettext("actions", "Back") %>
|
||||
</.link>
|
||||
</span>
|
||||
<.modal :if={@live_action == :edit} return_to={Routes.note_show_path(@socket, :show, @note.slug)}>
|
||||
<.live_component
|
||||
module={MemexWeb.NoteLive.FormComponent}
|
||||
id={@note.id}
|
||||
current_user={@current_user}
|
||||
title={@page_title}
|
||||
action={@live_action}
|
||||
note={@note}
|
||||
return_to={Routes.note_show_path(@socket, :show, @note.slug)}
|
||||
/>
|
||||
</.modal>
|
||||
|
@ -4,8 +4,8 @@ defmodule MemexWeb.PipelineLive.FormComponent do
|
||||
alias Memex.Pipelines
|
||||
|
||||
@impl true
|
||||
def update(%{pipeline: pipeline} = assigns, socket) do
|
||||
changeset = Pipelines.change_pipeline(pipeline)
|
||||
def update(%{pipeline: pipeline, current_user: current_user} = assigns, socket) do
|
||||
changeset = Pipelines.change_pipeline(pipeline, current_user)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
@ -14,39 +14,56 @@ defmodule MemexWeb.PipelineLive.FormComponent do
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"pipeline" => pipeline_params}, socket) do
|
||||
def handle_event(
|
||||
"validate",
|
||||
%{"pipeline" => pipeline_params},
|
||||
%{assigns: %{pipeline: pipeline, current_user: current_user}} = socket
|
||||
) do
|
||||
changeset =
|
||||
socket.assigns.pipeline
|
||||
|> Pipelines.change_pipeline(pipeline_params)
|
||||
pipeline
|
||||
|> Pipelines.change_pipeline(pipeline_params, current_user)
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
{:noreply, assign(socket, :changeset, changeset)}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"pipeline" => pipeline_params}, socket) do
|
||||
save_pipeline(socket, socket.assigns.action, pipeline_params)
|
||||
def handle_event(
|
||||
"save",
|
||||
%{"pipeline" => pipeline_params},
|
||||
%{assigns: %{action: action}} = socket
|
||||
) do
|
||||
save_pipeline(socket, action, pipeline_params)
|
||||
end
|
||||
|
||||
defp save_pipeline(socket, :edit, pipeline_params) do
|
||||
case Pipelines.update_pipeline(socket.assigns.pipeline, pipeline_params) do
|
||||
{:ok, _pipeline} ->
|
||||
defp save_pipeline(
|
||||
%{assigns: %{pipeline: pipeline, return_to: return_to, current_user: current_user}} =
|
||||
socket,
|
||||
:edit,
|
||||
pipeline_params
|
||||
) do
|
||||
case Pipelines.update_pipeline(pipeline, pipeline_params, current_user) do
|
||||
{:ok, %{slug: slug}} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "pipeline updated successfully")
|
||||
|> push_navigate(to: socket.assigns.return_to)}
|
||||
|> put_flash(:info, gettext("%{slug} saved", slug: slug))
|
||||
|> push_navigate(to: return_to)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :changeset, changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_pipeline(socket, :new, pipeline_params) do
|
||||
case Pipelines.create_pipeline(pipeline_params) do
|
||||
{:ok, _pipeline} ->
|
||||
defp save_pipeline(
|
||||
%{assigns: %{return_to: return_to, current_user: current_user}} = socket,
|
||||
:new,
|
||||
pipeline_params
|
||||
) do
|
||||
case Pipelines.create_pipeline(pipeline_params, current_user) do
|
||||
{:ok, %{slug: slug}} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "pipeline created successfully")
|
||||
|> push_navigate(to: socket.assigns.return_to)}
|
||||
|> put_flash(:info, gettext("%{slug} created", slug: slug))
|
||||
|> push_navigate(to: return_to)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, changeset: changeset)}
|
||||
|
@ -1,6 +1,4 @@
|
||||
<div>
|
||||
<h2><%= @title %></h2>
|
||||
|
||||
<div class="h-full flex flex-col justify-start items-stretch space-y-4">
|
||||
<.form
|
||||
:let={f}
|
||||
for={@changeset}
|
||||
@ -8,23 +6,42 @@
|
||||
phx-target={@myself}
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
phx-debounce="300"
|
||||
class="flex flex-col justify-start items-stretch space-y-4"
|
||||
>
|
||||
<%= label(f, :title) %>
|
||||
<%= text_input(f, :title) %>
|
||||
<%= error_tag(f, :title) %>
|
||||
<%= text_input(f, :slug,
|
||||
class: "input input-primary",
|
||||
placeholder: gettext("slug")
|
||||
) %>
|
||||
<%= error_tag(f, :slug) %>
|
||||
|
||||
<%= label(f, :description) %>
|
||||
<%= textarea(f, :description) %>
|
||||
<%= textarea(f, :description,
|
||||
id: "pipeline-form-description",
|
||||
class: "input input-primary h-64 min-h-64",
|
||||
phx_hook: "MaintainAttrs",
|
||||
phx_update: "ignore",
|
||||
placeholder: gettext("description")
|
||||
) %>
|
||||
<%= error_tag(f, :description) %>
|
||||
|
||||
<%= label(f, :visibility) %>
|
||||
<%= select(f, :visibility, Ecto.Enum.values(Memex.Pipelines.Pipeline, :visibility),
|
||||
prompt: "Choose a value"
|
||||
<%= text_input(f, :tags_string,
|
||||
id: "tags-input",
|
||||
class: "input input-primary",
|
||||
placeholder: gettext("tag1,tag2")
|
||||
) %>
|
||||
<%= error_tag(f, :visibility) %>
|
||||
<%= error_tag(f, :tags_string) %>
|
||||
|
||||
<div>
|
||||
<%= submit("Save", phx_disable_with: "Saving...") %>
|
||||
<div class="flex justify-center items-stretch space-x-4">
|
||||
<%= select(f, :visibility, Ecto.Enum.values(Memex.Pipelines.Pipeline, :visibility),
|
||||
class: "grow input input-primary",
|
||||
prompt: gettext("select privacy")
|
||||
) %>
|
||||
|
||||
<%= submit(dgettext("actions", "save"),
|
||||
phx_disable_with: gettext("saving..."),
|
||||
class: "mx-auto btn btn-primary"
|
||||
) %>
|
||||
</div>
|
||||
<%= error_tag(f, :visibility) %>
|
||||
</.form>
|
||||
</div>
|
||||
|
@ -1,46 +1,89 @@
|
||||
defmodule MemexWeb.PipelineLive.Index do
|
||||
use MemexWeb, :live_view
|
||||
|
||||
alias Memex.Pipelines
|
||||
alias Memex.Pipelines.Pipeline
|
||||
alias Memex.{Accounts.User, Pipelines, Pipelines.Pipeline}
|
||||
|
||||
@impl true
|
||||
def mount(%{"search" => search}, _session, socket) do
|
||||
{:ok, socket |> assign(search: search) |> display_pipelines()}
|
||||
end
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, assign(socket, :pipelines, list_pipelines())}
|
||||
{:ok, socket |> assign(search: nil) |> display_pipelines()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
||||
def handle_params(params, _url, %{assigns: %{live_action: live_action}} = socket) do
|
||||
{:noreply, apply_action(socket, live_action, params)}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||
defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"slug" => slug}) do
|
||||
%{slug: slug} = pipeline = Pipelines.get_pipeline_by_slug(slug, current_user)
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "edit pipeline")
|
||||
|> assign(:pipeline, Pipelines.get_pipeline!(id))
|
||||
|> assign(page_title: gettext("edit %{slug}", slug: slug))
|
||||
|> assign(pipeline: pipeline)
|
||||
end
|
||||
|
||||
defp apply_action(socket, :new, _params) do
|
||||
defp apply_action(%{assigns: %{current_user: %{id: current_user_id}}} = socket, :new, _params) do
|
||||
socket
|
||||
|> assign(:page_title, "new Pipeline")
|
||||
|> assign(:pipeline, %Pipeline{})
|
||||
|> assign(page_title: gettext("new pipeline"))
|
||||
|> assign(pipeline: %Pipeline{visibility: :private, user_id: current_user_id})
|
||||
end
|
||||
|
||||
defp apply_action(socket, :index, _params) do
|
||||
socket
|
||||
|> assign(:page_title, "listing pipelines")
|
||||
|> assign(:pipeline, nil)
|
||||
|> assign(page_title: gettext("pipelines"))
|
||||
|> assign(search: nil)
|
||||
|> assign(pipeline: nil)
|
||||
|> display_pipelines()
|
||||
end
|
||||
|
||||
defp apply_action(socket, :search, %{"search" => search}) do
|
||||
socket
|
||||
|> assign(page_title: gettext("pipelines"))
|
||||
|> assign(search: search)
|
||||
|> assign(pipeline: nil)
|
||||
|> display_pipelines()
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
pipeline = Pipelines.get_pipeline!(id)
|
||||
{:ok, _} = Pipelines.delete_pipeline(pipeline)
|
||||
def handle_event("delete", %{"id" => id}, %{assigns: %{current_user: current_user}} = socket) do
|
||||
pipeline = Pipelines.get_pipeline!(id, current_user)
|
||||
{:ok, %{slug: slug}} = Pipelines.delete_pipeline(pipeline, current_user)
|
||||
|
||||
{:noreply, assign(socket, :pipelines, list_pipelines())}
|
||||
socket =
|
||||
socket
|
||||
|> assign(pipelines: Pipelines.list_pipelines(current_user))
|
||||
|> put_flash(:info, gettext("%{slug} deleted", slug: slug))
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp list_pipelines do
|
||||
Pipelines.list_pipelines()
|
||||
@impl true
|
||||
def handle_event("search", %{"search" => %{"search_term" => ""}}, socket) do
|
||||
{:noreply, socket |> push_patch(to: Routes.pipeline_index_path(Endpoint, :index))}
|
||||
end
|
||||
|
||||
def handle_event("search", %{"search" => %{"search_term" => search_term}}, socket) do
|
||||
{:noreply,
|
||||
socket |> push_patch(to: Routes.pipeline_index_path(Endpoint, :search, search_term))}
|
||||
end
|
||||
|
||||
defp display_pipelines(%{assigns: %{current_user: current_user, search: search}} = socket)
|
||||
when not (current_user |> is_nil()) do
|
||||
socket |> assign(pipelines: Pipelines.list_pipelines(search, current_user))
|
||||
end
|
||||
|
||||
defp display_pipelines(%{assigns: %{search: search}} = socket) do
|
||||
socket |> assign(pipelines: Pipelines.list_public_pipelines(search))
|
||||
end
|
||||
|
||||
@spec is_owner_or_admin?(Pipeline.t(), User.t()) :: boolean()
|
||||
defp is_owner_or_admin?(%{user_id: user_id}, %{id: user_id}), do: true
|
||||
defp is_owner_or_admin?(_context, %{role: :admin}), do: true
|
||||
defp is_owner_or_admin?(_context, _other_user), do: false
|
||||
|
||||
@spec is_owner?(Pipeline.t(), User.t()) :: boolean()
|
||||
defp is_owner?(%{user_id: user_id}, %{id: user_id}), do: true
|
||||
defp is_owner?(_context, _other_user), do: false
|
||||
end
|
||||
|
@ -1,64 +1,76 @@
|
||||
<h1>listing pipelines</h1>
|
||||
<div class="mx-auto flex flex-col justify-center items-start space-y-4 max-w-3xl">
|
||||
<h1 class="text-xl">
|
||||
<%= gettext("pipelines") %>
|
||||
</h1>
|
||||
|
||||
<%= if @live_action in [:new, :edit] do %>
|
||||
<.modal return_to={Routes.pipeline_index_path(@socket, :index)}>
|
||||
<.form
|
||||
:let={f}
|
||||
for={:search}
|
||||
phx-change="search"
|
||||
phx-submit="search"
|
||||
class="self-stretch flex flex-col items-stretch"
|
||||
>
|
||||
<%= text_input(f, :search_term,
|
||||
class: "input input-primary",
|
||||
value: @search,
|
||||
phx_debounce: 300,
|
||||
placeholder: gettext("search")
|
||||
) %>
|
||||
</.form>
|
||||
|
||||
<%= if @pipelines |> Enum.empty?() do %>
|
||||
<h1 class="self-center text-primary-500">
|
||||
<%= gettext("no pipelines found") %>
|
||||
</h1>
|
||||
<% else %>
|
||||
<.live_component
|
||||
module={MemexWeb.PipelineLive.FormComponent}
|
||||
id={@pipeline.id || :new}
|
||||
title={@page_title}
|
||||
action={@live_action}
|
||||
pipeline={@pipeline}
|
||||
return_to={Routes.pipeline_index_path(@socket, :index)}
|
||||
/>
|
||||
</.modal>
|
||||
<% end %>
|
||||
module={MemexWeb.Components.PipelinesTableComponent}
|
||||
id="pipelines-index-table"
|
||||
current_user={@current_user}
|
||||
pipelines={@pipelines}
|
||||
>
|
||||
<:actions :let={pipeline}>
|
||||
<.link
|
||||
:if={is_owner?(pipeline, @current_user)}
|
||||
patch={Routes.pipeline_index_path(@socket, :edit, pipeline.slug)}
|
||||
data-qa={"pipeline-edit-#{pipeline.id}"}
|
||||
>
|
||||
<%= dgettext("actions", "edit") %>
|
||||
</.link>
|
||||
<.link
|
||||
:if={is_owner_or_admin?(pipeline, @current_user)}
|
||||
href="#"
|
||||
phx-click="delete"
|
||||
phx-value-id={pipeline.id}
|
||||
data-confirm={dgettext("prompts", "are you sure?")}
|
||||
data-qa={"delete-pipeline-#{pipeline.id}"}
|
||||
>
|
||||
<%= dgettext("actions", "delete") %>
|
||||
</.link>
|
||||
</:actions>
|
||||
</.live_component>
|
||||
<% end %>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Description</th>
|
||||
<th>Visibility</th>
|
||||
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pipelines">
|
||||
<%= for pipeline <- @pipelines do %>
|
||||
<tr id={"pipeline-#{pipeline.id}"}>
|
||||
<td><%= pipeline.title %></td>
|
||||
<td><%= pipeline.description %></td>
|
||||
<td><%= pipeline.visibility %></td>
|
||||
|
||||
<td>
|
||||
<span>
|
||||
<.link navigate={Routes.pipeline_show_path(@socket, :show, pipeline)}>
|
||||
<%= dgettext("actions", "show") %>
|
||||
</.link>
|
||||
</span>
|
||||
<span>
|
||||
<.link patch={Routes.pipeline_index_path(@socket, :edit, pipeline)}>
|
||||
<%= dgettext("actions", "edit") %>
|
||||
</.link>
|
||||
</span>
|
||||
<span>
|
||||
<.link
|
||||
href="#"
|
||||
phx-click="delete"
|
||||
phx-value-id={pipeline.id}
|
||||
data-confirm={dgettext("prompts", "are you sure?")}
|
||||
>
|
||||
<%= dgettext("actions", "delete") %>
|
||||
</.link>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<span>
|
||||
<.link patch={Routes.pipeline_index_path(@socket, :new)}>
|
||||
<.link
|
||||
:if={@current_user}
|
||||
patch={Routes.pipeline_index_path(@socket, :new)}
|
||||
class="self-end btn btn-primary"
|
||||
>
|
||||
<%= dgettext("actions", "new pipeline") %>
|
||||
</.link>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<.modal
|
||||
:if={@live_action in [:new, :edit]}
|
||||
return_to={Routes.pipeline_index_path(@socket, :index)}
|
||||
>
|
||||
<.live_component
|
||||
module={MemexWeb.PipelineLive.FormComponent}
|
||||
id={@pipeline.id || :new}
|
||||
current_user={@current_user}
|
||||
title={@page_title}
|
||||
action={@live_action}
|
||||
pipeline={@pipeline}
|
||||
return_to={Routes.pipeline_index_path(@socket, :index)}
|
||||
/>
|
||||
</.modal>
|
||||
|
@ -1,7 +1,8 @@
|
||||
defmodule MemexWeb.PipelineLive.Show do
|
||||
use MemexWeb, :live_view
|
||||
|
||||
alias Memex.Pipelines
|
||||
import MemexWeb.Components.StepContent
|
||||
alias Memex.{Accounts.User, Pipelines}
|
||||
alias Memex.Pipelines.{Pipeline, Steps, Steps.Step}
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
@ -9,13 +10,128 @@ defmodule MemexWeb.PipelineLive.Show do
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(%{"id" => id}, _, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, page_title(socket.assigns.live_action))
|
||||
|> assign(:pipeline, Pipelines.get_pipeline!(id))}
|
||||
def handle_params(
|
||||
%{"slug" => slug} = params,
|
||||
_url,
|
||||
%{assigns: %{current_user: current_user, live_action: live_action}} = socket
|
||||
) do
|
||||
pipeline =
|
||||
case Pipelines.get_pipeline_by_slug(slug, current_user) do
|
||||
nil -> raise MemexWeb.NotFoundError, gettext("%{slug} could not be found", slug: slug)
|
||||
pipeline -> pipeline
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, page_title(live_action, pipeline))
|
||||
|> assign(:pipeline, pipeline)
|
||||
|> assign(:steps, pipeline |> Steps.list_steps(current_user))
|
||||
|> apply_action(live_action, params)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp page_title(:show), do: "show pipeline"
|
||||
defp page_title(:edit), do: "edit pipeline"
|
||||
defp apply_action(socket, live_action, _params) when live_action in [:show, :edit] do
|
||||
socket
|
||||
end
|
||||
|
||||
defp apply_action(
|
||||
%{
|
||||
assigns: %{
|
||||
steps: steps,
|
||||
pipeline: %{id: pipeline_id},
|
||||
current_user: %{id: current_user_id}
|
||||
}
|
||||
} = socket,
|
||||
:add_step,
|
||||
_params
|
||||
) do
|
||||
socket
|
||||
|> assign(
|
||||
step: %Step{
|
||||
position: steps |> Enum.count(),
|
||||
pipeline_id: pipeline_id,
|
||||
user_id: current_user_id
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
defp apply_action(
|
||||
%{assigns: %{current_user: current_user}} = socket,
|
||||
:edit_step,
|
||||
%{"step_id" => step_id}
|
||||
) do
|
||||
socket |> assign(step: step_id |> Steps.get_step!(current_user))
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(
|
||||
"delete",
|
||||
_params,
|
||||
%{assigns: %{pipeline: pipeline, current_user: current_user}} = socket
|
||||
) do
|
||||
{:ok, %{slug: slug}} = Pipelines.delete_pipeline(pipeline, current_user)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> put_flash(:info, gettext("%{slug} deleted", slug: slug))
|
||||
|> push_navigate(to: Routes.pipeline_index_path(Endpoint, :index))
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(
|
||||
"delete_step",
|
||||
%{"step-id" => step_id},
|
||||
%{assigns: %{pipeline: %{slug: pipeline_slug}, current_user: current_user}} = socket
|
||||
) do
|
||||
{:ok, %{title: title}} =
|
||||
step_id
|
||||
|> Steps.get_step!(current_user)
|
||||
|> Steps.delete_step(current_user)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> put_flash(:info, gettext("%{title} deleted", title: title))
|
||||
|> push_patch(to: Routes.pipeline_show_path(Endpoint, :show, pipeline_slug))
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(
|
||||
"reorder_step",
|
||||
%{"step-id" => step_id, "direction" => direction},
|
||||
%{assigns: %{pipeline: %{slug: pipeline_slug}, current_user: current_user}} = socket
|
||||
) do
|
||||
direction = if direction == "up", do: :up, else: :down
|
||||
|
||||
{:ok, _step} =
|
||||
step_id
|
||||
|> Steps.get_step!(current_user)
|
||||
|> Steps.reorder_step(direction, current_user)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> push_patch(to: Routes.pipeline_show_path(Endpoint, :show, pipeline_slug))
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp page_title(:show, %{slug: slug}), do: slug
|
||||
|
||||
defp page_title(live_action, %{slug: slug}) when live_action in [:edit, :edit_step],
|
||||
do: gettext("edit %{slug}", slug: slug)
|
||||
|
||||
defp page_title(:add_step, %{slug: slug}), do: gettext("add step to %{slug}", slug: slug)
|
||||
|
||||
@spec is_owner_or_admin?(Pipeline.t(), User.t()) :: boolean()
|
||||
defp is_owner_or_admin?(%{user_id: user_id}, %{id: user_id}), do: true
|
||||
defp is_owner_or_admin?(_context, %{role: :admin}), do: true
|
||||
defp is_owner_or_admin?(_context, _other_user), do: false
|
||||
|
||||
@spec is_owner?(Pipeline.t(), User.t()) :: boolean()
|
||||
defp is_owner?(%{user_id: user_id}, %{id: user_id}), do: true
|
||||
defp is_owner?(_context, _other_user), do: false
|
||||
end
|
||||
|
@ -1,43 +1,165 @@
|
||||
<h1>show pipeline</h1>
|
||||
<div class="mx-auto flex flex-col justify-center items-stretch space-y-4 max-w-3xl">
|
||||
<h1 class="text-xl">
|
||||
<%= @pipeline.slug %>
|
||||
</h1>
|
||||
|
||||
<%= if @live_action in [:edit] do %>
|
||||
<.modal return_to={Routes.pipeline_show_path(@socket, :show, @pipeline)}>
|
||||
<.live_component
|
||||
module={MemexWeb.PipelineLive.FormComponent}
|
||||
id={@pipeline.id}
|
||||
title={@page_title}
|
||||
action={@live_action}
|
||||
pipeline={@pipeline}
|
||||
return_to={Routes.pipeline_show_path(@socket, :show, @pipeline)}
|
||||
/>
|
||||
</.modal>
|
||||
<div class="flex flex-wrap space-x-1">
|
||||
<.link
|
||||
:for={tag <- @pipeline.tags}
|
||||
navigate={Routes.pipeline_index_path(Endpoint, :search, tag)}
|
||||
class="link"
|
||||
>
|
||||
<%= tag %>
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
:if={@pipeline.description}
|
||||
id="show-pipeline-description"
|
||||
class="input input-primary h-32 min-h-32"
|
||||
phx-hook="MaintainAttrs"
|
||||
phx-update="ignore"
|
||||
readonly
|
||||
phx-no-format
|
||||
><%= @pipeline.description %></textarea>
|
||||
|
||||
<p class="self-end">
|
||||
<%= gettext("Visibility: %{visibility}", visibility: @pipeline.visibility) %>
|
||||
</p>
|
||||
|
||||
<div class="pb-4 self-end flex space-x-4">
|
||||
<.link class="btn btn-primary" navigate={Routes.pipeline_index_path(@socket, :index)}>
|
||||
<%= dgettext("actions", "back") %>
|
||||
</.link>
|
||||
<.link
|
||||
:if={is_owner?(@pipeline, @current_user)}
|
||||
class="btn btn-primary"
|
||||
patch={Routes.pipeline_show_path(@socket, :edit, @pipeline.slug)}
|
||||
>
|
||||
<%= dgettext("actions", "edit") %>
|
||||
</.link>
|
||||
<button
|
||||
:if={is_owner_or_admin?(@pipeline, @current_user)}
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
phx-click="delete"
|
||||
data-confirm={dgettext("prompts", "are you sure?")}
|
||||
data-qa={"delete-pipeline-#{@pipeline.id}"}
|
||||
>
|
||||
<%= dgettext("actions", "delete") %>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr class="hr" />
|
||||
|
||||
<h2 class="pt-2 self-center text-lg">
|
||||
<%= gettext("steps:") %>
|
||||
</h2>
|
||||
|
||||
<%= if @steps |> Enum.empty?() do %>
|
||||
<h3 class="self-center text-md text-primary-600">
|
||||
<%= gettext("no steps") %>
|
||||
</h3>
|
||||
<% else %>
|
||||
<%= for %{id: step_id, position: position, title: title} = step <- @steps do %>
|
||||
<div class="flex justify-between items-center space-x-4">
|
||||
<h3 class="text-md">
|
||||
<%= gettext("%{position}. %{title}", position: position + 1, title: title) %>
|
||||
</h3>
|
||||
|
||||
<%= if is_owner?(@pipeline, @current_user) do %>
|
||||
<div class="flex justify-between items-center space-x-4">
|
||||
<%= if position <= 0 do %>
|
||||
<i class="fas text-xl fa-chevron-up cursor-not-allowed opacity-25"></i>
|
||||
<% else %>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer flex justify-center items-center"
|
||||
phx-click="reorder_step"
|
||||
phx-value-direction="up"
|
||||
phx-value-step-id={step_id}
|
||||
data-qa={"move-step-up-#{step_id}"}
|
||||
>
|
||||
<i class="fas text-xl fa-chevron-up"></i>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= if position >= length(@steps) - 1 do %>
|
||||
<i class="fas text-xl fa-chevron-down cursor-not-allowed opacity-25"></i>
|
||||
<% else %>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer flex justify-center items-center"
|
||||
phx-click="reorder_step"
|
||||
phx-value-direction="down"
|
||||
phx-value-step-id={step_id}
|
||||
data-qa={"move-step-down-#{step_id}"}
|
||||
>
|
||||
<i class="fas text-xl fa-chevron-down"></i>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<.link
|
||||
class="self-end btn btn-primary"
|
||||
patch={Routes.pipeline_show_path(@socket, :edit_step, @pipeline.slug, step_id)}
|
||||
data-qa={"edit-step-#{step_id}"}
|
||||
>
|
||||
<%= dgettext("actions", "edit") %>
|
||||
</.link>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
phx-click="delete_step"
|
||||
phx-value-step-id={step_id}
|
||||
data-confirm={dgettext("prompts", "are you sure?")}
|
||||
data-qa={"delete-step-#{step_id}"}
|
||||
>
|
||||
<%= dgettext("actions", "delete") %>
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<.step_content step={step} />
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<.link
|
||||
:if={is_owner?(@pipeline, @current_user)}
|
||||
class="self-end btn btn-primary"
|
||||
patch={Routes.pipeline_show_path(@socket, :add_step, @pipeline.slug)}
|
||||
data-qa={"add-step-#{@pipeline.id}"}
|
||||
>
|
||||
<%= dgettext("actions", "add step") %>
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<%= case @live_action do %>
|
||||
<% :edit -> %>
|
||||
<.modal return_to={Routes.pipeline_show_path(@socket, :show, @pipeline.slug)}>
|
||||
<.live_component
|
||||
module={MemexWeb.PipelineLive.FormComponent}
|
||||
id={@pipeline.id}
|
||||
current_user={@current_user}
|
||||
title={@page_title}
|
||||
action={@live_action}
|
||||
pipeline={@pipeline}
|
||||
return_to={Routes.pipeline_show_path(@socket, :show, @pipeline.slug)}
|
||||
/>
|
||||
</.modal>
|
||||
<% action when action in [:add_step, :edit_step] -> %>
|
||||
<.modal return_to={Routes.pipeline_show_path(@socket, :show, @pipeline.slug)}>
|
||||
<.live_component
|
||||
module={MemexWeb.StepLive.FormComponent}
|
||||
id={@pipeline.id || :new}
|
||||
current_user={@current_user}
|
||||
title={@page_title}
|
||||
action={@live_action}
|
||||
pipeline={@pipeline}
|
||||
step={@step}
|
||||
return_to={Routes.pipeline_show_path(@socket, :show, @pipeline.slug)}
|
||||
/>
|
||||
</.modal>
|
||||
<% _ -> %>
|
||||
<% end %>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Title:</strong>
|
||||
<%= @pipeline.title %>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong>Description:</strong>
|
||||
<%= @pipeline.description %>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<strong>Visibility:</strong>
|
||||
<%= @pipeline.visibility %>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<span>
|
||||
<.link patch={Routes.pipeline_show_path(@socket, :edit, @pipeline)} class="button">
|
||||
<%= dgettext("actions", "edit") %>
|
||||
</.link>
|
||||
</span>
|
||||
|
|
||||
<span>
|
||||
<.link patch={Routes.pipeline_index_path(@socket, :index)}>
|
||||
<%= dgettext("actions", "Back") %>
|
||||
</.link>
|
||||
</span>
|
||||
|
74
lib/memex_web/live/step_live/form_component.ex
Normal file
74
lib/memex_web/live/step_live/form_component.ex
Normal file
@ -0,0 +1,74 @@
|
||||
defmodule MemexWeb.StepLive.FormComponent do
|
||||
use MemexWeb, :live_component
|
||||
|
||||
alias Memex.Pipelines.Steps
|
||||
|
||||
@impl true
|
||||
def update(%{step: step, current_user: current_user, pipeline: _pipeline} = assigns, socket) do
|
||||
changeset = Steps.change_step(step, current_user)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign(:changeset, changeset)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(
|
||||
"validate",
|
||||
%{"step" => step_params},
|
||||
%{assigns: %{step: step, current_user: current_user}} = socket
|
||||
) do
|
||||
changeset =
|
||||
step
|
||||
|> Steps.change_step(step_params, current_user)
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
{:noreply, assign(socket, :changeset, changeset)}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"step" => step_params}, %{assigns: %{action: action}} = socket) do
|
||||
save_step(socket, action, step_params)
|
||||
end
|
||||
|
||||
defp save_step(
|
||||
%{assigns: %{step: step, return_to: return_to, current_user: current_user}} = socket,
|
||||
:edit_step,
|
||||
step_params
|
||||
) do
|
||||
case Steps.update_step(step, step_params, current_user) do
|
||||
{:ok, %{title: title}} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, gettext("%{title} saved", title: title))
|
||||
|> push_navigate(to: return_to)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :changeset, changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_step(
|
||||
%{
|
||||
assigns: %{
|
||||
step: %{position: position},
|
||||
return_to: return_to,
|
||||
current_user: current_user,
|
||||
pipeline: pipeline
|
||||
}
|
||||
} = socket,
|
||||
:add_step,
|
||||
step_params
|
||||
) do
|
||||
case Steps.create_step(step_params, position, pipeline, current_user) do
|
||||
{:ok, %{title: title}} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, gettext("%{title} created", title: title))
|
||||
|> push_navigate(to: return_to)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, changeset: changeset)}
|
||||
end
|
||||
end
|
||||
end
|
34
lib/memex_web/live/step_live/form_component.html.heex
Normal file
34
lib/memex_web/live/step_live/form_component.html.heex
Normal file
@ -0,0 +1,34 @@
|
||||
<div class="h-full flex flex-col justify-start items-stretch space-y-4">
|
||||
<.form
|
||||
:let={f}
|
||||
for={@changeset}
|
||||
id="step-form"
|
||||
phx-target={@myself}
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
phx-debounce="300"
|
||||
class="flex flex-col justify-start items-stretch space-y-4"
|
||||
>
|
||||
<%= text_input(f, :title,
|
||||
class: "input input-primary",
|
||||
placeholder: gettext("title")
|
||||
) %>
|
||||
<%= error_tag(f, :title) %>
|
||||
|
||||
<%= textarea(f, :content,
|
||||
id: "step-form-content",
|
||||
class: "input input-primary h-64 min-h-64",
|
||||
phx_hook: "MaintainAttrs",
|
||||
phx_update: "ignore",
|
||||
placeholder: gettext("use [[context-slug]] to link to a context")
|
||||
) %>
|
||||
<%= error_tag(f, :content) %>
|
||||
|
||||
<div class="flex justify-center items-stretch space-x-4">
|
||||
<%= submit(dgettext("actions", "save"),
|
||||
phx_disable_with: gettext("saving..."),
|
||||
class: "mx-auto btn btn-primary"
|
||||
) %>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
3
lib/memex_web/not_found_error.ex
Normal file
3
lib/memex_web/not_found_error.ex
Normal file
@ -0,0 +1,3 @@
|
||||
defmodule MemexWeb.NotFoundError do
|
||||
defexception [:message, plug_status: 404]
|
||||
end
|
@ -11,15 +11,17 @@ defmodule MemexWeb.Router do
|
||||
plug :protect_from_forgery
|
||||
plug :put_secure_browser_headers
|
||||
plug :fetch_current_user
|
||||
plug :put_user_locale, default: Application.compile_env(:gettext, :default_locale, "en_US")
|
||||
plug :put_user_locale
|
||||
end
|
||||
|
||||
defp put_user_locale(%{assigns: %{current_user: %{locale: locale}}} = conn, default: default) do
|
||||
defp put_user_locale(%{assigns: %{current_user: %{locale: locale}}} = conn, _opts) do
|
||||
default = Application.fetch_env!(:gettext, :default_locale)
|
||||
Gettext.put_locale(locale || default)
|
||||
conn |> put_session(:locale, locale || default)
|
||||
end
|
||||
|
||||
defp put_user_locale(conn, default: default) do
|
||||
defp put_user_locale(conn, _opts) do
|
||||
default = Application.fetch_env!(:gettext, :default_locale)
|
||||
Gettext.put_locale(default)
|
||||
conn |> put_session(:locale, default)
|
||||
end
|
||||
@ -36,6 +38,7 @@ defmodule MemexWeb.Router do
|
||||
pipe_through :browser
|
||||
|
||||
live "/", HomeLive
|
||||
live "/faq", FaqLive
|
||||
end
|
||||
|
||||
## Authentication routes
|
||||
@ -58,34 +61,40 @@ defmodule MemexWeb.Router do
|
||||
pipe_through [:browser, :require_authenticated_user]
|
||||
|
||||
live "/notes/new", NoteLive.Index, :new
|
||||
live "/notes/:id/edit", NoteLive.Index, :edit
|
||||
live "/notes/:id/show/edit", NoteLive.Show, :edit
|
||||
live "/notes/:slug/edit", NoteLive.Index, :edit
|
||||
live "/note/:slug/edit", NoteLive.Show, :edit
|
||||
|
||||
live "/contexts/new", ContextLive.Index, :new
|
||||
live "/contexts/:id/edit", ContextLive.Index, :edit
|
||||
live "/contexts/:id/show/edit", ContextLive.Show, :edit
|
||||
live "/contexts/:slug/edit", ContextLive.Index, :edit
|
||||
live "/context/:slug/edit", ContextLive.Show, :edit
|
||||
|
||||
live "/pipelines/new", PipelineLive.Index, :new
|
||||
live "/pipelines/:id/edit", PipelineLive.Index, :edit
|
||||
live "/pipelines/:id/show/edit", PipelineLive.Show, :edit
|
||||
live "/pipelines/:slug/edit", PipelineLive.Index, :edit
|
||||
live "/pipeline/:slug/edit", PipelineLive.Show, :edit
|
||||
live "/pipeline/:slug/add_step", PipelineLive.Show, :add_step
|
||||
live "/pipeline/:slug/:step_id", PipelineLive.Show, :edit_step
|
||||
|
||||
get "/users/settings", UserSettingsController, :edit
|
||||
put "/users/settings", UserSettingsController, :update
|
||||
delete "/users/settings/:id", UserSettingsController, :delete
|
||||
get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email
|
||||
get "/export/:mode", ExportController, :export
|
||||
end
|
||||
|
||||
scope "/", MemexWeb do
|
||||
pipe_through [:browser]
|
||||
|
||||
live "/notes", NoteLive.Index, :index
|
||||
live "/notes/:id", NoteLive.Show, :show
|
||||
live "/notes/:search", NoteLive.Index, :search
|
||||
live "/note/:slug", NoteLive.Show, :show
|
||||
|
||||
live "/contexts", ContextLive.Index, :index
|
||||
live "/contexts/:id", ContextLive.Show, :show
|
||||
live "/contexts/:search", ContextLive.Index, :search
|
||||
live "/context/:slug", ContextLive.Show, :show
|
||||
|
||||
live "/pipelines", PipelineLive.Index, :index
|
||||
live "/pipelines/:id", PipelineLive.Show, :show
|
||||
live "/pipelines/:search", PipelineLive.Index, :search
|
||||
live "/pipeline/:slug", PipelineLive.Show, :show
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -57,6 +57,30 @@ defmodule MemexWeb.Telemetry do
|
||||
"The time the connection spent waiting before being checked out for the query"
|
||||
),
|
||||
|
||||
# Oban Metrics
|
||||
counter("oban.job.exception",
|
||||
tags: [:queue, :worker],
|
||||
event_name: [:oban, :job, :exception],
|
||||
measurement: :duration,
|
||||
description: "Number of oban jobs that raised an exception"
|
||||
),
|
||||
counter("oban.job.start",
|
||||
tags: [:queue, :worker],
|
||||
event_name: [:oban, :job, :start],
|
||||
measurement: :system_time,
|
||||
description: "Number of oban jobs started"
|
||||
),
|
||||
summary("oban.job.stop.duration",
|
||||
tags: [:queue, :worker],
|
||||
unit: {:native, :millisecond},
|
||||
description: "Length of time spent processing the oban job"
|
||||
),
|
||||
summary("oban.job.stop.queue_time",
|
||||
tags: [:queue, :worker],
|
||||
unit: {:native, :millisecond},
|
||||
description: "Time the oban job spent waiting in milliseconds"
|
||||
),
|
||||
|
||||
# VM Metrics
|
||||
summary("vm.memory.total", unit: {:byte, :kilobyte}),
|
||||
summary("vm.total_run_queue_lengths.total"),
|
||||
|
@ -6,7 +6,7 @@
|
||||
<br />
|
||||
|
||||
<span style="margin-bottom: 1em; font-size: 1.25em;">
|
||||
<%= dgettext("emails", "Welcome to Memex") %>
|
||||
<%= dgettext("emails", "Welcome to memEx") %>
|
||||
</span>
|
||||
|
||||
<br />
|
||||
@ -19,5 +19,5 @@
|
||||
|
||||
<br />
|
||||
|
||||
<%= dgettext("emails", "If you didn't create an account at Memex, please ignore this.") %>
|
||||
<%= dgettext("emails", "If you didn't create an account at memEx, please ignore this.") %>
|
||||
</div>
|
||||
|
@ -1,7 +1,7 @@
|
||||
|
||||
<%= dgettext("emails", "Hi %{email},", email: @user.email) %>
|
||||
|
||||
<%= dgettext("emails", "Welcome to Memex") %>
|
||||
<%= dgettext("emails", "Welcome to memEx") %>
|
||||
|
||||
<%= dgettext("emails", "You can confirm your account by visiting the URL below:") %>
|
||||
|
||||
|
@ -13,5 +13,5 @@
|
||||
|
||||
<br />
|
||||
|
||||
<%= dgettext("emails", "If you didn't request this change from Memex, please ignore this.") %>
|
||||
<%= dgettext("emails", "If you didn't request this change from memEx, please ignore this.") %>
|
||||
</div>
|
||||
|
@ -15,6 +15,6 @@
|
||||
|
||||
<%= dgettext(
|
||||
"emails",
|
||||
"If you didn't request this change from Memex, please ignore this."
|
||||
"If you didn't request this change from memEx, please ignore this."
|
||||
) %>
|
||||
</div>
|
||||
|
@ -5,15 +5,15 @@
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>
|
||||
<%= dgettext("errors", "Error") %>| Memex
|
||||
<%= dgettext("errors", "Error") %> | <%= gettext("memEx") %>
|
||||
</title>
|
||||
<link rel="stylesheet" href="/css/app.css" />
|
||||
<script defer type="text/javascript" src="/js/app.js">
|
||||
</script>
|
||||
</head>
|
||||
<body class="pb-8 m-0 p-0 w-full h-full">
|
||||
<body class="pb-8 m-0 p-0 w-full h-full bg-primary-800 text-primary-400 subpixel-antialiased">
|
||||
<header>
|
||||
<.topbar current_user={assigns[:current_user]}></.topbar>
|
||||
<.topbar current_user={assigns[:current_user]} />
|
||||
</header>
|
||||
|
||||
<div class="pb-8 w-full flex flex-col justify-center items-center text-center">
|
||||
@ -25,7 +25,7 @@
|
||||
<hr class="w-full hr" />
|
||||
|
||||
<a href={Routes.live_path(Endpoint, HomeLive)} class="link title text-primary-400 text-lg">
|
||||
<%= dgettext("errors", "Go back home") %>
|
||||
<%= dgettext("errors", "go back home") %>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,18 +1,14 @@
|
||||
<main role="main" class="min-h-full min-w-full">
|
||||
<header>
|
||||
<.topbar current_user={assigns[:current_user]}></.topbar>
|
||||
<.topbar current_user={assigns[:current_user]} />
|
||||
|
||||
<div class="mx-8 my-2 flex flex-col space-y-4 text-center">
|
||||
<%= if get_flash(@conn, :info) do %>
|
||||
<p class="alert alert-info" role="alert">
|
||||
<%= get_flash(@conn, :info) %>
|
||||
</p>
|
||||
<% end %>
|
||||
<%= if get_flash(@conn, :error) do %>
|
||||
<p class="alert alert-danger" role="alert">
|
||||
<%= get_flash(@conn, :error) %>
|
||||
</p>
|
||||
<% end %>
|
||||
<p :if={get_flash(@conn, :info)} class="alert alert-info" role="alert">
|
||||
<%= get_flash(@conn, :info) %>
|
||||
</p>
|
||||
<p :if={get_flash(@conn, :error)} class="alert alert-danger" role="alert">
|
||||
<%= get_flash(@conn, :error) %>
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
@ -4,15 +4,15 @@
|
||||
<%= @email.subject %>
|
||||
</title>
|
||||
</head>
|
||||
<body style="padding: 2em; color: rgb(31, 31, 31); background-color: rgb(220, 220, 228); font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; text-align: center;">
|
||||
<body style="padding: 2em; color: rgb(161, 161, 170); background-color: rgb(39, 39, 42); font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; text-align: center;">
|
||||
<%= @inner_content %>
|
||||
|
||||
<hr style="margin: 2em auto; border-width: 1px; border-color: rgb(212, 212, 216); width: 100%; max-width: 42rem;" />
|
||||
<hr style="margin: 2em auto; border-width: 1px; border-color: rgb(161, 161, 170); width: 100%; max-width: 42rem;" />
|
||||
|
||||
<a style="color: rgb(31, 31, 31);" href={Routes.live_url(Endpoint, HomeLive)}>
|
||||
<a style="color: rgb(161, 161, 170);" href={Routes.live_url(Endpoint, HomeLive)}>
|
||||
<%= dgettext(
|
||||
"emails",
|
||||
"This email was sent from Memex, the self-hosted firearm tracker website."
|
||||
"This email was sent from memEx"
|
||||
) %>
|
||||
</a>
|
||||
</body>
|
||||
|
@ -7,5 +7,5 @@
|
||||
=====================
|
||||
|
||||
<%= dgettext("emails",
|
||||
"This email was sent from Memex at %{url}, the self-hosted firearm tracker website.",
|
||||
"This email was sent from memEx at %{url}",
|
||||
url: Routes.live_url(Endpoint, HomeLive)) %>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user