94 Commits

Author SHA1 Message Date
7e78cd7c9a add new language note to contributing guide
All checks were successful
continuous-integration/drone/push Build is passing
2025-04-13 19:09:17 +00:00
5c32dbc324 add translation request to contributing guide
All checks were successful
continuous-integration/drone/push Build is passing
2025-04-13 11:58:14 -04:00
926d4f9837 fix style
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-04-05 04:02:14 +00:00
128498eac7 Added translation using Weblate (German)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-04-05 03:42:51 +00:00
32094221c2 update deps
Some checks are pending
continuous-integration/drone/push Build is running
2025-04-05 03:42:43 +00:00
4cca4ee3b7 remove unused toggle button component 2025-04-05 03:22:10 +00:00
da717013de improve accuracy of timestamps 2025-04-05 03:17:56 +00:00
7096e6abeb remove extra file 2025-04-05 03:00:06 +00:00
e379896512 use dynamic dispatch 2025-04-05 02:59:27 +00:00
0c5442f0cd add backlinks
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-02-15 06:01:03 +00:00
6c2aba84ef fix visibility issues with multiple users 2025-02-15 06:01:03 +00:00
3e686fa199 better code style 2025-02-15 06:01:03 +00:00
2a8a1d11b8 mark required fields as required
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-02-15 02:50:57 +00:00
c3d066016b add placeholder for empty notes and contexts 2025-02-15 02:50:57 +00:00
64bf39da29 fix content not escaping html properly 2025-02-15 02:50:56 +00:00
c25e02dee1 update dependencies 2025-02-15 02:50:45 +00:00
5be05ceea6 fix broken install step
All checks were successful
continuous-integration/drone/push Build is passing
2025-02-13 22:01:54 +00:00
e8a041024c add missing license, whoops!!
All checks were successful
continuous-integration/drone/push Build is passing
2024-12-31 00:32:20 -05:00
36f385c7f3 update js dependencies
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2024-12-30 20:09:00 -05:00
ddb8bbec53 downgrade elixir version for images
Some checks reported errors
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build was killed
2024-12-30 19:45:33 -05:00
1e55039a67 fix style issues
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2024-12-30 19:38:34 -05:00
2346a82a46 fix new invite button not working 2024-12-30 19:38:33 -05:00
b63c6bd318 fix descriptions possibly overflowing widths 2024-12-30 19:38:24 -05:00
b72a79c380 update gettext syntax 2024-12-30 19:38:11 -05:00
5cd7a7eef0 update dependencies 2024-12-30 19:05:01 -05:00
f6dc41498b update versions 2024-12-30 19:02:04 -05:00
1c912a1600 improve testing db timeout 2024-07-28 13:49:22 -04:00
eeef7c94cd fix emails
All checks were successful
continuous-integration/drone/push Build is passing
2024-07-28 13:35:34 -04:00
3c3391b3a6 update deps 2024-07-28 13:35:34 -04:00
52460024b9 update versions 2024-07-28 13:35:34 -04:00
48f7c8d18e fix empty invite index page
All checks were successful
continuous-integration/drone/push Build is passing
2024-02-24 13:14:38 -05:00
571e0b65b6 fix faq page copy 2024-02-23 23:36:00 -05:00
7dc2047e97 fix missing ssl and crypto packages
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2024-02-23 22:44:36 -05:00
f769e710d8 sanitize tags while they are being typed
All checks were successful
continuous-integration/drone/push Build is passing
2024-02-23 22:39:01 -05:00
d09f698b71 remove requirement for note and content to have content 2024-02-23 22:34:08 -05:00
8666f663ba Prevent possible additional submissions 2024-02-23 22:31:51 -05:00
22ccea893c sanitize titles while they are being typed 2024-02-23 22:31:15 -05:00
362c406471 fix credo warning for is_owner? 2024-02-23 22:18:25 -05:00
2a87037f06 fix credo warning for is_owner_or_admin? 2024-02-23 22:17:56 -05:00
53d0dcfb15 fix credo warning for is_admin? 2024-02-23 22:17:19 -05:00
c892b5449b fix credo warning for is_already_admin? 2024-02-23 22:16:08 -05:00
7cd9dca958 update elixir deps 2024-02-23 22:14:28 -05:00
0e8ddc22c5 update npm deps 2024-02-23 22:14:28 -05:00
3671ad6199 update tool versions 2024-02-23 22:14:20 -05:00
7189c955c3 bump version 2023-11-26 17:30:20 -05:00
f56ecc0ba3 fix content being displayed when blank 2023-11-26 17:30:14 -05:00
fdfca3f7a5 bump version
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-26 11:25:29 -05:00
c61b2c67b7 fix issue with displaying content 2023-11-26 11:25:26 -05:00
d0d958a638 update npm deps
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-04 23:46:21 -04:00
a437b5966f update elixir deps 2023-11-04 23:40:15 -04:00
e2378279d7 change how backlinks work
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-04 23:34:11 -04:00
1b49b668b3 add ctrl-enter submit 2023-11-04 22:40:56 -04:00
03021614b5 fix live flashes not dismissable by click 2023-11-04 22:22:46 -04:00
50af86798a fix warning in tests 2023-11-04 22:20:06 -04:00
be01723be2 make content previews resizable 2023-11-04 22:19:35 -04:00
0a27a4ee29 make step content not a required field 2023-11-04 22:10:16 -04:00
e2f8ac6b78 add bottom padding 2023-11-04 22:09:05 -04:00
d5e334dc09 tolerate spaces in tags 2023-11-04 22:07:25 -04:00
1d6ba5960c fix debounces 2023-11-04 22:05:20 -04:00
bc29ca6c20 update elixir/erlang version
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-20 18:25:36 -04:00
bf9fd4880f run mix format
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-07 18:43:25 -04:00
957e433847 update dependencies 2023-09-07 18:43:25 -04:00
edd631f520 run npm audit fix 2023-09-07 18:08:02 -04:00
2e1545a9f5 update npm 2023-09-07 18:07:21 -04:00
3e296080f5 fix user registration controller 2023-06-04 00:11:09 -04:00
d2ae6024ce fix error/404 pages not rendering properly 2023-05-12 22:59:04 -04:00
4615a29c11 disable arm builds
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-16 21:36:33 -04:00
eb48ff7dc0 build in arm64 and amd64
Some checks reported errors
continuous-integration/drone/push Build was killed
2023-04-16 17:05:13 -04:00
fcfd9857d5 remove maintain attrs
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-16 01:12:07 -04:00
c5f96a9d9d change invite path slightly
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-16 00:48:04 -04:00
c1a0b4017f improve tests
Some checks failed
continuous-integration/drone/push Build is failing
2023-04-15 21:40:57 -04:00
04ebe59afe ee cummings even more 2023-04-15 21:40:17 -04:00
50be85a1c3 make test async
Some checks are pending
continuous-integration/drone/push Build is running
2023-04-14 23:50:16 -04:00
994aa96a20 simply assigns
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-14 20:32:47 -04:00
026bf22f60 improve formatting 2023-04-14 20:08:03 -04:00
56e6eb3609 use more verified routes
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-14 19:51:14 -04:00
c49140e7f5 ee cummings even more
Some checks failed
continuous-integration/drone/push Build is failing
2023-04-14 19:19:58 -04:00
1276635a3e make emails more efficient
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-14 19:02:06 -04:00
f00dc50215 clean up router 2023-04-14 19:01:56 -04:00
35de8a6395 use embed templates suffix
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-14 18:45:12 -04:00
96e155a49a ee cummings more emails 2023-04-14 18:29:34 -04:00
c02fb06eb2 update erlang version
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-14 18:10:28 -04:00
a9d5649bef fix live reload glob
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-14 00:15:55 -04:00
650d61e95f fix email comment
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-13 23:48:17 -04:00
63d854ffbe upgrade to phoenix 1.7
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-13 23:29:29 -04:00
a1c846be33 update npm version 2023-04-13 18:45:32 -04:00
1b9f212e66 update to elixir 1.14.4 2023-04-13 18:44:12 -04:00
7805ddc270 update npm dependencies 2023-04-13 18:37:43 -04:00
c1455bccad update elixir deps 2023-04-13 18:35:25 -04:00
dd956be93f generate fonts with correct filename
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-28 22:03:11 -04:00
04361a5838 improve invite tests 2023-03-28 22:03:00 -04:00
cb049cb178 improve components
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-23 00:22:04 -04:00
5a41d8b3e7 improve tests
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-22 22:08:37 -04:00
64320dbdae add extra feature to changelog
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-19 16:01:35 -04:00
217 changed files with 7578 additions and 28058 deletions

View File

@ -17,7 +17,7 @@ steps:
- .mix
- name: test
image: elixir:1.14.1-alpine
image: elixir:1.18.3-alpine
environment:
TEST_DATABASE_URL: ecto://postgres:postgres@database/memex_test
HOST: testing.example.tld
@ -26,13 +26,12 @@ steps:
MIX_ENV: test
commands:
- apk add --no-cache build-base npm git
- mix local.rebar --force --if-missing
- mix local.hex --force --if-missing
- mix local.rebar --force
- mix local.hex --force
- mix deps.get
- npm set cache .npm
- npm --prefix ./assets ci --no-audit --prefer-offline
- npm run --prefix ./assets deploy
- mix do phx.digest, gettext.extract
- mix do phx.digest, gettext.extract, assets.deploy
- mix test.all
- name: build and publish stable
@ -42,7 +41,8 @@ steps:
repo: shibaobun/memex
purge: true
compress: true
platforms: linux/amd64,linux/arm/v7
platforms:
- linux/amd64
username:
from_secret: docker_username
password:
@ -59,7 +59,8 @@ steps:
repo: shibaobun/memex
purge: true
compress: true
platforms: linux/amd64,linux/arm/v7
platforms:
- linux/amd64
username:
from_secret: docker_username
password:

View File

@ -1,6 +1,6 @@
[
import_deps: [:ecto, :phoenix],
inputs: ["*.{heex,ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{heex,ex,exs}"],
import_deps: [:ecto, :ecto_sql, :phoenix],
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"],
subdirectories: ["priv/*/migrations"],
plugins: [Phoenix.LiveView.HTMLFormatter]
]

9
.gitignore vendored
View File

@ -28,10 +28,11 @@ npm-debug.log
# The directory NPM downloads your dependencies sources to.
/assets/node_modules/
# Since we are building assets from assets/,
# we ignore priv/static. You may want to comment
# this depending on your deployment strategy.
/priv/static/
# Ignore assets that are produced by build tools.
/priv/static/assets/
# Ignore digested assets cache.
/priv/static/cache_manifest.json
.elixir_ls/

View File

@ -1,3 +1,3 @@
elixir 1.14.1-otp-25
erlang 25.1.2
nodejs 18.9.1
elixir 1.18.3-otp-27
erlang 27.3.1
nodejs 23.10.0

View File

@ -1,4 +1,4 @@
FROM elixir:1.14.1-alpine AS build
FROM elixir:1.18.3-otp-27-alpine AS build
# install build dependencies
RUN apk add --no-cache build-base npm git python3
@ -7,8 +7,8 @@ RUN apk add --no-cache build-base npm git python3
WORKDIR /app
# install hex + rebar
RUN mix local.hex --force && \
mix local.rebar --force
RUN mix local.rebar --force && \
mix local.hex --force
# set build ENV
ENV MIX_ENV=prod
@ -25,24 +25,25 @@ RUN npm --prefix ./assets ci --progress=false --no-audit --loglevel=error
COPY lib lib
COPY priv priv
COPY assets assets
RUN npm run --prefix ./assets deploy
RUN mix do phx.digest, gettext.extract
# compile and build release
# uncomment COPY if rel/ exists
# COPY rel rel
RUN mix do compile, release
RUN mix do assets.deploy, compile, release
# prepare release image
FROM alpine:latest AS app
RUN apk upgrade --no-cache && \
apk add --no-cache bash openssl libssl1.1 libcrypto1.1 libgcc libstdc++ ncurses-libs
apk add --no-cache bash openssl libssl3 libcrypto3 libgcc libstdc++ ncurses-libs
WORKDIR /app
RUN chown nobody:nobody /app
ENV MIX_ENV=prod
USER nobody:nobody
COPY --from=build --chown=nobody:nobody /app/_build/prod/rel/memex ./

View File

@ -1,5 +0,0 @@
{
"presets": [
"@babel/preset-env"
]
}

View File

@ -1,126 +0,0 @@
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
$fa-font-path: "@fortawesome/fontawesome-free/webfonts";
@import "@fortawesome/fontawesome-free/scss/fontawesome";
@import "@fortawesome/fontawesome-free/scss/regular";
@import "@fortawesome/fontawesome-free/scss/solid";
@import "@fortawesome/fontawesome-free/scss/brands";
@import "components";
/* fix firefox scrollbars */
* {
scrollbar-width: auto;
scrollbar-color: rgba(161, 161, 170, var(--tw-bg-opacity)) white;
}
.fa-fade {
animation: pulse 1s ease-in-out 0s infinite alternate;
}
@keyframes pulse {
0% { scale: 0.95; opacity: 0.5; }
100% { scale: 1.0; opacity: 1; }
}
// disconnect toast
.phx-connected > #disconnect {
opacity: 0 !important;
pointer-events: none;
}
.phx-error > #disconnect {
opacity: 0.95 !important;
}
.invalid-feedback {
color: #f36c69;
display: block;
margin: -1rem 0 2rem;
}
/* LiveView specific classes for your customization */
.phx-no-feedback.invalid-feedback,
.phx-no-feedback .invalid-feedback {
display: none;
}
.phx-click-loading {
opacity: 0.5;
transition: opacity 1s ease-out;
}
.phx-loading{
cursor: wait;
}
.phx-modal {
opacity: 1!important;
position: fixed;
z-index: 1;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.4);
}
.phx-modal-content {
background-color: #fefefe;
margin: 15vh auto;
padding: 20px;
border: 1px solid #888;
width: 80%;
}
.phx-modal-close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
}
.phx-modal-close:hover,
.phx-modal-close:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
.fade-in-scale {
animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys;
}
.fade-out-scale {
animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys;
}
.fade-in {
animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys;
}
.fade-out {
animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys;
}
@keyframes fade-in-scale-keys{
0% { scale: 0.95; opacity: 0; }
100% { scale: 1.0; opacity: 1; }
}
@keyframes fade-out-scale-keys{
0% { scale: 1.0; opacity: 1; }
100% { scale: 0.95; opacity: 0; }
}
@keyframes fade-in-keys{
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes fade-out-keys{
0% { opacity: 1; }
100% { opacity: 0; }
}

View File

@ -1,90 +0,0 @@
@layer components {
.input {
@apply rounded-lg px-4 py-2 border focus:outline-none;
@apply shadow-sm focus:shadow-lg;
}
.input-primary {
@apply bg-primary-900;
@apply border-primary-900 hover:border-primary-800 active:border-primary-700;
@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);
-o-transform: scale(1.5);
transform: scale(1.5);
padding: 10px;
margin: 1em auto;
}
.title {
@apply leading-5 tracking-wide;
}
.btn {
@apply focus:outline-none px-4 py-2 rounded-lg;
@apply shadow-sm active:shadow-lg;
@apply border;
@apply transition-all duration-300 ease-in-out;
}
.btn-primary {
@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-800 active:bg-red-900;
@apply border-red-800 active:border-red-900;
@apply text-primary-300;
}
.hr {
@apply mx-auto border border-primary-600 w-full max-w-3xl;
}
.link {
@apply hover:underline;
@apply transition-colors duration-500 ease-in-out;
}
.alert {
@apply bg-primary-900;
@apply text-primary-400;
padding: 15px;
margin-bottom: 20px;
border: 1px solid transparent;
border-radius: 4px;
}
.alert-info {
@apply text-primary-400;
}
.alert-warning {
color: #8a6d3b;
}
.alert-danger {
color: #a94442;
}
.alert p {
@apply mb-0;
}
.alert:empty {
@apply hidden;
}
}

226
assets/css/style.css Normal file
View File

@ -0,0 +1,226 @@
@import "tailwindcss" source("../..");
@theme {
--color-primary-50: oklch(0.985 0 0);
--color-primary-100: oklch(0.967 0.001 286.375);
--color-primary-200: oklch(0.92 0.004 286.32);
--color-primary-300: oklch(0.871 0.006 286.286);
--color-primary-400: oklch(0.705 0.015 286.067);
--color-primary-500: oklch(0.552 0.016 285.938);
--color-primary-600: oklch(0.442 0.017 285.786);
--color-primary-700: oklch(0.37 0.013 285.805);
--color-primary-800: oklch(0.274 0.006 286.033);
--color-primary-900: oklch(0.21 0.006 285.885);
--color-primary-950: oklch(0.141 0.005 285.823);
--font-display: "Nunito Sans", sans-serif;
}
@import "@fortawesome/fontawesome-free/css/fontawesome" source("../..");
@import "@fortawesome/fontawesome-free/css/regular" source("../..");
@import "@fortawesome/fontawesome-free/css/solid" source("../..");
@import "@fortawesome/fontawesome-free/css/brands" source("../..");
/* fix firefox scrollbars */
* {
scrollbar-width: auto;
scrollbar-color: rgba(161, 161, 170, var(--tw-bg-opacity)) white;
}
.fa-fade {
animation: pulse 1s ease-in-out 0s infinite alternate;
}
@keyframes pulse {
0% { scale: 0.95; opacity: 0.5; }
100% { scale: 1.0; opacity: 1; }
}
/* disconnect toast */
.phx-connected > #disconnect {
opacity: 0 !important;
pointer-events: none;
}
.phx-error > #disconnect {
opacity: 0.95 !important;
}
.invalid-feedback {
color: #f36c69;
display: block;
margin: -1rem 0 2rem;
}
/* LiveView specific classes for your customization */
.phx-no-feedback.invalid-feedback,
.phx-no-feedback .invalid-feedback {
display: none;
}
.phx-click-loading {
opacity: 0.5;
transition: opacity 1s ease-out;
}
.phx-loading{
cursor: wait;
}
.phx-modal {
opacity: 1!important;
position: fixed;
z-index: 1;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.4);
}
.phx-modal-content {
background-color: #fefefe;
margin: 15vh auto;
padding: 20px;
border: 1px solid #888;
width: 80%;
}
.phx-modal-close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
}
.phx-modal-close:hover,
.phx-modal-close:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
.fade-in-scale {
animation: 0.2s ease-in 0s normal forwards 1 fade-in-scale-keys;
}
.fade-out-scale {
animation: 0.2s ease-out 0s normal forwards 1 fade-out-scale-keys;
}
.fade-in {
animation: 0.2s ease-out 0s normal forwards 1 fade-in-keys;
}
.fade-out {
animation: 0.2s ease-out 0s normal forwards 1 fade-out-keys;
}
@keyframes fade-in-scale-keys{
0% { scale: 0.95; opacity: 0; }
100% { scale: 1.0; opacity: 1; }
}
@keyframes fade-out-scale-keys{
0% { scale: 1.0; opacity: 1; }
100% { scale: 0.95; opacity: 0; }
}
@keyframes fade-in-keys{
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes fade-out-keys{
0% { opacity: 1; }
100% { opacity: 0; }
}
/* components */
.input {
@apply px-4 py-2 rounded-lg border focus:outline-hidden;
@apply shadow-sm focus:shadow-lg;
}
.input-primary {
@apply bg-primary-900;
@apply border-primary-900 hover:border-primary-800 active:border-primary-700;
@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);
-o-transform: scale(1.5);
transform: scale(1.5);
padding: 10px;
margin: 1em auto;
}
.title {
@apply tracking-wide leading-5;
}
.btn {
@apply px-4 py-2 rounded-lg focus:outline-hidden;
@apply shadow-sm active:shadow-lg;
@apply border;
@apply transition-all duration-300 ease-in-out;
}
.btn-primary {
@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-rose-800 active:bg-rose-900;
@apply border-rose-800 active:border-rose-900;
@apply text-primary-300;
}
.hr {
@apply mx-auto w-full max-w-3xl border border-primary-600;
}
.link {
@apply hover:underline;
@apply transition-colors duration-500 ease-in-out;
}
.alert {
@apply bg-primary-900;
@apply text-primary-400;
padding: 15px;
margin-bottom: 20px;
border: 1px solid transparent;
border-radius: 4px;
}
.alert-info {
@apply text-primary-400;
}
.alert-warning {
color: #8a6d3b;
}
.alert-danger {
color: #a94442;
}
.alert p {
@apply mb-0;
}
.alert:empty {
@apply hidden;
}

View File

@ -1,6 +1,3 @@
// We import the CSS which is extracted to its own file by esbuild.
// Remove this line if you add a your own CSS build pipeline (e.g postcss).
import '../css/app.scss'
import '@fontsource/nunito-sans'
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
@ -26,16 +23,19 @@ import 'phoenix_html'
import { Socket } from 'phoenix'
import { LiveSocket } from 'phoenix_live_view'
import topbar from 'topbar'
import CtrlEnter from './ctrlenter'
import Date from './date'
import DateTime from './datetime'
import MaintainAttrs from './maintain_attrs'
import SanitizeTags from './sanitizetags'
import SanitizeTitles from './sanitizetitles'
const csrfTokenElement = document.querySelector("meta[name='csrf-token']")
let csrfToken
if (csrfTokenElement) { csrfToken = csrfTokenElement.getAttribute('content') }
const liveSocket = new LiveSocket('/live', Socket, {
params: { _csrf_token: csrfToken },
hooks: { Date, DateTime, MaintainAttrs }
hooks: { CtrlEnter, Date, DateTime, SanitizeTags, SanitizeTitles }
})
// Show progress bar on live navigation and form submits

11
assets/js/ctrlenter.js Normal file
View File

@ -0,0 +1,11 @@
export default {
addFormSubmit (context) {
context.el.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'Enter') {
context.el.dispatchEvent(
new Event('submit', { bubbles: true, cancelable: true }))
}
})
},
mounted () { this.addFormSubmit(this) }
}

View File

@ -1,22 +0,0 @@
// maintain user adjusted attributes, like textbox length on phoenix liveview
// update. https://github.com/phoenixframework/phoenix_live_view/issues/1011
export default {
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))
}
}
}

11
assets/js/sanitizetags.js Normal file
View File

@ -0,0 +1,11 @@
export default {
SanitizeTags (context) {
context.el.addEventListener('keyup', (e) => {
e.target.value = e.target.value
.replace(' ', ',')
.replace(',,', ',')
.replace(/[^a-zA-Z0-9,]/, '')
})
},
mounted () { this.SanitizeTags(this) }
}

View File

@ -0,0 +1,10 @@
export default {
SanitizeTitles (context) {
context.el.addEventListener('keyup', (e) => {
e.target.value = e.target.value
.replace(' ', '-')
.replace(/[^a-zA-Z0-9-]/, '')
})
},
mounted () { this.SanitizeTitles(this) }
}

26534
assets/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,47 +1,18 @@
{
"repository": {},
"description": " ",
"license": "MIT",
"engines": {
"node": "v18.9.1",
"npm": "8.19.1"
"node": "v23.10.0"
},
"scripts": {
"deploy": "NODE_ENV=production webpack --mode production",
"watch": "webpack --mode development --watch --watch-options-stdin",
"format": "standard --fix",
"test": "standard"
},
"dependencies": {
"@fontsource/nunito-sans": "^4.5.10",
"@fortawesome/fontawesome-free": "^6.3.0",
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view",
"topbar": "^2.0.1"
"@fontsource/nunito-sans": "^5.2.5",
"@fortawesome/fontawesome-free": "^6.7.2",
"topbar": "^3.0.0"
},
"devDependencies": {
"@babel/core": "^7.21.3",
"@babel/preset-env": "^7.20.2",
"autoprefixer": "^10.4.14",
"babel-loader": "^9.1.2",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.7.3",
"css-minimizer-webpack-plugin": "^4.2.2",
"file-loader": "^6.2.0",
"mini-css-extract-plugin": "^2.7.5",
"npm-check-updates": "^16.7.12",
"postcss": "^8.4.21",
"postcss-import": "^15.1.0",
"postcss-loader": "^7.1.0",
"postcss-preset-env": "^8.0.1",
"sass": "^1.59.3",
"sass-loader": "^13.2.1",
"standard": "^17.0.0",
"tailwindcss": "^3.2.7",
"terser-webpack-plugin": "^5.3.7",
"webpack": "^5.76.2",
"webpack-cli": "^5.0.1",
"webpack-dev-server": "^4.13.1"
"npm-check-updates": "^17.1.16",
"standard": "^17.1.2"
}
}

View File

@ -1,7 +0,0 @@
module.exports = {
plugins: {
'postcss-import': {},
tailwindcss: {},
autoprefixer: {}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@ -1,46 +0,0 @@
const colors = require('tailwindcss/colors')
module.exports = {
content: [
'../lib/**/*.{ex,heex,leex,eex}',
'./js/**/*.js'
],
theme: {
colors: {
transparent: 'transparent',
current: 'currentColor',
primary: colors.zinc,
black: colors.black,
white: colors.white,
gray: colors.neutral,
indigo: colors.indigo,
red: colors.rose,
yellow: colors.amber
},
fontFamily: {
sans: ['Nunito Sans', 'ui-sans-serif', 'system-ui', '-apple-system', 'BlinkMacSystemFont']
},
extend: {
spacing: {
128: '32rem',
192: '48rem',
256: '64rem'
},
minWidth: {
4: '1rem',
8: '2rem',
12: '3rem',
16: '4rem',
20: '8rem'
},
maxWidth: {
4: '1rem',
8: '2rem',
12: '3rem',
16: '4rem',
20: '8rem'
}
}
},
plugins: []
}

View File

@ -1,57 +0,0 @@
const path = require('path')
const glob = require('glob')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const TerserPlugin = require('terser-webpack-plugin')
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin')
const CopyWebpackPlugin = require('copy-webpack-plugin')
module.exports = (env, options) => {
const devMode = options.mode !== 'production'
return {
optimization: {
minimizer: [
new TerserPlugin({ parallel: true, extractComments: true }),
new CssMinimizerPlugin({})
]
},
entry: {
app: glob.sync('./vendor/**/*.js').concat(['./js/app.js'])
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, '../priv/static/js'),
publicPath: '/js/'
},
devtool: devMode ? 'eval-cheap-module-source-map' : undefined,
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader'
}
},
{
test: /\.s?css$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'postcss-loader',
'sass-loader'
]
},
{
test: /\.(woff(2)?|ttf|eot|svg|otf)(\?v=[0-9]\.[0-9]\.[0-9])?$/,
type: 'asset/resource',
generator: { filename: 'fonts/[name][ext]' }
}
]
},
plugins: [
new MiniCssExtractPlugin({ filename: '../css/app.css' }),
new CopyWebpackPlugin({ patterns: [{ from: 'static/', to: '../' }] })
]
}
}

View File

@ -1,9 +1,70 @@
# v0.1.20
- Update deps
- Improve accuracy of timestamps
# v0.1.19
- Add backlinks
- Fix visibility issues with multiple users
# v0.1.18
- Update deps
- Fix content not escaping HTML properly
- Add placeholder for empty notes and contexts
- Marks some required fields as required
# v0.1.17
- Fix new invite button not working
- Fix some descriptions possibly overflowing widths
- Update dependencies
# v0.1.16
- Fix empty invite index page
- Fix faq copy
- Fix issue with emails
- Update deps
# v0.1.15
- Sanitize titles while they are being typed
- Sanitize tags while they are being typed
- Remove requirement for note and content to have content
- Prevent possible additional submissions
- Fix content being displayed when blank
# v0.1.14
- Fix issue with item content not able to be displayed sometimes
# v0.1.13
- Update dependencies
- Fix debounces
- Allow space as delimiter for tags
- Add bottom padding to page
- Make pipeline step not require content
- Make content previews resizable
- Fix live flashes not dismissable by click
- Fix disconnection modal not displaying
- Submit items with ctrl-enter
- Display backlinks in pipeline description
- Modify backlink format
# v0.1.12
- Code quality fixes
- Fix error/404 pages not rendering properly
- Update dependencies
# v0.1.11
- Update dependencies
- ee cummings even more
- Improve tests
- Change invite path slightly
- Disable arm builds since ci fails to build
# v0.1.10
- Improve accessibility
- Code quality improvements
- Fix dates displaying incorrectly
- Add links to readme for github mirror
- Add license (whoops)
- Display links in note/context/step contents
# v0.1.9
- Improve server log

View File

@ -8,26 +8,31 @@
import Config
config :memex,
ecto_repos: [Memex.Repo],
generators: [binary_id: true]
env: :dev,
ecto_repos: [Memex.Repo]
config :memex, Memex.Accounts, registration: System.get_env("REGISTRATION", "invite")
# Configures the endpoint
config :memex, MemexWeb.Endpoint,
adapter: Bandit.PhoenixAdapter,
url: [scheme: "https", host: System.get_env("HOST") || "localhost", port: "443"],
http: [port: String.to_integer(System.get_env("PORT") || "4000")],
secret_key_base: "KH59P0iZixX5gP/u+zkxxG8vAAj6vgt0YqnwEB5JP5K+E567SsqkCz69uWShjE7I",
render_errors: [view: MemexWeb.ErrorView, accepts: ~w(html json), layout: false],
render_errors: [
formats: [html: MemexWeb.ErrorHTML, json: MemexWeb.ErrorJSON],
layout: false
],
pubsub_server: Memex.PubSub,
live_view: [signing_salt: "zOLgd3lr"]
config :memex, Memex.Application, automigrate: false
config :memex, :generators,
migration: true,
binary_id: true,
sample_binary_id: "11111111-1111-1111-1111-111111111111"
migration: true,
sample_binary_id: "11111111-1111-1111-1111-111111111111",
timestamp_type: :utc_datetime_usec
# Configures the mailer
#
@ -51,14 +56,25 @@ config :memex, Oban,
queues: [default: 10, mailers: 20]
# Configure esbuild (the version is required)
# config :esbuild,
# version: "0.14.0",
# default: [
# args:
# ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
# cd: Path.expand("../assets", __DIR__),
# env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
# ]
config :esbuild,
version: "0.17.11",
memex: [
args:
~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/* --loader:.woff=file --loader:.woff2=file),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]
# Configure tailwind (the version is required)
config :tailwind,
version: "4.0.0",
memex: [
args: ~w(
--input=css/style.css
--output=../priv/static/assets/style.css
),
cd: Path.expand("../assets", __DIR__)
]
# Configures Elixir's Logger
config :logger, :console,

View File

@ -2,6 +2,7 @@ import Config
# Configure your database
config :memex, Memex.Repo,
stacktrace: true,
show_sensitive_data_on_connection_error: true,
pool_size: 10
@ -12,21 +13,14 @@ config :memex, Memex.Repo,
# watchers to your application. For example, we use it
# with esbuild to bundle .js and .css sources.
config :memex, MemexWeb.Endpoint,
http: [ip: {0, 0, 0, 0}, port: 4000],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "dg2lccMgaY3+ZeKppR+ondk4ZRaANZGIN0LMZT1u1uzscH4jO5W9a9b9V9BkC+MW",
watchers: [
# Start the esbuild watcher by calling Esbuild.install_and_run(:default, args)
# esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}
node: [
"node_modules/webpack/bin/webpack.js",
"--mode",
"development",
"--watch",
"--watch-options-stdin",
cd: Path.expand("../assets", __DIR__)
]
esbuild: {Esbuild, :install_and_run, [:memex, ~w(--sourcemap=inline --watch)]},
tailwind: {Tailwind, :install_and_run, [:memex, ~w(--watch)]}
]
# ## SSL Support
@ -57,10 +51,9 @@ config :memex, MemexWeb.Endpoint,
config :memex, MemexWeb.Endpoint,
live_reload: [
patterns: [
~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$",
~r"lib/memex_web/(live|views)/.*(ex)$",
~r"lib/memex_web/templates/.*(eex)$"
~r"lib/memex_web/*/.*(ex)$"
]
]
@ -74,3 +67,9 @@ config :phoenix, :stacktrace_depth, 20
# Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime
config :phoenix_live_view,
# Include HEEx debug annotations as HTML comments in rendered markup
debug_heex_annotations: true,
# Enable helpful, but potentially expensive runtime checks
enable_expensive_runtime_checks: true

View File

@ -14,6 +14,8 @@ config :memex, MemexWeb.Endpoint, cache_static_manifest: "priv/static/cache_mani
# Do not print debug messages in production
config :logger, level: :info
config :memex, env: :prod
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key

View File

@ -7,11 +7,21 @@ import Config
# any compile-time configuration in here, as it won't be applied.
# The block below contains prod specific runtime configuration.
# Start the phoenix server if environment is set and running in a release
if System.get_env("PHX_SERVER") && System.get_env("RELEASE_NAME") do
# ## Using releases
#
# If you use `mix release`, you need to explicitly enable the server
# by passing the PHX_SERVER=true when you start it:
#
# PHX_SERVER=true bin/memex start
#
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
# script that automatically sets the env var above.
if System.get_env("PHX_SERVER") do
config :memex, MemexWeb.Endpoint, server: true
end
config :memex, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
# Set default locale
config :gettext, :default_locale, System.get_env("LOCALE", "en_US")
@ -66,7 +76,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 running: mix phx.gen.secret
You can generate one by running: priv/random.sh
"""
config :memex, MemexWeb.Endpoint, secret_key_base: secret_key_base

View File

@ -9,8 +9,9 @@ config :bcrypt_elixir, :log_rounds, 1
# to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information.
config :memex, Memex.Repo,
pool_size: 10,
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: 10
timeout: 60000
# We don't run a server during test. If one is required,
# you can enable the server option below.
@ -19,6 +20,8 @@ config :memex, MemexWeb.Endpoint,
secret_key_base: "S3qq9QtUdsFtlYej+HTjAVN95uP5i5tf2sPYINWSQfCKJghFj2B1+wTAoljZyHOK",
server: false
config :memex, env: :test
# In test we don't send emails.
config :memex, Memex.Mailer, adapter: Swoosh.Adapters.Test
@ -26,10 +29,10 @@ config :memex, Memex.Mailer, adapter: Swoosh.Adapters.Test
config :memex, Memex.Accounts, registration: "public"
# Print only warnings and errors during test
config :logger, level: :warn
config :logger, level: :warning
# Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime
# Disable Oban
config :memex, Oban, queues: false, plugins: false
config :memex, Oban, queues: false, plugins: false, testing: :manual

View File

@ -1,5 +1,16 @@
# Contribution Guide
## Translations needed!
[![translation
status](https://weblate.bubbletea.dev/widgets/memex/-/287x66-black.png)](https://weblate.bubbletea.dev/engage/memex)
If you're multilingual, this project can use your translations! Visit
[weblate](https://weblate.bubbletea.dev/engage/memex/) for more information.
Also, if your language isn't displayed here, I'd love to add that language so
you can start! Please contact me at
(shibao@bubbletea.dev)[mailto:shibao@bubbletea.dev] and let me know!
## Style Tips
- In order to keep code concise and improve readability, please try to make your
@ -113,7 +124,7 @@ In `test` mode (or in the Docker container), memEx will listen for the same envi
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.
with `docker run -it shibaobun/memex priv/random.sh` and set for server to start.
- `SMTP_HOST`: The url for your SMTP email provider. Must be set
- `SMTP_PORT`: The port for your SMTP relay. Defaults to `587`.
- `SMTP_USERNAME`: The username for your SMTP relay. Must be set!

BIN
home.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 340 KiB

View File

@ -6,4 +6,34 @@ defmodule Memex do
Contexts are also responsible for managing your data, regardless
if it comes from the database, an external API or others.
"""
def context do
quote do
use Gettext, backend: MemexWeb.Gettext
import Ecto.Query
alias Ecto.{Changeset, Multi, Queryable, UUID}
alias Memex.Accounts.User
alias Memex.Repo
end
end
def schema do
quote do
use Ecto.Schema
use Gettext, backend: MemexWeb.Gettext
import Ecto.{Changeset, Query}
alias Ecto.{Association, Changeset, Queryable, UUID}
alias Memex.Accounts.User
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
end
end
@doc """
When used, dispatch to the appropriate context/schema/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end

View File

@ -3,10 +3,9 @@ defmodule Memex.Accounts do
The Accounts context.
"""
import Ecto.Query, warn: false
alias Memex.{Mailer, Repo}
alias Memex.Accounts.{Invite, Invites, User, UserToken}
alias Ecto.{Changeset, Multi}
use Memex, :context
alias Memex.Mailer
alias Memex.Accounts.{Invite, Invites, UserToken}
alias Oban.Job
## Database getters
@ -117,7 +116,7 @@ defmodule Memex.Accounts 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
if allow_registration?() and !invite_token do
{:ok, nil}
else
Invites.use_invite(invite_token)
@ -219,7 +218,7 @@ defmodule Memex.Accounts do
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
%UserToken{sent_to: email} <- Repo.one(query),
{:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do
{:ok, _result} <- Repo.transaction(user_email_multi(user, email, context)) do
:ok
else
_error_tuple -> :error
@ -374,8 +373,8 @@ defmodule Memex.Accounts do
@doc """
Deletes the signed token with the given context.
"""
@spec delete_session_token(token :: String.t()) :: :ok
def delete_session_token(token) do
@spec delete_user_session_token(token :: String.t()) :: :ok
def delete_user_session_token(token) do
UserToken.token_and_context_query(token, "session") |> Repo.delete_all()
:ok
end
@ -405,15 +404,15 @@ defmodule Memex.Accounts do
## Examples
iex> is_admin?(%User{role: :admin})
iex> admin?(%User{role: :admin})
true
iex> is_admin?(%User{})
iex> admin?(%User{})
false
"""
@spec is_admin?(User.t()) :: boolean()
def is_admin?(%User{id: user_id}) do
@spec admin?(User.t()) :: boolean()
def admin?(%User{id: user_id}) do
Repo.exists?(from u in User, where: u.id == ^user_id, where: u.role == :admin)
end
@ -422,16 +421,16 @@ defmodule Memex.Accounts do
## Examples
iex> is_already_admin?(%User{role: :admin})
iex> already_admin?(%User{role: :admin})
true
iex> is_already_admin?(%User{})
iex> 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
@spec already_admin?(User.t() | nil) :: boolean()
def already_admin?(%User{role: :admin}), do: true
def already_admin?(_invalid_user), do: false
## Confirmation

View File

@ -1,48 +0,0 @@
defmodule Memex.Email do
@moduledoc """
Emails that can be sent using Swoosh.
You can find the base email templates at
`lib/memex_web/templates/layout/email.html.heex` for html emails and
`lib/memex_web/templates/layout/email.txt.heex` for text emails.
"""
use Phoenix.Swoosh, view: MemexWeb.EmailView, layout: {MemexWeb.LayoutView, :email}
import MemexWeb.Gettext
alias Memex.Accounts.User
alias MemexWeb.EmailView
@typedoc """
Represents an HTML and text body email that can be sent
"""
@type t() :: Swoosh.Email.t()
@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]
new() |> to(email) |> from({name, from}) |> subject(subject)
end
@spec generate_email(key :: String.t(), User.t(), attrs :: map()) :: t()
def generate_email("welcome", user, %{"url" => url}) do
user
|> base_email(dgettext("emails", "Confirm your Memex account"))
|> render_body("confirm_email.html", %{user: user, url: url})
|> text_body(EmailView.render("confirm_email.txt", %{user: user, url: url}))
end
def generate_email("reset_password", user, %{"url" => url}) do
user
|> base_email(dgettext("emails", "Reset your Memex password"))
|> render_body("reset_password.html", %{user: user, url: url})
|> text_body(EmailView.render("reset_password.txt", %{user: user, url: url}))
end
def generate_email("update_email", user, %{"url" => url}) do
user
|> base_email(dgettext("emails", "Update your Memex email"))
|> render_body("update_email.html", %{user: user, url: url})
|> text_body(EmailView.render("update_email.txt", %{user: user, url: url}))
end
end

View File

@ -5,24 +5,19 @@ defmodule Memex.Accounts.Invite do
`:uses_left` is defined.
"""
use Ecto.Schema
import Ecto.Changeset
alias Ecto.{Association, Changeset, UUID}
alias Memex.Accounts.User
use Memex, :schema
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "invites" do
field :name, :string
field :token, :string
field :uses_left, :integer, default: nil
field :disabled_at, :naive_datetime
field :disabled_at, :utc_datetime_usec
belongs_to :created_by, User
has_many :users, User
timestamps()
timestamps(type: :utc_datetime_usec)
end
@type t :: %__MODULE__{
@ -30,12 +25,12 @@ defmodule Memex.Accounts.Invite do
name: String.t(),
token: token(),
uses_left: integer() | nil,
disabled_at: NaiveDateTime.t(),
disabled_at: DateTime.t(),
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()
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
@type new_invite :: %__MODULE__{}
@type id :: UUID.t()

View File

@ -3,10 +3,8 @@ defmodule Memex.Accounts.Invites do
The Invites context.
"""
import Ecto.Query, warn: false
alias Ecto.Multi
alias Memex.Accounts.{Invite, User}
alias Memex.Repo
use Memex, :context
alias Memex.Accounts.Invite
@invite_token_length 20
@ -125,7 +123,7 @@ defmodule Memex.Accounts.Invites do
end
defp decrement_invite_changeset(%Invite{uses_left: 1} = invite) do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
now = DateTime.utc_now()
invite |> Invite.update_changeset(%{uses_left: 0, disabled_at: now})
end

View File

@ -3,11 +3,8 @@ defmodule Memex.Accounts.User do
A Memex user
"""
use Ecto.Schema
import Ecto.Changeset
import MemexWeb.Gettext
alias Ecto.{Association, Changeset, UUID}
alias Memex.Accounts.{Invite, User}
use Memex, :schema
alias Memex.Accounts.Invite
@derive {Jason.Encoder,
only: [
@ -20,13 +17,12 @@ defmodule Memex.Accounts.User do
:updated_at
]}
@derive {Inspect, except: [:password]}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users" do
field :email, :string
field :password, :string, virtual: true
field :hashed_password, :string
field :confirmed_at, :naive_datetime
field :current_password, :string, virtual: true, redact: true
field :confirmed_at, :utc_datetime_usec
field :role, Ecto.Enum, values: [:admin, :user], default: :user
field :locale, :string
@ -34,7 +30,7 @@ defmodule Memex.Accounts.User do
belongs_to :invite, Invite
timestamps()
timestamps(type: :utc_datetime_usec)
end
@type t :: %User{
@ -42,14 +38,14 @@ defmodule Memex.Accounts.User do
email: String.t(),
password: String.t(),
hashed_password: String.t(),
confirmed_at: NaiveDateTime.t(),
confirmed_at: DateTime.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()
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
@type new_user :: %User{}
@type id :: UUID.t()
@ -140,7 +136,7 @@ defmodule Memex.Accounts.User do
|> cast(attrs, [:email])
|> validate_email()
|> case do
%{changes: %{email: _}} = changeset -> changeset
%{changes: %{email: _email}} = changeset -> changeset
%{} = changeset -> add_error(changeset, :email, dgettext("errors", "did not change"))
end
end
@ -171,7 +167,7 @@ defmodule Memex.Accounts.User do
"""
@spec confirm_changeset(t() | changeset()) :: changeset()
def confirm_changeset(user_or_changeset) do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
now = DateTime.utc_now()
user_or_changeset |> change(confirmed_at: now)
end
@ -197,6 +193,8 @@ defmodule Memex.Accounts.User do
"""
@spec validate_current_password(changeset(), String.t()) :: changeset()
def validate_current_password(changeset, password) do
changeset = cast(changeset, %{current_password: password}, [:current_password])
if valid_password?(changeset.data, password),
do: changeset,
else: changeset |> add_error(:current_password, dgettext("errors", "is not valid"))

View File

@ -3,10 +3,7 @@ defmodule Memex.Accounts.UserToken do
Schema for a user's session token
"""
use Ecto.Schema
import Ecto.Query
alias Ecto.{Association, UUID}
alias Memex.Accounts.User
use Memex, :schema
@hash_algorithm :sha256
@rand_size 32
@ -18,8 +15,6 @@ defmodule Memex.Accounts.UserToken do
@change_email_validity_in_days 7
@session_validity_in_days 60
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users_tokens" do
field :token, :binary
field :context, :string
@ -37,7 +32,7 @@ defmodule Memex.Accounts.UserToken do
sent_to: String.t(),
user: User.t() | Association.NotLoaded.t(),
user_id: User.id() | nil,
inserted_at: NaiveDateTime.t()
inserted_at: DateTime.t()
}
@type new_user_token :: %__MODULE__{}
@type id :: UUID.t()
@ -155,7 +150,7 @@ defmodule Memex.Accounts.UserToken do
from t in __MODULE__, where: t.user_id == ^user.id
end
def user_and_contexts_query(user, [_ | _] = contexts) do
def user_and_contexts_query(user, [_head | _rest] = contexts) do
from t in __MODULE__, where: t.user_id == ^user.id and t.context in ^contexts
end
end

View File

@ -15,6 +15,7 @@ defmodule Memex.Application do
MemexWeb.Telemetry,
# Start the PubSub system
{Phoenix.PubSub, name: Memex.PubSub},
{DNSCluster, query: Application.get_env(:memex, :dns_cluster_query) || :ignore},
# Start the Endpoint (http/https)
MemexWeb.Endpoint,
# Add Oban

View File

@ -3,8 +3,8 @@ defmodule Memex.Contexts do
The Contexts context.
"""
import Ecto.Query, warn: false
alias Memex.{Accounts.User, Contexts.Context, Repo}
use Memex, :context
alias Memex.Contexts.Context
@doc """
Returns the list of contexts.
@ -22,16 +22,16 @@ defmodule Memex.Contexts do
@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)
def list_contexts(search, %{id: user_id}) when user_id |> is_binary() and search in [nil, ""] do
Repo.all(from c in Context, order_by: c.slug)
end
def list_contexts(search, %{id: user_id}) when search |> is_binary() do
def list_contexts(search, %{id: user_id})
when user_id |> is_binary() and 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', ?)",
@ -63,7 +63,7 @@ defmodule Memex.Contexts do
@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
def list_public_contexts(search) when search in [nil, ""] do
Repo.all(from c in Context, where: c.visibility == :public, order_by: c.slug)
end
@ -88,6 +88,42 @@ defmodule Memex.Contexts do
)
end
@doc """
Returns the list of contexts that link to a particular slug.
## Examples
iex> backlink(%User{id: 123})
[%Context{}, ...]
iex> backlink("[other-context]", %User{id: 123})
[%Context{content: "[other-context]"}, ...]
"""
@spec backlink(String.t(), User.t()) :: [Context.t()]
def backlink(link, %{id: user_id}) when user_id |> is_binary() do
link = link |> String.replace("[", "\\[") |> String.replace("]", "\\]")
link_regex = "(^|[^\[])#{link}($|[^\]])"
Repo.all(
from c in Context,
where: fragment("? ~ ?", c.content, ^link_regex),
order_by: c.slug
)
end
def backlink(link, _invalid_user) do
link = link |> String.replace("[", "\\[") |> String.replace("]", "\\]")
link_regex = "(^|[^\[])#{link}($|[^\]])"
Repo.all(
from c in Context,
where: fragment("? ~ ?", c.content, ^link_regex),
where: c.visibility == :public,
order_by: c.slug
)
end
@doc """
Gets a single context.
@ -103,12 +139,8 @@ defmodule Memex.Contexts do
"""
@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]
)
def get_context!(id, %{id: user_id}) when user_id |> is_binary() do
Repo.one!(from c in Context, where: c.id == ^id)
end
def get_context!(id, _invalid_user) do
@ -134,12 +166,8 @@ defmodule Memex.Contexts do
"""
@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]
)
def get_context_by_slug(slug, %{id: user_id}) when user_id |> is_binary() do
Repo.one(from c in Context, where: c.slug == ^slug)
end
def get_context_by_slug(slug, _invalid_user) do
@ -194,23 +222,16 @@ defmodule Memex.Contexts do
## Examples
iex> delete_context(%Context{user_id: 123}, %User{id: 123})
{:ok, %Context{}}
iex> delete_context(%Context{user_id: 123}, %User{role: :admin})
{:ok, %Context{}}
iex> delete_context(%Context{}, %User{id: 123})
{:ok, %Context{}}
iex> delete_context(%Context{}, nil)
{:error, %Ecto.Changeset{}}
"""
@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
def delete_context(%Context{} = context, %{id: user_id}) when user_id |> is_binary() do
context |> Repo.delete()
end
@ -228,13 +249,4 @@ defmodule Memex.Contexts do
def change_context(%Context{} = context, attrs \\ %{}, user) do
context |> Context.update_changeset(attrs, user)
end
@spec is_owner_or_admin?(Context.t(), User.t()) :: boolean()
def is_owner_or_admin?(%{user_id: user_id}, %{id: user_id}), do: true
def is_owner_or_admin?(_context, %{role: :admin}), do: true
def is_owner_or_admin?(_context, _other_user), do: false
@spec is_owner?(Context.t(), User.t()) :: boolean()
def is_owner?(%{user_id: user_id}, %{id: user_id}), do: true
def is_owner?(_context, _other_user), do: false
end

View File

@ -3,12 +3,10 @@ defmodule Memex.Contexts.Context do
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}
use Memex, :schema
@derive {Phoenix.Param, key: :slug}
@derive {Jason.Encoder,
only: [
:slug,
@ -18,8 +16,6 @@ defmodule Memex.Contexts.Context do
: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
@ -29,7 +25,7 @@ defmodule Memex.Contexts.Context do
field :user_id, :binary_id
timestamps()
timestamps(type: :utc_datetime_usec)
end
@type t :: %__MODULE__{
@ -39,8 +35,8 @@ defmodule Memex.Contexts.Context do
tags_string: String.t() | nil,
visibility: :public | :private | :unlisted,
user_id: User.id(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
@type id :: UUID.t()
@type slug :: String.t()
@ -56,33 +52,34 @@ defmodule Memex.Contexts.Context do
|> 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])
|> validate_required([:slug, :user_id, :visibility])
|> unique_constraint(:slug)
|> unsafe_validate_unique(:slug, Repo)
|> unsafe_validate_unique(:slug, Memex.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
def update_changeset(%__MODULE__{} = context, attrs, %User{id: user_id})
when user_id |> is_binary() do
context
|> 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])
|> validate_required([:slug, :visibility])
|> unique_constraint(:slug)
|> unsafe_validate_unique(:slug, Repo)
|> unsafe_validate_unique(:slug, Memex.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}\-\,]+$/,
|> 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"
"invalid format: only numbers, letters and hyphen are accepted. tags must be comma or space delimited"
)
)
|> cast_tags()
@ -97,9 +94,9 @@ defmodule Memex.Contexts.Context do
defp process_tags(tags_string) when tags_string |> is_binary() do
tags_string
|> String.split(",", trim: true)
|> String.split([",", " "], trim: true)
|> Enum.map(fn str -> str |> String.trim() end)
|> Enum.reject(fn str -> str |> is_nil() end)
|> Enum.reject(fn str -> str in [nil, ""] end)
|> Enum.sort()
end

56
lib/memex/email.ex Normal file
View File

@ -0,0 +1,56 @@
defmodule Memex.Email do
@moduledoc """
Emails that can be sent using Swoosh.
You can find the base email templates at
`lib/memex_web/components/layouts/email_html.html.heex` for html emails and
`lib/memex_web/components/layouts/email_text.txt.eex` for text emails.
"""
use Gettext, backend: MemexWeb.Gettext
import Swoosh.Email
import Phoenix.Template
alias Memex.Accounts.User
alias MemexWeb.{EmailHTML, Layouts}
@typedoc """
Represents an HTML and text body email that can be sent
"""
@type t() :: Swoosh.Email.t()
@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]
new() |> to(email) |> from({name, from}) |> subject(subject)
end
@spec generate_email(key :: String.t(), User.t(), attrs :: map()) :: t()
def generate_email("welcome", user, %{"url" => url}) do
user
|> base_email(dgettext("emails", "confirm your memEx account"))
|> render_body(:confirm_email, %{user: user, url: url})
end
def generate_email("reset_password", user, %{"url" => url}) do
user
|> base_email(dgettext("emails", "reset your memEx password"))
|> render_body(:reset_password, %{user: user, url: url})
end
def generate_email("update_email", user, %{"url" => url}) do
user
|> base_email(dgettext("emails", "update your memEx email"))
|> render_body(:update_email, %{user: user, url: url})
end
defp render_body(email, template, assigns) do
html_heex = apply(EmailHTML, String.to_existing_atom("#{template}_html"), [assigns])
html = render_to_string(Layouts, "email_html", "html", email: email, inner_content: html_heex)
text_heex = apply(EmailHTML, String.to_existing_atom("#{template}_text"), [assigns])
text = render_to_string(Layouts, "email_text", "text", email: email, inner_content: text_heex)
email |> html_body(html) |> text_body(text)
end
end

View File

@ -14,17 +14,17 @@ defmodule Memex.Logger do
|> Map.put(:stacktrace, Exception.format_stacktrace(stacktrace))
|> pretty_encode()
Logger.error(meta.reason, data: data)
Logger.error("#{meta.reason} #{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)
Logger.info("Started oban job: #{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)
Logger.info("Finished oban job: #{data}")
end
def handle_event([:oban, :job, unhandled_event], measure, meta, _config) do
@ -33,7 +33,7 @@ defmodule Memex.Logger do
|> Map.put(:event, unhandled_event)
|> pretty_encode()
Logger.warning("Unhandled oban job event", data: data)
Logger.warning("Unhandled oban job event: #{data}")
end
def handle_event(unhandled_event, measure, meta, config) do
@ -45,7 +45,7 @@ defmodule Memex.Logger do
config: config
})
Logger.warning("Unhandled telemetry event", data: data)
Logger.warning("Unhandled telemetry event: #{data}")
end
defp get_oban_job_data(%{job: job}, measure) do

View File

@ -3,8 +3,8 @@ defmodule Memex.Notes do
The Notes context.
"""
import Ecto.Query, warn: false
alias Memex.{Accounts.User, Notes.Note, Repo}
use Memex, :context
alias Memex.Notes.Note
@doc """
Returns the list of notes.
@ -22,16 +22,15 @@ defmodule Memex.Notes do
@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)
def list_notes(search, %{id: user_id}) when user_id |> is_binary() and search in [nil, ""] do
Repo.all(from n in Note, order_by: n.slug)
end
def list_notes(search, %{id: user_id}) when search |> is_binary() do
def list_notes(search, %{id: user_id}) when user_id |> is_binary() and 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', ?)",
@ -62,7 +61,7 @@ defmodule Memex.Notes do
@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
def list_public_notes(search) when search in [nil, ""] do
Repo.all(from n in Note, where: n.visibility == :public, order_by: n.slug)
end
@ -87,6 +86,42 @@ defmodule Memex.Notes do
)
end
@doc """
Returns the list of notes that link to a particular slug.
## Examples
iex> backlink(%User{id: 123})
[%Note{}, ...]
iex> backlink("[other-note]", %User{id: 123})
[%Note{content: "[other-note]"}, ...]
"""
@spec backlink(String.t(), User.t()) :: [Note.t()]
def backlink(link, %{id: user_id}) when user_id |> is_binary() do
link = link |> String.replace("[", "\\[") |> String.replace("]", "\\]")
link_regex = "(^|[^\[])#{link}($|[^\]])"
Repo.all(
from n in Note,
where: fragment("? ~ ?", n.content, ^link_regex),
order_by: n.slug
)
end
def backlink(link, _invalid_user) do
link = link |> String.replace("[", "\\[") |> String.replace("]", "\\]")
link_regex = "(^|[^\[])#{link}($|[^\]])"
Repo.all(
from n in Note,
where: fragment("? ~ ?", n.content, ^link_regex),
where: n.visibility == :public,
order_by: n.slug
)
end
@doc """
Gets a single note.
@ -102,12 +137,8 @@ defmodule Memex.Notes do
"""
@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]
)
def get_note!(id, %{id: user_id}) when user_id |> is_binary() do
Repo.one!(from n in Note, where: n.id == ^id)
end
def get_note!(id, _invalid_user) do
@ -133,12 +164,8 @@ defmodule Memex.Notes do
"""
@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]
)
def get_note_by_slug(slug, %{id: user_id}) when user_id |> is_binary() do
Repo.one(from n in Note, where: n.slug == ^slug)
end
def get_note_by_slug(slug, _invalid_user) do
@ -192,22 +219,15 @@ defmodule Memex.Notes do
## Examples
iex> delete_note(%Note{user_id: 123}, %User{id: 123})
{:ok, %Note{}}
iex> delete_note(%Note{}, %User{role: :admin})
{:ok, %Note{}}
iex> delete_note(%Note{}, %User{id: 123})
{:ok, %Note{}}
iex> delete_note(%Note{}, nil)
{:error, %Ecto.Changeset{}}
"""
@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
def delete_note(%Note{} = note, %{id: user_id}) when user_id |> is_binary() do
note |> Repo.delete()
end
@ -228,13 +248,4 @@ defmodule Memex.Notes do
def change_note(%Note{} = note, attrs \\ %{}, user) do
note |> Note.update_changeset(attrs, user)
end
@spec is_owner_or_admin?(Note.t(), User.t()) :: boolean()
def is_owner_or_admin?(%{user_id: user_id}, %{id: user_id}), do: true
def is_owner_or_admin?(_context, %{role: :admin}), do: true
def is_owner_or_admin?(_context, _other_user), do: false
@spec is_owner?(Note.t(), User.t()) :: boolean()
def is_owner?(%{user_id: user_id}, %{id: user_id}), do: true
def is_owner?(_context, _other_user), do: false
end

View File

@ -2,12 +2,10 @@ 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}
use Memex, :schema
@derive {Phoenix.Param, key: :slug}
@derive {Jason.Encoder,
only: [
:slug,
@ -17,8 +15,6 @@ defmodule Memex.Notes.Note do
: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
@ -28,7 +24,7 @@ defmodule Memex.Notes.Note do
field :user_id, :binary_id
timestamps()
timestamps(type: :utc_datetime_usec)
end
@type t :: %__MODULE__{
@ -38,8 +34,8 @@ defmodule Memex.Notes.Note do
tags_string: String.t() | nil,
visibility: :public | :private | :unlisted,
user_id: User.id(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
@type id :: UUID.t()
@type slug :: String.t()
@ -55,33 +51,34 @@ defmodule Memex.Notes.Note do
|> 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])
|> validate_required([:slug, :user_id, :visibility])
|> unique_constraint(:slug)
|> unsafe_validate_unique(:slug, Repo)
|> unsafe_validate_unique(:slug, Memex.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
def update_changeset(%__MODULE__{} = note, attrs, %User{id: user_id})
when user_id |> is_binary() 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])
|> validate_required([:slug, :visibility])
|> unique_constraint(:slug)
|> unsafe_validate_unique(:slug, Repo)
|> unsafe_validate_unique(:slug, Memex.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}\-\,]+$/,
|> 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"
"invalid format: only numbers, letters and hyphen are accepted. tags must be comma or space delimited"
)
)
|> cast_tags()
@ -96,9 +93,9 @@ defmodule Memex.Notes.Note do
defp process_tags(tags_string) when tags_string |> is_binary() do
tags_string
|> String.split(",", trim: true)
|> String.split([",", " "], trim: true)
|> Enum.map(fn str -> str |> String.trim() end)
|> Enum.reject(fn str -> str |> is_nil() end)
|> Enum.reject(fn str -> str in [nil, ""] end)
|> Enum.sort()
end

View File

@ -3,8 +3,8 @@ defmodule Memex.Pipelines do
The Pipelines context.
"""
import Ecto.Query, warn: false
alias Memex.{Accounts.User, Pipelines.Pipeline, Repo}
use Memex, :context
alias Memex.Pipelines.Pipeline
@doc """
Returns the list of pipelines.
@ -22,16 +22,17 @@ defmodule Memex.Pipelines do
@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)
def list_pipelines(search, %{id: user_id})
when user_id |> is_binary() and search in [nil, ""] do
Repo.all(from p in Pipeline, order_by: p.slug)
end
def list_pipelines(search, %{id: user_id}) when search |> is_binary() do
def list_pipelines(search, %{id: user_id})
when user_id |> is_binary() and 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', ?)",
@ -62,7 +63,7 @@ defmodule Memex.Pipelines do
@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
def list_public_pipelines(search) when search in [nil, ""] do
Repo.all(from p in Pipeline, where: p.visibility == :public, order_by: p.slug)
end
@ -102,12 +103,8 @@ defmodule Memex.Pipelines do
"""
@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]
)
def get_pipeline!(id, %{id: user_id}) when user_id |> is_binary() do
Repo.one!(from p in Pipeline, where: p.id == ^id)
end
def get_pipeline!(id, _invalid_user) do
@ -133,12 +130,8 @@ defmodule Memex.Pipelines do
"""
@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]
)
def get_pipeline_by_slug(slug, %{id: user_id}) when user_id |> is_binary() do
Repo.one(from p in Pipeline, where: p.slug == ^slug)
end
def get_pipeline_by_slug(slug, _invalid_user) do
@ -149,6 +142,50 @@ defmodule Memex.Pipelines do
)
end
@doc """
Returns the list of pipelines that link to a particular slug.
## Examples
iex> backlink(%User{id: 123})
[%Pipeline{}, ...]
iex> backlink("[other-pipeline]", %User{id: 123})
[%Pipeline{description: "[other-pipeline]"}, ...]
"""
@spec backlink(String.t(), User.t()) :: [Pipeline.t()]
def backlink(link, %{id: user_id}) when user_id |> is_binary() do
link = link |> String.replace("[", "\\[") |> String.replace("]", "\\]")
link_regex = "(^|[^\[])#{link}($|[^\]])"
Repo.all(
from p in Pipeline,
left_join: s in assoc(p, :steps),
where:
fragment("? ~ ?", p.description, ^link_regex) or
fragment("? ~ ?", s.content, ^link_regex),
distinct: true,
order_by: p.slug
)
end
def backlink(link, _invalid_user) do
link = link |> String.replace("[", "\\[") |> String.replace("]", "\\]")
link_regex = "(^|[^\[])#{link}($|[^\]])"
Repo.all(
from p in Pipeline,
left_join: s in assoc(p, :steps),
where:
fragment("? ~ ?", p.description, ^link_regex) or
fragment("? ~ ?", s.content, ^link_regex),
where: p.visibility == :public,
distinct: true,
order_by: p.slug
)
end
@doc """
Creates a pipeline.
@ -193,23 +230,16 @@ defmodule Memex.Pipelines do
## Examples
iex> delete_pipeline(%Pipeline{user_id: 123}, %User{id: 123})
{:ok, %Pipeline{}}
iex> delete_pipeline(%Pipeline{}, %User{role: :admin})
{:ok, %Pipeline{}}
iex> delete_pipeline(%Pipeline{}, %User{id: 123})
{:ok, %Pipeline{}}
iex> delete_pipeline(%Pipeline{}, nil)
{:error, %Ecto.Changeset{}}
"""
@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
def delete_pipeline(%Pipeline{} = pipeline, %{id: user_id}) when user_id |> is_binary() do
pipeline |> Repo.delete()
end
@ -230,13 +260,4 @@ defmodule Memex.Pipelines do
def change_pipeline(%Pipeline{} = pipeline, attrs \\ %{}, user) do
pipeline |> Pipeline.update_changeset(attrs, user)
end
@spec is_owner_or_admin?(Pipeline.t(), User.t()) :: boolean()
def is_owner_or_admin?(%{user_id: user_id}, %{id: user_id}), do: true
def is_owner_or_admin?(_context, %{role: :admin}), do: true
def is_owner_or_admin?(_context, _other_user), do: false
@spec is_owner?(Pipeline.t(), User.t()) :: boolean()
def is_owner?(%{user_id: user_id}, %{id: user_id}), do: true
def is_owner?(_context, _other_user), do: false
end

View File

@ -2,12 +2,11 @@ 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}
use Memex, :schema
alias Memex.Pipelines.Steps.Step
@derive {Phoenix.Param, key: :slug}
@derive {Jason.Encoder,
only: [
:slug,
@ -18,8 +17,6 @@ defmodule Memex.Pipelines.Pipeline do
:steps,
:updated_at
]}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "pipelines" do
field :slug, :string
field :description, :string
@ -31,7 +28,7 @@ defmodule Memex.Pipelines.Pipeline do
has_many :steps, Step, preload_order: [asc: :position]
timestamps()
timestamps(type: :utc_datetime_usec)
end
@type t :: %__MODULE__{
@ -41,8 +38,8 @@ defmodule Memex.Pipelines.Pipeline do
tags_string: String.t() | nil,
visibility: :public | :private | :unlisted,
user_id: User.id(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
@type id :: UUID.t()
@type slug :: String.t()
@ -60,11 +57,12 @@ defmodule Memex.Pipelines.Pipeline do
)
|> validate_required([:slug, :user_id, :visibility])
|> unique_constraint(:slug)
|> unsafe_validate_unique(:slug, Repo)
|> unsafe_validate_unique(:slug, Memex.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
def update_changeset(%__MODULE__{} = pipeline, attrs, %User{id: user_id})
when user_id |> is_binary() do
pipeline
|> cast(attrs, [:slug, :description, :tags, :visibility])
|> cast_tags_string(attrs)
@ -73,18 +71,18 @@ defmodule Memex.Pipelines.Pipeline do
)
|> validate_required([:slug, :visibility])
|> unique_constraint(:slug)
|> unsafe_validate_unique(:slug, Repo)
|> unsafe_validate_unique(:slug, Memex.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}\-\,]+$/,
|> 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"
"invalid format: only numbers, letters and hyphen are accepted. tags must be comma or space delimited"
)
)
|> cast_tags()
@ -99,9 +97,9 @@ defmodule Memex.Pipelines.Pipeline do
defp process_tags(tags_string) when tags_string |> is_binary() do
tags_string
|> String.split(",", trim: true)
|> String.split([",", " "], trim: true)
|> Enum.map(fn str -> str |> String.trim() end)
|> Enum.reject(fn str -> str |> is_nil() end)
|> Enum.reject(fn str -> str in [nil, ""] end)
|> Enum.sort()
end

View File

@ -2,10 +2,9 @@ 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}
use Memex, :schema
alias Memex.Pipelines.Pipeline
@derive {Jason.Encoder,
only: [
@ -15,8 +14,6 @@ defmodule Memex.Pipelines.Steps.Step do
: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
@ -25,7 +22,7 @@ defmodule Memex.Pipelines.Steps.Step do
belongs_to :pipeline, Pipeline
field :user_id, :binary_id
timestamps()
timestamps(type: :utc_datetime_usec)
end
@type t :: %__MODULE__{
@ -35,8 +32,8 @@ defmodule Memex.Pipelines.Steps.Step do
pipeline: Pipeline.t() | Ecto.Association.NotLoaded.t(),
pipeline_id: Pipeline.id(),
user_id: User.id(),
inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t()
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
@type id :: UUID.t()
@type changeset :: Changeset.t(t())
@ -44,35 +41,32 @@ defmodule Memex.Pipelines.Steps.Step do
@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
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])
|> validate_required([:title, :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
def update_changeset(%__MODULE__{} = step, attrs, %User{id: user_id})
when user_id |> is_binary() do
step
|> cast(attrs, [:title, :content])
|> validate_required([:title, :content, :user_id, :position])
|> validate_required([:title, :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
def position_changeset(%__MODULE__{} = step, position, %User{id: user_id})
when user_id |> is_binary() do
step
|> change(position: position)
|> validate_required([:title, :content, :user_id, :position])
|> validate_required([:title, :user_id, :position])
end
end

View File

@ -3,9 +3,7 @@ defmodule Memex.Pipelines.Steps do
The context for steps within a pipeline
"""
import Ecto.Query, warn: false
alias Ecto.Multi
alias Memex.{Accounts.User, Repo}
use Memex, :context
alias Memex.Pipelines.{Pipeline, Steps.Step}
@doc """
@ -21,11 +19,10 @@ defmodule Memex.Pipelines.Steps do
"""
@spec list_steps(Pipeline.t(), User.t()) :: [Step.t()]
def list_steps(%{id: pipeline_id}, %{id: user_id}) do
def list_steps(%{id: pipeline_id}, %{id: user_id}) when user_id |> is_binary() do
Repo.all(
from s in Step,
where: s.pipeline_id == ^pipeline_id,
where: s.user_id == ^user_id,
order_by: s.position
)
end
@ -62,8 +59,8 @@ defmodule Memex.Pipelines.Steps do
"""
@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)
def get_step!(id, %{id: user_id}) when user_id |> is_binary() do
Repo.one!(from n in Step, where: n.id == ^id)
end
def get_step!(id, _invalid_user) do
@ -119,22 +116,15 @@ defmodule Memex.Pipelines.Steps do
## 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})
{:ok, %Step{}}
iex> delete_step(%Step{}, nil)
{: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
def delete_step(%Step{} = step, %{id: user_id}) when user_id |> is_binary() do
delete_step(step)
end
@ -181,10 +171,11 @@ defmodule Memex.Pipelines.Steps do
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,
%Step{position: position, pipeline_id: pipeline_id} = step,
:up,
%{id: user_id} = user
) do
)
when user_id |> is_binary() do
Multi.new()
|> Multi.update_all(
:reorder_steps,
@ -207,10 +198,11 @@ defmodule Memex.Pipelines.Steps do
end
def reorder_step(
%Step{pipeline_id: pipeline_id, position: position, user_id: user_id} = step,
%Step{pipeline_id: pipeline_id, position: position} = step,
:down,
%{id: user_id} = user
) do
)
when user_id |> is_binary() do
Multi.new()
|> Multi.one(
:step_count,

View File

@ -1,54 +1,62 @@
defmodule MemexWeb do
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, views, channels and so on.
as controllers, components, channels, and so on.
This can be used in your application as:
use MemexWeb, :controller
use MemexWeb, :view
use MemexWeb, :html
The definitions below will be executed for every view,
controller, etc, so keep them short and clean, focused
The definitions below will be executed for every controller,
component, etc, so keep them short and clean, focused
on imports, uses and aliases.
Do NOT define functions inside the quoted expressions
below. Instead, define any helper function in modules
and import those modules here.
below. Instead, define additional modules and import
those modules here.
"""
def controller do
quote do
use Phoenix.Controller, namespace: MemexWeb
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt webfonts)
def router do
quote do
use Phoenix.Router, helpers: false
# Import common connection and controller functions to use in pipelines
import Plug.Conn
import MemexWeb.Gettext
alias MemexWeb.Endpoint
alias MemexWeb.Router.Helpers, as: Routes
import Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def view do
def channel do
quote do
use Phoenix.View,
root: "lib/memex_web/templates",
namespace: MemexWeb
use Phoenix.Channel
end
end
# Import convenience functions from controllers
import Phoenix.Controller,
only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1]
def controller do
quote do
use Phoenix.Controller,
formats: [:html, :json],
layouts: [html: MemexWeb.Layouts]
# Include shared imports and aliases for views
unquote(view_helpers())
use Gettext, backend: MemexWeb.Gettext
import MemexWeb.ControllerHelpers
import Plug.Conn
unquote(verified_routes())
end
end
def live_view do
quote do
use Phoenix.LiveView, layout: {MemexWeb.LayoutView, :live}
use Phoenix.LiveView,
layout: {MemexWeb.Layouts, :app}
on_mount MemexWeb.InitAssigns
unquote(view_helpers())
unquote(html_helpers())
end
end
@ -56,50 +64,45 @@ defmodule MemexWeb do
quote do
use Phoenix.LiveComponent
unquote(view_helpers())
unquote(html_helpers())
end
end
def component do
def html do
quote do
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
use Phoenix.Component
unquote(view_helpers())
# Import convenience functions from controllers
import Phoenix.Controller,
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
# Include general helpers for rendering HTML
unquote(html_helpers())
end
end
def router do
defp html_helpers do
quote do
use Phoenix.Router
use PhoenixHTMLHelpers
use Gettext, backend: MemexWeb.Gettext
import Phoenix.{Component, HTML, HTML.Form}
import MemexWeb.{ErrorHelpers, CoreComponents, HTMLHelpers}
import Phoenix.{Controller, LiveView.Router}
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
import Plug.Conn
# Shortcut for generating JS commands
alias Phoenix.LiveView.JS
# Routes generation with the ~p sigil
unquote(verified_routes())
end
end
def channel do
def verified_routes do
quote do
use Phoenix.Channel
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
import MemexWeb.Gettext
end
end
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, link, <.form>, etc)
# Import basic rendering functionality (render, render_layout, etc)
import Phoenix.{Component, View}
import MemexWeb.{ErrorHelpers, Gettext, CoreComponents, ViewHelpers}
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
alias MemexWeb.Endpoint
alias MemexWeb.Router.Helpers, as: Routes
use Phoenix.VerifiedRoutes,
endpoint: MemexWeb.Endpoint,
router: MemexWeb.Router,
statics: MemexWeb.static_paths()
end
end

View File

@ -37,7 +37,7 @@ defmodule MemexWeb.Components.ContextsTableComponent do
} = socket
) do
columns =
if actions == [] or current_user |> is_nil() do
if actions == [] or !current_user do
[]
else
[%{label: gettext("actions"), key: :actions, sortable: false}]
@ -88,29 +88,21 @@ defmodule MemexWeb.Components.ContextsTableComponent do
@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}
defp get_value_for_key(:slug, %{slug: slug} = assigns, _additional_data) do
slug_block = ~H"""
<.link navigate={Routes.context_show_path(Endpoint, :show, @slug)} class="link">
<%= @slug %>
<.link navigate={~p"/context/#{@slug}"} class="link">
{@slug}
</.link>
"""
{slug, slug_block}
end
defp get_value_for_key(:tags, %{tags: tags}, _additional_data) do
assigns = %{tags: tags}
defp get_value_for_key(:tags, assigns, _additional_data) do
~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 :for={tag <- @tags} patch={~p"/contexts/#{tag}"} class="link">
{tag}
</.link>
</div>
"""
@ -121,7 +113,7 @@ defmodule MemexWeb.Components.ContextsTableComponent do
~H"""
<div class="flex justify-center items-center space-x-4">
<%= render_slot(@actions, @context) %>
{render_slot(@actions, @context)}
</div>
"""
end

View File

@ -2,14 +2,15 @@ defmodule MemexWeb.CoreComponents do
@moduledoc """
Provides core UI components.
"""
use PhoenixHTMLHelpers
use Phoenix.Component
import MemexWeb.{Gettext, ViewHelpers}
use MemexWeb, :verified_routes
use Gettext, backend: MemexWeb.Gettext
import MemexWeb.HTMLHelpers
alias Memex.{Accounts, Accounts.Invite, Accounts.User}
alias Memex.Contexts.Context
alias Memex.Notes.Note
alias Memex.Pipelines.Steps.Step
alias MemexWeb.{Endpoint, HomeLive}
alias MemexWeb.Router.Helpers, as: Routes
alias Memex.Pipelines.{Pipeline, Steps.Step}
alias Phoenix.HTML
alias Phoenix.LiveView.JS
@ -31,13 +32,13 @@ defmodule MemexWeb.CoreComponents do
## Examples
<.modal return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)}>
<.modal return_to={~p"/\#{<%= schema.plural %>}"}>
<.live_component
module={<%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.FormComponent}
id={@<%= schema.singular %>.id || :new}
title={@page_title}
action={@live_action}
return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)}
return_to={~p"/\#{<%= schema.singular %>}"}
<%= schema.singular %>: @<%= schema.singular %>
/>
</.modal>
@ -56,24 +57,6 @@ defmodule MemexWeb.CoreComponents do
attr :id, :string, default: nil
slot(:inner_block)
@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)
attr :user, User, required: true
slot(:inner_block, required: true)
def user_card(assigns)
attr :invite, Invite, required: true
@ -88,14 +71,14 @@ defmodule MemexWeb.CoreComponents do
attr :datetime, :any, required: true, doc: "A `DateTime` struct or nil"
@doc """
Phoenix.Component for a <time> element that renders the naivedatetime in the
Phoenix.Component for a <time> element that renders the DateTime in the
user's local timezone
"""
def datetime(assigns)
@spec cast_datetime(NaiveDateTime.t() | nil) :: String.t()
defp cast_datetime(%NaiveDateTime{} = datetime) do
datetime |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_iso8601(:extended)
@spec cast_datetime(DateTime.t() | nil) :: String.t()
defp cast_datetime(%DateTime{} = datetime) do
datetime |> DateTime.to_iso8601(:extended)
end
defp cast_datetime(_datetime), do: ""
@ -131,53 +114,128 @@ defmodule MemexWeb.CoreComponents do
def step_content(assigns)
defp add_links_to_content(content, data_qa_prefix) do
# replace links
attr :pipeline, Pipeline, required: true
# link regex from
# https://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url
# and modified with additional schemes from
# https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml
def pipeline_content(assigns)
content =
Regex.replace(
~r<((file|git|https?|ipfs|ipns|irc|jabber|magnet|mailto|mumble|tel|udp|xmpp):\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*))>,
content,
fn _whole_match, link ->
link =
HTML.Link.link(
link,
to: link,
class: "link inline",
target: "_blank",
rel: "noopener noreferrer"
)
|> HTML.Safe.to_iodata()
|> IO.iodata_to_binary()
defp display_links(record) do
record
|> get_content()
|> Phoenix.HTML.html_escape()
|> Phoenix.HTML.safe_to_string()
|> replace_hyperlinks(record)
|> replace_triple_links(record)
|> replace_double_links(record)
|> replace_single_links(record)
|> HTML.raw()
end
"</p>#{link}<p class=\"inline\">"
end
)
defp get_content(%{content: content}), do: content |> get_text()
defp get_content(%{description: description}), do: description |> get_text()
defp get_content(_fallthrough), do: nil |> get_text()
content =
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: "#{data_qa_prefix}-#{slug}"]
)
|> HTML.Safe.to_iodata()
|> IO.iodata_to_binary()
defp get_text(string) when is_binary(string), do: string
defp get_text(_fallthrough), do: ""
"</p>#{link}<p class=\"inline\">"
end
)
# replaces hyperlinks like https://bubbletea.dev
#
# link regex from
# https://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url
# and modified with additional schemes from
# https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml
defp replace_hyperlinks(content, _record) do
Regex.replace(
~r<((file|git|https?|ipfs|ipns|irc|jabber|magnet|mailto|mumble|tel|udp|xmpp):\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*))>,
content,
fn _whole_match, link ->
link =
link(
link,
to: link,
class: "link inline break-words",
target: "_blank",
rel: "noopener noreferrer"
)
|> HTML.Safe.to_iodata()
|> IO.iodata_to_binary()
content |> HTML.raw()
"</p>#{link}<p class=\"inline break-words\">"
end
)
end
# replaces triple links like [[[slug-title]]]
defp replace_triple_links(content, _record) do
Regex.replace(
~r/(^|[^\[])\[\[\[([\p{L}\p{N}\-]+)\]\]\]($|[^\]])/,
content,
fn _whole_match, prefix, slug, suffix ->
link =
link(
"[[[#{slug}]]]",
to: ~p"/note/#{slug}",
class: "link inline break-words"
)
|> HTML.Safe.to_iodata()
|> IO.iodata_to_binary()
"#{prefix}</p>#{link}<p class=\"inline break-words\">#{suffix}"
end
)
end
# replaces double links like [[slug-title]]
defp replace_double_links(content, record) do
Regex.replace(
~r/(^|[^\[])\[\[([\p{L}\p{N}\-]+)\]\]($|[^\]])/,
content,
fn _whole_match, prefix, slug, suffix ->
target =
case record do
%Pipeline{} -> ~p"/context/#{slug}"
%Step{} -> ~p"/context/#{slug}"
_context -> ~p"/note/#{slug}"
end
link =
link(
"[[#{slug}]]",
to: target,
class: "link inline break-words"
)
|> HTML.Safe.to_iodata()
|> IO.iodata_to_binary()
"#{prefix}</p>#{link}<p class=\"inline break-words\">#{suffix}"
end
)
end
# replaces single links like [slug-title]
defp replace_single_links(content, record) do
Regex.replace(
~r/(^|[^\[])\[([\p{L}\p{N}\-]+)\]($|[^\]])/,
content,
fn _whole_match, prefix, slug, suffix ->
target =
case record do
%Pipeline{} -> ~p"/pipeline/#{slug}"
%Step{} -> ~p"/pipeline/#{slug}"
%Context{} -> ~p"/context/#{slug}"
_note -> ~p"/note/#{slug}"
end
link =
link(
"[#{slug}]",
to: target,
class: "link inline break-words"
)
|> HTML.Safe.to_iodata()
|> IO.iodata_to_binary()
"#{prefix}</p>#{link}<p class=\"inline break-words\">#{suffix}"
end
)
end
end

View File

@ -1,8 +1,11 @@
<div
:if={@context.content}
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"
class="inline-block overflow-y-auto overflow-x-hidden whitespace-pre-wrap resize-y input input-primary h-128 min-h-128"
phx-update="ignore"
readonly
phx-no-format
><p class="inline"><%= add_links_to_content(@context.content, "context-note") %></p></div>
><p class="inline"><%= display_links(@context) %></p></div>
<div :if={!@context.content} class="text-sm italic text-center text-zinc-600">
{gettext("(This context is empty)")}
</div>

View File

@ -1,3 +1,3 @@
<time :if={@date} id={@id} datetime={Date.to_iso8601(@date, :extended)} phx-hook="Date">
<%= Date.to_iso8601(@date, :extended) %>
{Date.to_iso8601(@date, :extended)}
</time>

View File

@ -1,3 +1,3 @@
<time :if={@datetime} id={@id} datetime={cast_datetime(@datetime)} phx-hook="DateTime">
<%= cast_datetime(@datetime) %>
{cast_datetime(@datetime)}
</time>

View File

@ -1,48 +1,44 @@
<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">
<%= @invite.name %>
<div class="flex flex-col justify-center items-center px-8 py-4 space-y-4 rounded-lg border border-zinc-400 shadow-lg transition-all duration-300 ease-in-out bg-primary-900 hover:shadow-md">
<h1 class="text-xl title">
{@invite.name}
</h1>
<%= if @invite.disabled_at |> is_nil() do %>
<%= if @invite.disabled_at do %>
<h2 class="title text-md">
<%= if @invite.uses_left do %>
<%= gettext(
"uses left: %{uses_left_count}",
uses_left_count: @invite.uses_left
) %>
<% else %>
<%= gettext("uses left: unlimited") %>
<% end %>
{gettext("invite disabled")}
</h2>
<% else %>
<h2 class="title text-md">
<%= gettext("invite disabled") %>
<%= if @invite.uses_left do %>
{gettext(
"uses left: %{uses_left_count}",
uses_left_count: @invite.uses_left
)}
<% else %>
{gettext("uses left: unlimited")}
<% end %>
</h2>
<% end %>
<.qr_code
content={Routes.user_registration_url(Endpoint, :new, invite: @invite.token)}
content={url(MemexWeb.Endpoint, ~p"/users/register?invite=#{@invite.token}")}
filename={@invite.name}
/>
<h2 :if={@use_count && @use_count != 0} class="title text-md">
<%= gettext("uses: %{uses_count}", uses_count: @use_count) %>
{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-primary-400 bg-primary-800"
class="px-4 py-2 mx-2 my-1 text-xs text-center break-all rounded-lg text-primary-400 bg-primary-800"
phx-no-format
><%= Routes.user_registration_url(Endpoint, :new, invite: @invite.token) %></code>
<%= if @code_actions, do: render_slot(@code_actions) %>
><%= url(MemexWeb.Endpoint, ~p"/users/register?invite=#{@invite.token}") %></code>
{if @code_actions, do: render_slot(@code_actions)}
</div>
<div :if={@inner_block} class="flex space-x-4 justify-center items-center">
<%= render_slot(@inner_block) %>
<div :if={@inner_block} class="flex justify-center items-center space-x-4">
{render_slot(@inner_block)}
</div>
</div>

View File

@ -28,7 +28,7 @@
patch={@return_to}
id="close"
class="absolute top-8 right-10
text-gray-500 hover:text-gray-800
text-zinc-500 hover:text-zinc-800
transition-all duration-500 ease-in-out"
phx-remove={hide_modal()}
aria-label={gettext("close modal")}
@ -37,7 +37,7 @@
</.link>
<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) %>
{render_slot(@inner_block)}
</div>
</div>
</div>

View File

@ -1,8 +1,11 @@
<div
:if={@note.content}
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"
class="inline-block overflow-y-auto overflow-x-hidden whitespace-pre-wrap resize-y input input-primary h-128 min-h-128"
phx-update="ignore"
readonly
phx-no-format
><p class="inline"><%= add_links_to_content(@note.content, "note-link") %></p></div>
><p class="inline"><%= display_links(@note) %></p></div>
<div :if={!@note.content} class="text-sm italic text-center text-zinc-600">
{gettext("(This note is empty)")}
</div>

View File

@ -0,0 +1,8 @@
<div
:if={@pipeline.description}
id={"show-pipeline-description-#{@pipeline.id}"}
class="inline-block overflow-y-auto overflow-x-hidden h-32 whitespace-pre-wrap resize-y input input-primary min-h-32"
phx-update="ignore"
readonly
phx-no-format
><p class="inline"><%= display_links(@pipeline) %></p></div>

View File

@ -1,8 +1,8 @@
<div
:if={@step.content}
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"
class="inline-block overflow-y-auto overflow-x-hidden h-32 whitespace-pre-wrap resize-y input input-primary min-h-32"
phx-update="ignore"
readonly
phx-no-format
><p class="inline"><%= add_links_to_content(@step.content, "step-context") %></p></div>
><p class="inline"><%= display_links(@step) %></p></div>

View File

@ -1,30 +0,0 @@
<label for={@id || @action} class="inline-flex relative items-center cursor-pointer">
<input
id={@id || @action}
type="checkbox"
value={@value}
checked={@value}
class="sr-only peer"
aria-labelledby={"#{@id || @action}-label"}
{
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
id={"#{@id || @action}-label"}
class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300"
>
<%= render_slot(@inner_block) %>
</span>
</label>

View File

@ -1,18 +1,15 @@
<nav role="navigation" class="mb-8 px-8 py-4 w-full bg-primary-900 text-primary-400">
<div class="flex flex-col sm:flex-row justify-between items-center">
<div class="mb-4 sm:mb-0 sm:mr-8 flex flex-row justify-start items-center space-x-2">
<.link
navigate={Routes.live_path(Endpoint, HomeLive)}
class="mx-2 my-1 leading-5 text-xl text-primary-400 hover:underline"
>
<%= gettext("memEx") %>
<.link navigate={~p"/"} class="mx-2 my-1 leading-5 text-xl text-primary-400 hover:underline">
{gettext("memEx")}
</.link>
<%= if @title_content do %>
<span class="mx-2 my-1">
|
</span>
<%= @title_content %>
{@title_content}
<% end %>
</div>
@ -21,55 +18,40 @@
<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 hover:underline truncate"
>
<%= gettext("notes") %>
<.link navigate={~p"/notes"} class="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 hover:underline truncate"
>
<%= gettext("contexts") %>
<.link navigate={~p"/contexts"} class="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 hover:underline truncate"
>
<%= gettext("pipelines") %>
<.link navigate={~p"/pipelines"} class="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 :if={@current_user |> Accounts.is_already_admin?()} class="mx-2 my-1">
<.link
navigate={Routes.invite_index_path(Endpoint, :index)}
class="text-primary-400 hover:underline"
>
<%= gettext("invites") %>
<li :if={@current_user |> Accounts.already_admin?()} class="mx-2 my-1">
<.link navigate={~p"/invites"} class="text-primary-400 hover:underline">
{gettext("invites")}
</.link>
</li>
<li class="mx-2 my-1">
<.link
navigate={Routes.user_settings_path(Endpoint, :edit)}
class="text-primary-400 hover:underline truncate"
>
<%= @current_user.email %>
<.link navigate={~p"/users/settings"} class="text-primary-400 hover:underline truncate">
{@current_user.email}
</.link>
</li>
<li class="mx-2 my-1">
<.link
href={Routes.user_session_path(Endpoint, :delete)}
href={~p"/users/log_out"}
method="delete"
data-confirm={dgettext("prompts", "are you sure you want to log out?")}
aria-label={gettext("log out")}
@ -84,7 +66,7 @@
class="mx-2 my-1"
>
<.link
navigate={Routes.live_dashboard_path(Endpoint, :home)}
navigate={~p"/dashboard"}
class="text-primary-400 hover:underline"
aria-label={gettext("live dashboard")}
>
@ -93,20 +75,14 @@
</li>
<% else %>
<li :if={Accounts.allow_registration?()} class="mx-2 my-1">
<.link
href={Routes.user_registration_path(Endpoint, :new)}
class="text-primary-400 hover:underline truncate"
>
<%= dgettext("actions", "register") %>
<.link href={~p"/users/register"} class="text-primary-400 hover:underline truncate">
{dgettext("actions", "register")}
</.link>
</li>
<li class="mx-2 my-1">
<.link
href={Routes.user_session_path(Endpoint, :new)}
class="text-primary-400 hover:underline truncate"
>
<%= dgettext("actions", "log in") %>
<.link href={~p"/users/log_in"} class="text-primary-400 hover:underline truncate">
{dgettext("actions", "log in")}
</.link>
</li>
<% end %>

View File

@ -2,36 +2,36 @@
id={"user-#{@user.id}"}
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
border border-zinc-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 %>
{@user.email}
</h1>
<h3 class="px-4 py-2 rounded-lg title text-lg">
<p>
<%= if @user.confirmed_at do %>
<%= gettext(
{gettext(
"user confirmed on%{confirmed_datetime}",
confirmed_datetime: ""
) %>
)}
<.datetime id={"#{@user.id}-confirmed-at"} datetime={@user.confirmed_at} />
<% else %>
<%= gettext("email unconfirmed") %>
{gettext("email unconfirmed")}
<% end %>
</p>
<p>
<%= gettext(
{gettext(
"user registered on%{registered_datetime}",
registered_datetime: ""
) %>
)}
<.datetime id={"#{@user.id}-inserted-at"} datetime={@user.inserted_at} />
</p>
</h3>
<div :if={@inner_block} class="px-4 py-2 flex space-x-4 justify-center items-center">
<%= render_slot(@inner_block) %>
{render_slot(@inner_block)}
</div>
</div>

View File

@ -0,0 +1,17 @@
defmodule MemexWeb.Layouts do
@moduledoc """
The root layouts for the entire application
"""
use MemexWeb, :html
embed_templates "layouts/*"
def get_title(%{assigns: %{title: title}}) when title not in [nil, ""] do
gettext("memEx | %{title}", title: title)
end
def get_title(_conn) do
gettext("memEx")
end
end

View File

@ -1,38 +1,38 @@
<main class="pb-8 min-w-full">
<main role="main" class="pb-8 min-w-full">
<header>
<.topbar current_user={assigns[:current_user]} />
<div class="mx-8 my-2 flex flex-col space-y-4 text-center">
<p
:if={@flash && @flash |> Map.has_key?("info")}
class="alert alert-info"
class="alert alert-info cursor-pointer"
role="alert"
phx-click="lv:clear-flash"
phx-value-key="info"
>
<%= live_flash(@flash, "info") %>
{Phoenix.Flash.get(@flash, :info)}
</p>
<p
:if={@flash && @flash |> Map.has_key?("error")}
class="alert alert-danger"
class="alert alert-danger cursor-pointer"
role="alert"
phx-click="lv:clear-flash"
phx-value-key="error"
>
<%= live_flash(@flash, "error") %>
{Phoenix.Flash.get(@flash, :error)}
</p>
</div>
</header>
<div class="mx-4 sm:mx-8 md:mx-16">
<%= @inner_content %>
{@inner_content}
</div>
</main>
<div
id="disconnect"
class="z-50 fixed opacity-0 bottom-12 right-12 px-8 py-4 w-max h-max
class="z-50 fixed opacity-0 bottom-8 right-12 px-8 py-4 w-max h-max
border border-primary-400 shadow-lg rounded-lg bg-primary-900 text-primary-400
flex justify-center items-center space-x-4
transition-opacity ease-in-out duration-500 delay-[2000ms]"
@ -40,6 +40,6 @@
<i class="fas fa-fade text-md fa-satellite-dish"></i>
<h1 class="title text-md">
<%= gettext("Reconnecting...") %>
{gettext("Reconnecting...")}
</h1>
</div>

View File

@ -1,19 +1,16 @@
<html>
<head>
<title>
<%= @email.subject %>
{@email.subject}
</title>
</head>
<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 %>
{@inner_content}
<hr style="margin: 2em auto; border-width: 1px; border-color: rgb(161, 161, 170); width: 100%; max-width: 42rem;" />
<a style="color: rgb(161, 161, 170);" href={Routes.live_url(Endpoint, HomeLive)}>
<%= dgettext(
"emails",
"This email was sent from memEx"
) %>
<a style="color: rgb(161, 161, 170);" href={~p"/"}>
{dgettext("emails", "this email was sent from memEx")}
</a>
</body>
</html>

View File

@ -0,0 +1,9 @@
<%= @email.subject %>
====================
<%= @inner_content %>
=====================
<%= dgettext("emails", "this email was sent from memEx at %{url}", url: ~p"/") %>

View File

@ -0,0 +1 @@
{@inner_content}

View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en" class="p-0 m-0 w-full h-full bg-primary-800 [scrollbar-gutter:stable]">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<.live_title suffix={" | #{gettext("memEx")}"}>
{assigns[:page_title] || gettext("memEx")}
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
<link phx-track-static rel="stylesheet" href={~p"/assets/style.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
</script>
</head>
<body class="p-0 m-0 w-full h-full subpixel-antialiased text-primary-400">
{@inner_content}
</body>
</html>

View File

@ -37,7 +37,7 @@ defmodule MemexWeb.Components.NotesTableComponent do
} = socket
) do
columns =
if actions == [] or current_user |> is_nil() do
if actions == [] or !current_user do
[]
else
[%{label: gettext("actions"), key: :actions, sortable: false}]
@ -88,25 +88,21 @@ defmodule MemexWeb.Components.NotesTableComponent do
@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}
defp get_value_for_key(:slug, %{slug: slug} = assigns, _additional_data) do
slug_block = ~H"""
<.link navigate={Routes.note_show_path(Endpoint, :show, @slug)} class="link">
<%= @slug %>
<.link navigate={~p"/note/#{@slug}"} class="link">
{@slug}
</.link>
"""
{slug, slug_block}
end
defp get_value_for_key(:tags, %{tags: tags}, _additional_data) do
assigns = %{tags: tags}
defp get_value_for_key(:tags, assigns, _additional_data) do
~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 :for={tag <- @tags} patch={~p"/notes/#{tag}"} class="link">
{tag}
</.link>
</div>
"""
@ -117,7 +113,7 @@ defmodule MemexWeb.Components.NotesTableComponent do
~H"""
<div class="flex justify-center items-center space-x-4">
<%= render_slot(@actions, @note) %>
{render_slot(@actions, @note)}
</div>
"""
end

View File

@ -37,7 +37,7 @@ defmodule MemexWeb.Components.PipelinesTableComponent do
} = socket
) do
columns =
if actions == [] or current_user |> is_nil() do
if actions == [] or !current_user do
[]
else
[%{label: gettext("actions"), key: :actions, sortable: false}]
@ -89,41 +89,31 @@ defmodule MemexWeb.Components.PipelinesTableComponent do
@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}
defp get_value_for_key(:slug, %{slug: slug} = assigns, _additional_data) do
slug_block = ~H"""
<.link navigate={Routes.pipeline_show_path(Endpoint, :show, @slug)} class="link">
<%= @slug %>
<.link navigate={~p"/pipeline/#{@slug}"} class="link">
{@slug}
</.link>
"""
{slug, slug_block}
end
defp get_value_for_key(:description, %{description: description}, _additional_data) do
assigns = %{description: description}
defp get_value_for_key(:description, %{description: description} = assigns, _additional_data) do
description_block = ~H"""
<div class="truncate max-w-sm">
<%= @description %>
<div class="max-w-sm truncate">
{@description}
</div>
"""
{description, description_block}
end
defp get_value_for_key(:tags, %{tags: tags}, _additional_data) do
assigns = %{tags: tags}
defp get_value_for_key(:tags, assigns, _additional_data) do
~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 :for={tag <- @tags} patch={~p"/pipelines/#{tag}"} class="link">
{tag}
</.link>
</div>
"""
@ -134,7 +124,7 @@ defmodule MemexWeb.Components.PipelinesTableComponent do
~H"""
<div class="flex justify-center items-center space-x-4">
<%= render_slot(@actions, @pipeline) %>
{render_slot(@actions, @pipeline)}
</div>
"""
end

View File

@ -135,4 +135,25 @@ defmodule MemexWeb.Components.TableComponent do
sort_mode
)
end
@doc """
Conditionally composes elements into the columns list, supports maps and
lists. Works tail to front in order for efficiency
iex> []
...> |> maybe_compose_columns(%{label: "Column 3"}, true)
...> |> maybe_compose_columns(%{label: "Column 2"}, false)
...> |> maybe_compose_columns(%{label: "Column 1"})
[%{label: "Column 1"}, %{label: "Column 3"}]
"""
@spec maybe_compose_columns(list(), element_to_add :: list() | map()) :: list()
@spec maybe_compose_columns(list(), element_to_add :: list() | map(), boolean()) :: list()
def maybe_compose_columns(columns, element_or_elements, add? \\ true)
def maybe_compose_columns(columns, elements, true) when is_list(elements),
do: Enum.concat(elements, columns)
def maybe_compose_columns(columns, element, true) when is_map(element), do: [element | columns]
def maybe_compose_columns(columns, _element_or_elements, false), do: columns
end

View File

@ -12,7 +12,7 @@
phx-target={@myself}
>
<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>
<span class={if @last_sort_key == key, do: "underline"}>{label}</span>
<%= if @last_sort_key == key do %>
<%= case @sort_mode do %>
<% :asc -> %>
@ -27,7 +27,7 @@
</th>
<% else %>
<th class={["p-2 cursor-not-allowed", column[:class]]}>
<%= label %>
{label}
</th>
<% end %>
<% end %>
@ -41,9 +41,9 @@
<td :for={%{key: key} = value <- @columns} class={["p-2", value[:class]]}>
<%= case values |> Map.get(key) do %>
<% {_custom_sort_value, value} -> %>
<%= value %>
{value}
<% value -> %>
<%= value %>
{value}
<% end %>
</td>
</tr>

View File

@ -0,0 +1,13 @@
defmodule MemexWeb.ControllerHelpers do
@moduledoc """
Implements controller helpers
"""
import Plug.Conn, only: [assign: 3]
def assign(conn, assigns) do
assigns
|> Map.new()
|> Enum.reduce(conn, fn {key, value}, conn -> conn |> assign(key, value) end)
end
end

View File

@ -6,7 +6,8 @@ defmodule MemexWeb.EmailController do
use MemexWeb, :controller
alias Memex.Accounts.User
plug :put_layout, {MemexWeb.LayoutView, :email}
plug :put_root_layout, html: {MemexWeb.Layouts, :email_html}
plug :put_layout, false
@sample_assigns %{
email: %{subject: "Example subject"},
@ -18,6 +19,6 @@ defmodule MemexWeb.EmailController do
Debug route used to preview emails
"""
def preview(conn, %{"id" => template}) do
render(conn, "#{template |> to_string()}.html", @sample_assigns)
render(conn, String.to_existing_atom(template), @sample_assigns)
end
end

View File

@ -0,0 +1,10 @@
defmodule MemexWeb.EmailHTML do
@moduledoc """
Renders email templates
"""
use MemexWeb, :html
embed_templates "email_html/*.html", suffix: "_html"
embed_templates "email_html/*.txt", suffix: "_text"
end

View File

@ -0,0 +1,23 @@
<div style="display: flex; flex-direction: column; justify-content: center; align-items: center;">
<span style="margin-bottom: 0.75em; font-size: 1.5em;">
{dgettext("emails", "Hi %{email},", email: @user.email)}
</span>
<br />
<span style="margin-bottom: 1em; font-size: 1.25em;">
{dgettext("emails", "Welcome to memEx")}
</span>
<br />
{dgettext("emails", "You can confirm your account by visiting the URL below:")}
<br />
<a style="margin: 1em; color: rgb(161, 161, 170);" href={@url}>{@url}</a>
<br />
{dgettext("emails", "If you didn't create an account at memEx, please ignore this.")}
</div>

View File

@ -9,4 +9,4 @@
<%= dgettext("emails",
"If you didn't create an account at %{url}, please ignore this.",
url: Routes.live_url(Endpoint, HomeLive)) %>
url: ~p"/") %>

View File

@ -0,0 +1,17 @@
<div style="display: flex; flex-direction: column; justify-content: center; align-items: center;">
<span style="margin-bottom: 0.5em; font-size: 1.5em;">
{dgettext("emails", "Hi %{email},", email: @user.email)}
</span>
<br />
{dgettext("emails", "You can reset your password by visiting the URL below:")}
<br />
<a style="margin: 1em; color: rgb(161, 161, 170);" href={@url}>{@url}</a>
<br />
{dgettext("emails", "If you didn't request this change from memEx, please ignore this.")}
</div>

View File

@ -7,4 +7,4 @@
<%= dgettext("emails",
"If you didn't request this change from %{url}, please ignore this.",
url: Routes.live_url(Endpoint, HomeLive)) %>
url: ~p"/") %>

View File

@ -1,20 +1,20 @@
<div style="display: flex; flex-direction: column; justify-content: center; align-items: center;">
<span style="margin-bottom: 0.5em; font-size: 1.5em;">
<%= dgettext("emails", "Hi %{email},", email: @user.email) %>
{dgettext("emails", "Hi %{email},", email: @user.email)}
</span>
<br />
<%= dgettext("emails", "You can change your email by visiting the URL below:") %>
{dgettext("emails", "You can change your email by visiting the URL below:")}
<br />
<a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}><%= @url %></a>
<a style="margin: 1em; color: rgb(161, 161, 170);" href={@url}>{@url}</a>
<br />
<%= dgettext(
{dgettext(
"emails",
"If you didn't request this change from memEx, please ignore this."
) %>
)}
</div>

View File

@ -7,4 +7,4 @@
<%= dgettext("emails",
"If you didn't request this change from %{url}, please ignore this.",
url: Routes.live_url(Endpoint, HomeLive)) %>
url: ~p"/") %>

View File

@ -1,15 +1,16 @@
defmodule MemexWeb.ErrorView do
use MemexWeb, :view
alias MemexWeb.HomeLive
defmodule MemexWeb.ErrorHTML do
use MemexWeb, :html
def template_not_found(error_path, _assigns) do
embed_templates "error_html/*"
def render(template, _assigns) do
error_string =
case error_path do
case template do
"404.html" -> dgettext("errors", "not found")
"401.html" -> dgettext("errors", "unauthorized")
_other_path -> dgettext("errors", "internal server error")
end
render("error.html", %{error_string: error_string})
error(%{error_string: error_string})
end
end

View File

@ -5,7 +5,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>
<%= dgettext("errors", "Error") %> | <%= gettext("memEx") %>
{dgettext("errors", "Error")} | {gettext("memEx")}
</title>
<link rel="stylesheet" href="/css/app.css" />
<script defer type="text/javascript" src="/js/app.js">
@ -19,16 +19,13 @@
<div class="pb-8 w-full flex flex-col justify-center items-center text-center">
<div class="p-8 sm:p-16 w-full flex flex-col justify-center items-center space-y-4 max-w-3xl">
<h1 class="title text-primary-400 text-3xl">
<%= @error_string %>
{@error_string}
</h1>
<hr class="w-full hr" />
<.link
href={Routes.live_path(Endpoint, HomeLive)}
class="link title text-primary-400 text-lg"
>
<%= dgettext("errors", "go back home") %>
<.link href={~p"/"} class="link title text-primary-400 text-lg">
{dgettext("errors", "go back home")}
</.link>
</div>
</div>

View File

@ -0,0 +1,14 @@
defmodule MemexWeb.ErrorJSON do
use Gettext, backend: MemexWeb.Gettext
def render(template, _assigns) do
error_string =
case template do
"404.json" -> dgettext("errors", "not found")
"401.json" -> dgettext("errors", "unauthorized")
_other_path -> dgettext("errors", "internal server error")
end
%{errors: %{detail: error_string}}
end
end

View File

@ -1,11 +0,0 @@
defmodule MemexWeb.HomeController do
@moduledoc """
Controller for home page
"""
use MemexWeb, :controller
def index(conn, _params) do
render(conn, "index.html")
end
end

View File

@ -0,0 +1,5 @@
defmodule MemexWeb.HomeHTML do
use MemexWeb, :html
embed_templates "home_html/*"
end

View File

@ -3,12 +3,11 @@ defmodule MemexWeb.UserAuth do
Functions for user session and authentication
"""
use MemexWeb, :verified_routes
use Gettext, backend: MemexWeb.Gettext
import Plug.Conn
import Phoenix.Controller
import MemexWeb.Gettext
alias Memex.{Accounts, Accounts.User}
alias MemexWeb.HomeLive
alias MemexWeb.Router.Helpers, as: Routes
# Make the remember me cookie valid for 60 days.
# If you want bump or reduce this value, also change
@ -39,7 +38,7 @@ defmodule MemexWeb.UserAuth do
dgettext("errors", "You must confirm your account and log in to access this page.")
)
|> maybe_store_return_to()
|> redirect(to: Routes.user_session_path(conn, :new))
|> redirect(to: ~p"/users/log_in")
|> halt()
end
@ -49,8 +48,7 @@ defmodule MemexWeb.UserAuth do
conn
|> renew_session()
|> put_session(:user_token, token)
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
|> put_token_in_session(token)
|> maybe_write_remember_me_cookie(token, params)
|> redirect(to: user_return_to || signed_in_path(conn))
end
@ -96,7 +94,7 @@ defmodule MemexWeb.UserAuth do
"""
def log_out_user(conn) do
user_token = get_session(conn, :user_token)
user_token && Accounts.delete_session_token(user_token)
user_token && Accounts.delete_user_session_token(user_token)
if live_socket_id = get_session(conn, :live_socket_id) do
MemexWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
@ -105,7 +103,7 @@ defmodule MemexWeb.UserAuth do
conn
|> renew_session()
|> delete_resp_cookie(@remember_me_cookie)
|> redirect(to: "/")
|> redirect(to: ~p"/")
end
@doc """
@ -119,19 +117,110 @@ defmodule MemexWeb.UserAuth do
end
defp ensure_user_token(conn) do
if user_token = get_session(conn, :user_token) do
{user_token, conn}
if token = get_session(conn, :user_token) do
{token, conn}
else
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
if user_token = conn.cookies[@remember_me_cookie] do
{user_token, put_session(conn, :user_token, user_token)}
if token = conn.cookies[@remember_me_cookie] do
{token, put_token_in_session(conn, token)}
else
{nil, conn}
end
end
end
@doc """
Handles mounting and authenticating the current_user in LiveViews.
## `on_mount` arguments
* `:mount_current_user` - Assigns current_user
to socket assigns based on user_token, or nil if
there's no user_token or no matching user.
* `:ensure_authenticated` - Authenticates the user from the session,
and assigns the current_user to socket assigns based
on user_token.
Redirects to login page if there's no logged user.
* `:redirect_if_user_is_authenticated` - Authenticates the user from the session.
Redirects to signed_in_path if there's a logged user.
## Examples
Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate
the current_user:
defmodule MemexWeb.PageLive do
use MemexWeb, :live_view
on_mount {MemexWeb.UserAuth, :mount_current_user}
...
end
Or use the `live_session` of your router to invoke the on_mount callback:
live_session :authenticated, on_mount: [{MemexWeb.UserAuth, :ensure_authenticated}] do
live "/profile", ProfileLive, :index
end
"""
def on_mount(:mount_current_user, _params, session, socket) do
{:cont, mount_current_user(session, socket)}
end
def on_mount(:ensure_authenticated, _params, session, socket) do
socket = mount_current_user(session, socket)
if socket.assigns.current_user do
{:cont, socket}
else
error_flash = dgettext("errors", "You must log in to access this page.")
socket =
socket
|> Phoenix.LiveView.put_flash(:error, error_flash)
|> Phoenix.LiveView.redirect(to: ~p"/users/log_in")
{:halt, socket}
end
end
def on_mount(:ensure_admin, _params, session, socket) do
socket = mount_current_user(session, socket)
if socket.assigns.current_user && socket.assigns.current_user.role == :admin do
{:cont, socket}
else
error_flash = dgettext("errors", "You must log in as an administrator to access this page.")
socket =
socket
|> Phoenix.LiveView.put_flash(:error, error_flash)
|> Phoenix.LiveView.redirect(to: ~p"/users/log_in")
{:halt, socket}
end
end
def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do
socket = mount_current_user(session, socket)
if socket.assigns.current_user do
{:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))}
else
{:cont, socket}
end
end
defp mount_current_user(session, socket) do
Phoenix.Component.assign_new(socket, :current_user, fn ->
if user_token = session["user_token"] do
Accounts.get_user_by_session_token(user_token)
end
end)
end
@doc """
Used for routes that require the user to not be authenticated.
"""
@ -161,7 +250,7 @@ defmodule MemexWeb.UserAuth do
dgettext("errors", "You must confirm your account and log in to access this page.")
)
|> maybe_store_return_to()
|> redirect(to: Routes.user_session_path(conn, :new))
|> redirect(to: ~p"/users/log_in")
|> halt()
end
end
@ -176,16 +265,34 @@ defmodule MemexWeb.UserAuth do
conn
|> put_flash(:error, dgettext("errors", "You are not authorized to view this page."))
|> maybe_store_return_to()
|> redirect(to: Routes.live_path(conn, HomeLive))
|> redirect(to: ~p"/")
|> halt()
end
end
def 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
def put_user_locale(conn, _opts) do
default = Application.fetch_env!(:gettext, :default_locale)
Gettext.put_locale(default)
conn |> put_session(:locale, default)
end
defp put_token_in_session(conn, token) do
conn
|> put_session(:user_token, token)
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
end
defp maybe_store_return_to(%{method: "GET"} = conn) do
put_session(conn, :user_return_to, current_path(conn))
end
defp maybe_store_return_to(conn), do: conn
defp signed_in_path(_conn), do: "/"
defp signed_in_path(_conn), do: ~p"/"
end

View File

@ -1,18 +1,17 @@
defmodule MemexWeb.UserConfirmationController do
use MemexWeb, :controller
import MemexWeb.Gettext
use Gettext, backend: MemexWeb.Gettext
alias Memex.Accounts
def new(conn, _params) do
render(conn, "new.html", page_title: gettext("Confirm your account"))
render(conn, :new, page_title: gettext("confirm your account"))
end
def create(conn, %{"user" => %{"email" => email}}) do
if user = Accounts.get_user_by_email(email) do
Accounts.deliver_user_confirmation_instructions(
user,
&Routes.user_confirmation_url(conn, :confirm, &1)
fn token -> url(MemexWeb.Endpoint, ~p"/users/confirm/#{token}") end
)
end
@ -22,11 +21,10 @@ defmodule MemexWeb.UserConfirmationController do
:info,
dgettext(
"prompts",
"If your email is in our system and it has not been confirmed yet, " <>
"you will receive an email with instructions shortly."
"if your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly."
)
)
|> redirect(to: "/")
|> redirect(to: ~p"/")
end
# Do not log in the user after confirmation to avoid a
@ -36,7 +34,7 @@ defmodule MemexWeb.UserConfirmationController do
{:ok, %{email: email}} ->
conn
|> put_flash(:info, dgettext("prompts", "%{email} confirmed successfully.", email: email))
|> redirect(to: "/")
|> redirect(to: ~p"/")
:error ->
# If there is a current user and the account was already confirmed,
@ -44,16 +42,16 @@ defmodule MemexWeb.UserConfirmationController do
# by some automation or by the user themselves, so we redirect without
# a warning message.
case conn.assigns do
%{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
redirect(conn, to: "/")
%{current_user: %{confirmed_at: %{}}} ->
redirect(conn, to: ~p"/")
%{} ->
conn
|> put_flash(
:error,
dgettext("errors", "User confirmation link is invalid or it has expired.")
dgettext("errors", "user confirmation link is invalid or it has expired.")
)
|> redirect(to: "/")
|> redirect(to: ~p"/")
end
end
end

View File

@ -0,0 +1,6 @@
defmodule MemexWeb.UserConfirmationHTML do
use MemexWeb, :html
alias Memex.Accounts
embed_templates "user_confirmation_html/*"
end

View File

@ -0,0 +1,31 @@
<div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4">
<h1 class="title text-primary-400 text-xl">
{dgettext("actions", "Resend confirmation instructions")}
</h1>
<.form
:let={f}
for={%{}}
as={:user}
action={~p"/users/confirm"}
class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
>
{label(f, :email, gettext("Email"), class: "title text-lg text-primary-400")}
{email_input(f, :email, required: true, class: "input input-primary col-span-2")}
{submit(dgettext("actions", "Resend confirmation instructions"),
class: "mx-auto btn btn-primary col-span-3"
)}
</.form>
<hr class="hr" />
<div class="flex flex-row justify-center items-center space-x-4">
<.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary">
{dgettext("actions", "register")}
</.link>
<.link href={~p"/users/log_in"} class="btn btn-primary">
{dgettext("actions", "log in")}
</.link>
</div>
</div>

View File

@ -1,16 +1,16 @@
defmodule MemexWeb.UserRegistrationController do
use MemexWeb, :controller
import MemexWeb.Gettext
use Gettext, backend: MemexWeb.Gettext
alias Ecto.Changeset
alias Memex.{Accounts, Accounts.Invites}
alias MemexWeb.HomeLive
def new(conn, %{"invite" => invite_token}) do
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"))
|> redirect(to: Routes.live_path(Endpoint, HomeLive))
|> put_flash(:error, dgettext("errors", "sorry, this invite was not found or expired"))
|> redirect(to: ~p"/")
end
end
@ -19,14 +19,14 @@ defmodule MemexWeb.UserRegistrationController do
conn |> render_new()
else
conn
|> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled"))
|> redirect(to: Routes.live_path(Endpoint, HomeLive))
|> put_flash(:error, dgettext("errors", "sorry, public registration is disabled"))
|> redirect(to: ~p"/")
end
end
# renders new user registration page
defp render_new(conn, invite_token \\ nil) do
render(conn, "new.html",
render(conn, :new,
changeset: Accounts.change_user_registration(),
invite_token: invite_token,
page_title: gettext("register")
@ -38,8 +38,8 @@ defmodule MemexWeb.UserRegistrationController do
conn |> create_user(attrs, invite_token)
else
conn
|> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
|> redirect(to: Routes.live_path(Endpoint, HomeLive))
|> put_flash(:error, dgettext("errors", "sorry, this invite was not found or expired"))
|> redirect(to: ~p"/")
end
end
@ -48,8 +48,8 @@ defmodule MemexWeb.UserRegistrationController do
conn |> create_user(attrs)
else
conn
|> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled"))
|> redirect(to: Routes.live_path(Endpoint, HomeLive))
|> put_flash(:error, dgettext("errors", "sorry, public registration is disabled"))
|> redirect(to: ~p"/")
end
end
@ -58,20 +58,20 @@ defmodule MemexWeb.UserRegistrationController do
{:ok, user} ->
Accounts.deliver_user_confirmation_instructions(
user,
&Routes.user_confirmation_url(conn, :confirm, &1)
fn token -> url(MemexWeb.Endpoint, ~p"/users/confirm/#{token}") end
)
conn
|> put_flash(:info, dgettext("prompts", "please check your email to verify your account"))
|> redirect(to: Routes.user_session_path(Endpoint, :new))
|> redirect(to: ~p"/users/log_in")
{:error, :invalid_token} ->
conn
|> put_flash(:error, dgettext("errors", "sorry, this invite was not found or expired"))
|> redirect(to: Routes.live_path(Endpoint, HomeLive))
|> redirect(to: ~p"/")
{:error, %Ecto.Changeset{} = changeset} ->
conn |> render("new.html", changeset: changeset, invite_token: invite_token)
{:error, %Changeset{} = changeset} ->
conn |> render(:new, changeset: changeset, invite_token: invite_token)
end
end
end

View File

@ -0,0 +1,5 @@
defmodule MemexWeb.UserRegistrationHTML do
use MemexWeb, :html
embed_templates "user_registration_html/*"
end

View File

@ -0,0 +1,50 @@
<div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4">
<h1 class="title text-primary-400 text-xl">
{dgettext("actions", "register")}
</h1>
<.form
:let={f}
for={@changeset}
action={~p"/users/register"}
class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
>
<p :if={@changeset.action && not @changeset.valid?} class="alert alert-danger col-span-3">
{dgettext("errors", "oops, something went wrong! please check the errors below.")}
</p>
<%= if @invite_token do %>
{hidden_input(f, :invite_token, value: @invite_token)}
<% end %>
{label(f, :email, gettext("email"), class: "title text-lg text-primary-400")}
{email_input(f, :email, required: true, class: "input input-primary col-span-2")}
{error_tag(f, :email, "col-span-3")}
{label(f, :password, gettext("password"), class: "title text-lg text-primary-400")}
{password_input(f, :password, required: true, class: "input input-primary col-span-2")}
{error_tag(f, :password, "col-span-3")}
{label(f, :locale, gettext("language"), class: "title text-lg text-primary-400")}
{select(
f,
:locale,
[{gettext("english"), "en_US"}],
class: "input input-primary col-span-2"
)}
{error_tag(f, :locale)}
{submit(dgettext("actions", "register"), class: "mx-auto btn btn-primary col-span-3")}
</.form>
<hr class="hr" />
<div class="flex flex-row justify-center items-center space-x-4">
<.link href={~p"/users/log_in"} class="btn btn-primary">
{dgettext("actions", "log in")}
</.link>
<.link href={~p"/users/reset_password"} class="btn btn-primary">
{dgettext("actions", "forgot your password?")}
</.link>
</div>
</div>

View File

@ -6,14 +6,14 @@ 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, page_title: gettext("forgot your password?"))
end
def create(conn, %{"user" => %{"email" => email}}) do
if user = Accounts.get_user_by_email(email) do
Accounts.deliver_user_reset_password_instructions(
user,
&Routes.user_reset_password_url(conn, :edit, &1)
fn token -> url(MemexWeb.Endpoint, ~p"/users/reset_password/#{token}") end
)
end
@ -23,17 +23,16 @@ defmodule MemexWeb.UserResetPasswordController do
:info,
dgettext(
"prompts",
"If your email is in our system, you will receive instructions to " <>
"reset your password shortly."
"if your email is in our system, you will receive instructions to reset your password shortly."
)
)
|> redirect(to: "/")
|> redirect(to: ~p"/")
end
def edit(conn, _params) do
render(conn, "edit.html",
render(conn, :edit,
changeset: Accounts.change_user_password(conn.assigns.user),
page_title: gettext("Reset your password")
page_title: gettext("reset your password")
)
end
@ -41,13 +40,13 @@ defmodule MemexWeb.UserResetPasswordController do
# leaked token giving the user access to the account.
def update(conn, %{"user" => user_params}) do
case Accounts.reset_user_password(conn.assigns.user, user_params) do
{:ok, _} ->
{:ok, _user} ->
conn
|> put_flash(:info, dgettext("prompts", "Password reset successfully."))
|> redirect(to: Routes.user_session_path(conn, :new))
|> put_flash(:info, dgettext("prompts", "password reset successfully."))
|> redirect(to: ~p"/users/log_in")
{:error, changeset} ->
render(conn, "edit.html", changeset: changeset)
render(conn, :edit, changeset: changeset)
end
end
@ -55,14 +54,14 @@ defmodule MemexWeb.UserResetPasswordController do
%{"token" => token} = conn.params
if user = Accounts.get_user_by_reset_password_token(token) do
conn |> assign(:user, user) |> assign(:token, token)
conn |> assign(user: user, token: token)
else
conn
|> put_flash(
:error,
dgettext("errors", "Reset password link is invalid or it has expired.")
dgettext("errors", "reset password link is invalid or it has expired.")
)
|> redirect(to: "/")
|> redirect(to: ~p"/")
|> halt()
end
end

View File

@ -0,0 +1,6 @@
defmodule MemexWeb.UserResetPasswordHTML do
use MemexWeb, :html
alias Memex.Accounts
embed_templates "user_reset_password_html/*"
end

View File

@ -0,0 +1,44 @@
<div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4">
<h1 class="title text-primary-400 text-xl">
{dgettext("actions", "reset password")}
</h1>
<.form
:let={f}
for={@changeset}
action={~p"/users/reset_password/#{@token}"}
class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
>
<p :if={@changeset.action && not @changeset.valid?} class="alert alert-danger col-span-3">
{dgettext("errors", "oops, something went wrong! please check the errors below.")}
</p>
{label(f, :password, gettext("new password"), class: "title text-lg text-primary-400")}
{password_input(f, :password, required: true, class: "input input-primary col-span-2")}
{error_tag(f, :password, "col-span-3")}
{label(f, :password_confirmation, gettext("confirm new password"),
class: "title text-lg text-primary-400"
)}
{password_input(f, :password_confirmation,
required: true,
class: "input input-primary col-span-2"
)}
{error_tag(f, :password_confirmation, "col-span-3")}
{submit(dgettext("actions", "reset password"),
class: "mx-auto btn btn-primary col-span-3"
)}
</.form>
<hr class="hr" />
<div class="flex flex-row justify-center items-center space-x-4">
<.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary">
{dgettext("actions", "register")}
</.link>
<.link href={~p"/users/log_in"} class="btn btn-primary">
{dgettext("actions", "log in")}
</.link>
</div>
</div>

View File

@ -0,0 +1,31 @@
<div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4">
<h1 class="title text-primary-400 text-xl">
{dgettext("actions", "forgot your password?")}
</h1>
<.form
:let={f}
for={%{}}
as={:user}
action={~p"/users/reset_password"}
class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
>
{label(f, :email, gettext("email"), class: "title text-lg text-primary-400")}
{email_input(f, :email, required: true, class: "input input-primary col-span-2")}
{submit(dgettext("actions", "send instructions to reset password"),
class: "mx-auto btn btn-primary col-span-3"
)}
</.form>
<hr class="hr" />
<div class="flex flex-row justify-center items-center space-x-4">
<.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary">
{dgettext("actions", "register")}
</.link>
<.link href={~p"/users/log_in"} class="btn btn-primary">
{dgettext("actions", "log in")}
</.link>
</div>
</div>

View File

@ -5,7 +5,7 @@ defmodule MemexWeb.UserSessionController do
alias MemexWeb.UserAuth
def new(conn, _params) do
render(conn, "new.html", error_message: nil, page_title: gettext("log in"))
render(conn, :new, error_message: nil, page_title: gettext("log in"))
end
def create(conn, %{"user" => user_params}) do
@ -14,7 +14,7 @@ defmodule MemexWeb.UserSessionController do
if user = Accounts.get_user_by_email_and_password(email, password) do
UserAuth.log_in_user(conn, user, user_params)
else
render(conn, "new.html", error_message: dgettext("errors", "Invalid email or password"))
render(conn, :new, error_message: dgettext("errors", "invalid email or password"))
end
end

View File

@ -0,0 +1,6 @@
defmodule MemexWeb.UserSessionHTML do
use MemexWeb, :html
alias Memex.Accounts
embed_templates "user_session_html/*"
end

View File

@ -0,0 +1,41 @@
<div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4">
<h1 class="title text-primary-400 text-xl">
{dgettext("actions", "log in")}
</h1>
<.form
:let={f}
for={@conn}
action={~p"/users/log_in"}
as={:user}
class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
>
<p :if={@error_message} class="alert alert-danger col-span-3">
{@error_message}
</p>
{label(f, :email, gettext("email"), class: "title text-lg text-primary-400")}
{email_input(f, :email, required: true, class: "input input-primary col-span-2")}
{label(f, :password, gettext("password"), class: "title text-lg text-primary-400")}
{password_input(f, :password, required: true, class: "input input-primary col-span-2")}
{label(f, :remember_me, gettext("keep me logged in for 60 days"),
class: "title text-lg text-primary-400"
)}
{checkbox(f, :remember_me, class: "checkbox col-span-2")}
{submit(dgettext("actions", "log in"), class: "mx-auto btn btn-primary col-span-3")}
</.form>
<hr class="hr" />
<div class="flex flex-row justify-center items-center space-x-4">
<.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary">
{dgettext("actions", "register")}
</.link>
<.link href={~p"/users/reset_password"} class="btn btn-primary">
{dgettext("actions", "forgot your password?")}
</.link>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More