Compare commits
	
		
			94 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 7e78cd7c9a | |||
| 5c32dbc324 | |||
| 926d4f9837 | |||
| 
						 | 
					128498eac7 | ||
| 32094221c2 | |||
| 4cca4ee3b7 | |||
| da717013de | |||
| 7096e6abeb | |||
| e379896512 | |||
| 0c5442f0cd | |||
| 6c2aba84ef | |||
| 3e686fa199 | |||
| 2a8a1d11b8 | |||
| c3d066016b | |||
| 64bf39da29 | |||
| c25e02dee1 | |||
| 5be05ceea6 | |||
| e8a041024c | |||
| 36f385c7f3 | |||
| ddb8bbec53 | |||
| 1e55039a67 | |||
| 2346a82a46 | |||
| b63c6bd318 | |||
| b72a79c380 | |||
| 5cd7a7eef0 | |||
| f6dc41498b | |||
| 1c912a1600 | |||
| eeef7c94cd | |||
| 3c3391b3a6 | |||
| 52460024b9 | |||
| 48f7c8d18e | |||
| 571e0b65b6 | |||
| 7dc2047e97 | |||
| f769e710d8 | |||
| d09f698b71 | |||
| 8666f663ba | |||
| 22ccea893c | |||
| 362c406471 | |||
| 2a87037f06 | |||
| 53d0dcfb15 | |||
| c892b5449b | |||
| 7cd9dca958 | |||
| 0e8ddc22c5 | |||
| 3671ad6199 | |||
| 7189c955c3 | |||
| f56ecc0ba3 | |||
| fdfca3f7a5 | |||
| c61b2c67b7 | |||
| d0d958a638 | |||
| a437b5966f | |||
| e2378279d7 | |||
| 1b49b668b3 | |||
| 03021614b5 | |||
| 50af86798a | |||
| be01723be2 | |||
| 0a27a4ee29 | |||
| e2f8ac6b78 | |||
| d5e334dc09 | |||
| 1d6ba5960c | |||
| bc29ca6c20 | |||
| bf9fd4880f | |||
| 957e433847 | |||
| edd631f520 | |||
| 2e1545a9f5 | |||
| 3e296080f5 | |||
| d2ae6024ce | |||
| 4615a29c11 | |||
| eb48ff7dc0 | |||
| fcfd9857d5 | |||
| c5f96a9d9d | |||
| c1a0b4017f | |||
| 04ebe59afe | |||
| 50be85a1c3 | |||
| 994aa96a20 | |||
| 026bf22f60 | |||
| 56e6eb3609 | |||
| c49140e7f5 | |||
| 1276635a3e | |||
| f00dc50215 | |||
| 35de8a6395 | |||
| 96e155a49a | |||
| c02fb06eb2 | |||
| a9d5649bef | |||
| 650d61e95f | |||
| 63d854ffbe | |||
| a1c846be33 | |||
| 1b9f212e66 | |||
| 7805ddc270 | |||
| c1455bccad | |||
| dd956be93f | |||
| 04361a5838 | |||
| cb049cb178 | |||
| 5a41d8b3e7 | |||
| 64320dbdae | 
							
								
								
									
										15
									
								
								.drone.yml
									
									
									
									
									
								
							
							
						
						
									
										15
									
								
								.drone.yml
									
									
									
									
									
								
							@@ -17,7 +17,7 @@ steps:
 | 
				
			|||||||
      - .mix
 | 
					      - .mix
 | 
				
			||||||
 | 
					
 | 
				
			||||||
- name: test
 | 
					- name: test
 | 
				
			||||||
  image: elixir:1.14.1-alpine
 | 
					  image: elixir:1.18.3-alpine
 | 
				
			||||||
  environment:
 | 
					  environment:
 | 
				
			||||||
    TEST_DATABASE_URL: ecto://postgres:postgres@database/memex_test
 | 
					    TEST_DATABASE_URL: ecto://postgres:postgres@database/memex_test
 | 
				
			||||||
    HOST: testing.example.tld
 | 
					    HOST: testing.example.tld
 | 
				
			||||||
@@ -26,13 +26,12 @@ steps:
 | 
				
			|||||||
    MIX_ENV: test
 | 
					    MIX_ENV: test
 | 
				
			||||||
  commands:
 | 
					  commands:
 | 
				
			||||||
  - apk add --no-cache build-base npm git
 | 
					  - apk add --no-cache build-base npm git
 | 
				
			||||||
  - mix local.rebar --force --if-missing
 | 
					  - mix local.rebar --force
 | 
				
			||||||
  - mix local.hex --force --if-missing
 | 
					  - mix local.hex --force
 | 
				
			||||||
  - mix deps.get
 | 
					  - mix deps.get
 | 
				
			||||||
  - npm set cache .npm
 | 
					  - npm set cache .npm
 | 
				
			||||||
  - npm --prefix ./assets ci --no-audit --prefer-offline
 | 
					  - npm --prefix ./assets ci --no-audit --prefer-offline
 | 
				
			||||||
  - npm run --prefix ./assets deploy
 | 
					  - mix do phx.digest, gettext.extract, assets.deploy
 | 
				
			||||||
  - mix do phx.digest, gettext.extract
 | 
					 | 
				
			||||||
  - mix test.all
 | 
					  - mix test.all
 | 
				
			||||||
 | 
					
 | 
				
			||||||
- name: build and publish stable
 | 
					- name: build and publish stable
 | 
				
			||||||
@@ -42,7 +41,8 @@ steps:
 | 
				
			|||||||
    repo: shibaobun/memex
 | 
					    repo: shibaobun/memex
 | 
				
			||||||
    purge: true
 | 
					    purge: true
 | 
				
			||||||
    compress: true
 | 
					    compress: true
 | 
				
			||||||
    platforms: linux/amd64,linux/arm/v7
 | 
					    platforms:
 | 
				
			||||||
 | 
					      - linux/amd64
 | 
				
			||||||
    username:
 | 
					    username:
 | 
				
			||||||
      from_secret: docker_username
 | 
					      from_secret: docker_username
 | 
				
			||||||
    password:
 | 
					    password:
 | 
				
			||||||
@@ -59,7 +59,8 @@ steps:
 | 
				
			|||||||
    repo: shibaobun/memex
 | 
					    repo: shibaobun/memex
 | 
				
			||||||
    purge: true
 | 
					    purge: true
 | 
				
			||||||
    compress: true
 | 
					    compress: true
 | 
				
			||||||
    platforms: linux/amd64,linux/arm/v7
 | 
					    platforms:
 | 
				
			||||||
 | 
					      - linux/amd64
 | 
				
			||||||
    username:
 | 
					    username:
 | 
				
			||||||
      from_secret: docker_username
 | 
					      from_secret: docker_username
 | 
				
			||||||
    password:
 | 
					    password:
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,6 @@
 | 
				
			|||||||
[
 | 
					[
 | 
				
			||||||
  import_deps: [:ecto, :phoenix],
 | 
					  import_deps: [:ecto, :ecto_sql, :phoenix],
 | 
				
			||||||
  inputs: ["*.{heex,ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{heex,ex,exs}"],
 | 
					  inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"],
 | 
				
			||||||
  subdirectories: ["priv/*/migrations"],
 | 
					  subdirectories: ["priv/*/migrations"],
 | 
				
			||||||
  plugins: [Phoenix.LiveView.HTMLFormatter]
 | 
					  plugins: [Phoenix.LiveView.HTMLFormatter]
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										9
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -28,10 +28,11 @@ npm-debug.log
 | 
				
			|||||||
# The directory NPM downloads your dependencies sources to.
 | 
					# The directory NPM downloads your dependencies sources to.
 | 
				
			||||||
/assets/node_modules/
 | 
					/assets/node_modules/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Since we are building assets from assets/,
 | 
					# Ignore assets that are produced by build tools.
 | 
				
			||||||
# we ignore priv/static. You may want to comment
 | 
					/priv/static/assets/
 | 
				
			||||||
# this depending on your deployment strategy.
 | 
					
 | 
				
			||||||
/priv/static/
 | 
					# Ignore digested assets cache.
 | 
				
			||||||
 | 
					/priv/static/cache_manifest.json
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.elixir_ls/
 | 
					.elixir_ls/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,3 @@
 | 
				
			|||||||
elixir 1.14.1-otp-25
 | 
					elixir 1.18.3-otp-27
 | 
				
			||||||
erlang 25.1.2
 | 
					erlang 27.3.1
 | 
				
			||||||
nodejs 18.9.1
 | 
					nodejs 23.10.0
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										13
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								Dockerfile
									
									
									
									
									
								
							@@ -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
 | 
					# install build dependencies
 | 
				
			||||||
RUN apk add --no-cache build-base npm git python3
 | 
					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
 | 
					WORKDIR /app
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# install hex + rebar
 | 
					# install hex + rebar
 | 
				
			||||||
RUN mix local.hex --force && \
 | 
					RUN mix local.rebar --force && \
 | 
				
			||||||
    mix local.rebar --force
 | 
					    mix local.hex --force
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# set build ENV
 | 
					# set build ENV
 | 
				
			||||||
ENV MIX_ENV=prod
 | 
					ENV MIX_ENV=prod
 | 
				
			||||||
@@ -25,24 +25,25 @@ RUN npm --prefix ./assets ci --progress=false --no-audit --loglevel=error
 | 
				
			|||||||
COPY lib lib
 | 
					COPY lib lib
 | 
				
			||||||
COPY priv priv
 | 
					COPY priv priv
 | 
				
			||||||
COPY assets assets
 | 
					COPY assets assets
 | 
				
			||||||
RUN npm run --prefix ./assets deploy
 | 
					 | 
				
			||||||
RUN mix do phx.digest, gettext.extract
 | 
					RUN mix do phx.digest, gettext.extract
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# compile and build release
 | 
					# compile and build release
 | 
				
			||||||
# uncomment COPY if rel/ exists
 | 
					# uncomment COPY if rel/ exists
 | 
				
			||||||
# COPY rel rel
 | 
					# COPY rel rel
 | 
				
			||||||
RUN mix do compile, release
 | 
					RUN mix do assets.deploy, compile, release
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# prepare release image
 | 
					# prepare release image
 | 
				
			||||||
FROM alpine:latest AS app
 | 
					FROM alpine:latest AS app
 | 
				
			||||||
 | 
					
 | 
				
			||||||
RUN apk upgrade --no-cache && \
 | 
					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
 | 
					WORKDIR /app
 | 
				
			||||||
 | 
					
 | 
				
			||||||
RUN chown nobody:nobody /app
 | 
					RUN chown nobody:nobody /app
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					ENV MIX_ENV=prod
 | 
				
			||||||
 | 
					
 | 
				
			||||||
USER nobody:nobody
 | 
					USER nobody:nobody
 | 
				
			||||||
 | 
					
 | 
				
			||||||
COPY --from=build --chown=nobody:nobody /app/_build/prod/rel/memex ./
 | 
					COPY --from=build --chown=nobody:nobody /app/_build/prod/rel/memex ./
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +0,0 @@
 | 
				
			|||||||
{
 | 
					 | 
				
			||||||
    "presets": [
 | 
					 | 
				
			||||||
        "@babel/preset-env"
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -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; }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -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
									
								
							
							
						
						
									
										226
									
								
								assets/css/style.css
									
									
									
									
									
										Normal 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;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -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'
 | 
					import '@fontsource/nunito-sans'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
 | 
					// 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 { Socket } from 'phoenix'
 | 
				
			||||||
import { LiveSocket } from 'phoenix_live_view'
 | 
					import { LiveSocket } from 'phoenix_live_view'
 | 
				
			||||||
import topbar from 'topbar'
 | 
					import topbar from 'topbar'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import CtrlEnter from './ctrlenter'
 | 
				
			||||||
import Date from './date'
 | 
					import Date from './date'
 | 
				
			||||||
import DateTime from './datetime'
 | 
					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']")
 | 
					const csrfTokenElement = document.querySelector("meta[name='csrf-token']")
 | 
				
			||||||
let csrfToken
 | 
					let csrfToken
 | 
				
			||||||
if (csrfTokenElement) { csrfToken = csrfTokenElement.getAttribute('content') }
 | 
					if (csrfTokenElement) { csrfToken = csrfTokenElement.getAttribute('content') }
 | 
				
			||||||
const liveSocket = new LiveSocket('/live', Socket, {
 | 
					const liveSocket = new LiveSocket('/live', Socket, {
 | 
				
			||||||
  params: { _csrf_token: csrfToken },
 | 
					  params: { _csrf_token: csrfToken },
 | 
				
			||||||
  hooks: { Date, DateTime, MaintainAttrs }
 | 
					  hooks: { CtrlEnter, Date, DateTime, SanitizeTags, SanitizeTitles }
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Show progress bar on live navigation and form submits
 | 
					// Show progress bar on live navigation and form submits
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										11
									
								
								assets/js/ctrlenter.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								assets/js/ctrlenter.js
									
									
									
									
									
										Normal 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) }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -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
									
								
							
							
						
						
									
										11
									
								
								assets/js/sanitizetags.js
									
									
									
									
									
										Normal 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) }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										10
									
								
								assets/js/sanitizetitles.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								assets/js/sanitizetitles.js
									
									
									
									
									
										Normal 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
									
									
									
								
							
							
						
						
									
										26534
									
								
								assets/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -1,47 +1,18 @@
 | 
				
			|||||||
{
 | 
					{
 | 
				
			||||||
  "repository": {},
 | 
					 | 
				
			||||||
  "description": " ",
 | 
					 | 
				
			||||||
  "license": "MIT",
 | 
					 | 
				
			||||||
  "engines": {
 | 
					  "engines": {
 | 
				
			||||||
    "node": "v18.9.1",
 | 
					    "node": "v23.10.0"
 | 
				
			||||||
    "npm": "8.19.1"
 | 
					 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "scripts": {
 | 
					  "scripts": {
 | 
				
			||||||
    "deploy": "NODE_ENV=production webpack --mode production",
 | 
					 | 
				
			||||||
    "watch": "webpack --mode development --watch --watch-options-stdin",
 | 
					 | 
				
			||||||
    "format": "standard --fix",
 | 
					    "format": "standard --fix",
 | 
				
			||||||
    "test": "standard"
 | 
					    "test": "standard"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "dependencies": {
 | 
					  "dependencies": {
 | 
				
			||||||
    "@fontsource/nunito-sans": "^4.5.10",
 | 
					    "@fontsource/nunito-sans": "^5.2.5",
 | 
				
			||||||
    "@fortawesome/fontawesome-free": "^6.3.0",
 | 
					    "@fortawesome/fontawesome-free": "^6.7.2",
 | 
				
			||||||
    "phoenix": "file:../deps/phoenix",
 | 
					    "topbar": "^3.0.0"
 | 
				
			||||||
    "phoenix_html": "file:../deps/phoenix_html",
 | 
					 | 
				
			||||||
    "phoenix_live_view": "file:../deps/phoenix_live_view",
 | 
					 | 
				
			||||||
    "topbar": "^2.0.1"
 | 
					 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "devDependencies": {
 | 
					  "devDependencies": {
 | 
				
			||||||
    "@babel/core": "^7.21.3",
 | 
					    "npm-check-updates": "^17.1.16",
 | 
				
			||||||
    "@babel/preset-env": "^7.20.2",
 | 
					    "standard": "^17.1.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"
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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  | 
@@ -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: []
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -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: '../' }] })
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
							
								
								
									
										61
									
								
								changelog.md
									
									
									
									
									
								
							
							
						
						
									
										61
									
								
								changelog.md
									
									
									
									
									
								
							@@ -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
 | 
					# v0.1.10
 | 
				
			||||||
- Improve accessibility
 | 
					- Improve accessibility
 | 
				
			||||||
- Code quality improvements
 | 
					- Code quality improvements
 | 
				
			||||||
- Fix dates displaying incorrectly
 | 
					- Fix dates displaying incorrectly
 | 
				
			||||||
- Add links to readme for github mirror
 | 
					- Add links to readme for github mirror
 | 
				
			||||||
- Add license (whoops)
 | 
					- Add license (whoops)
 | 
				
			||||||
 | 
					- Display links in note/context/step contents
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# v0.1.9
 | 
					# v0.1.9
 | 
				
			||||||
- Improve server log
 | 
					- Improve server log
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,26 +8,31 @@
 | 
				
			|||||||
import Config
 | 
					import Config
 | 
				
			||||||
 | 
					
 | 
				
			||||||
config :memex,
 | 
					config :memex,
 | 
				
			||||||
  ecto_repos: [Memex.Repo],
 | 
					  env: :dev,
 | 
				
			||||||
  generators: [binary_id: true]
 | 
					  ecto_repos: [Memex.Repo]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
config :memex, Memex.Accounts, registration: System.get_env("REGISTRATION", "invite")
 | 
					config :memex, Memex.Accounts, registration: System.get_env("REGISTRATION", "invite")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Configures the endpoint
 | 
					# Configures the endpoint
 | 
				
			||||||
config :memex, MemexWeb.Endpoint,
 | 
					config :memex, MemexWeb.Endpoint,
 | 
				
			||||||
 | 
					  adapter: Bandit.PhoenixAdapter,
 | 
				
			||||||
  url: [scheme: "https", host: System.get_env("HOST") || "localhost", port: "443"],
 | 
					  url: [scheme: "https", host: System.get_env("HOST") || "localhost", port: "443"],
 | 
				
			||||||
  http: [port: String.to_integer(System.get_env("PORT") || "4000")],
 | 
					  http: [port: String.to_integer(System.get_env("PORT") || "4000")],
 | 
				
			||||||
  secret_key_base: "KH59P0iZixX5gP/u+zkxxG8vAAj6vgt0YqnwEB5JP5K+E567SsqkCz69uWShjE7I",
 | 
					  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,
 | 
					  pubsub_server: Memex.PubSub,
 | 
				
			||||||
  live_view: [signing_salt: "zOLgd3lr"]
 | 
					  live_view: [signing_salt: "zOLgd3lr"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
config :memex, Memex.Application, automigrate: false
 | 
					config :memex, Memex.Application, automigrate: false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
config :memex, :generators,
 | 
					config :memex, :generators,
 | 
				
			||||||
  migration: true,
 | 
					 | 
				
			||||||
  binary_id: 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
 | 
					# Configures the mailer
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
@@ -51,14 +56,25 @@ config :memex, Oban,
 | 
				
			|||||||
  queues: [default: 10, mailers: 20]
 | 
					  queues: [default: 10, mailers: 20]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Configure esbuild (the version is required)
 | 
					# Configure esbuild (the version is required)
 | 
				
			||||||
# config :esbuild,
 | 
					config :esbuild,
 | 
				
			||||||
#   version: "0.14.0",
 | 
					  version: "0.17.11",
 | 
				
			||||||
#   default: [
 | 
					  memex: [
 | 
				
			||||||
#     args:
 | 
					    args:
 | 
				
			||||||
#       ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
 | 
					      ~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__),
 | 
					    cd: Path.expand("../assets", __DIR__),
 | 
				
			||||||
#     env: %{"NODE_PATH" => Path.expand("../deps", __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
 | 
					# Configures Elixir's Logger
 | 
				
			||||||
config :logger, :console,
 | 
					config :logger, :console,
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,6 +2,7 @@ import Config
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
# Configure your database
 | 
					# Configure your database
 | 
				
			||||||
config :memex, Memex.Repo,
 | 
					config :memex, Memex.Repo,
 | 
				
			||||||
 | 
					  stacktrace: true,
 | 
				
			||||||
  show_sensitive_data_on_connection_error: true,
 | 
					  show_sensitive_data_on_connection_error: true,
 | 
				
			||||||
  pool_size: 10
 | 
					  pool_size: 10
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -12,21 +13,14 @@ config :memex, Memex.Repo,
 | 
				
			|||||||
# watchers to your application. For example, we use it
 | 
					# watchers to your application. For example, we use it
 | 
				
			||||||
# with esbuild to bundle .js and .css sources.
 | 
					# with esbuild to bundle .js and .css sources.
 | 
				
			||||||
config :memex, MemexWeb.Endpoint,
 | 
					config :memex, MemexWeb.Endpoint,
 | 
				
			||||||
 | 
					  http: [ip: {0, 0, 0, 0}, port: 4000],
 | 
				
			||||||
  check_origin: false,
 | 
					  check_origin: false,
 | 
				
			||||||
  code_reloader: true,
 | 
					  code_reloader: true,
 | 
				
			||||||
  debug_errors: true,
 | 
					  debug_errors: true,
 | 
				
			||||||
  secret_key_base: "dg2lccMgaY3+ZeKppR+ondk4ZRaANZGIN0LMZT1u1uzscH4jO5W9a9b9V9BkC+MW",
 | 
					  secret_key_base: "dg2lccMgaY3+ZeKppR+ondk4ZRaANZGIN0LMZT1u1uzscH4jO5W9a9b9V9BkC+MW",
 | 
				
			||||||
  watchers: [
 | 
					  watchers: [
 | 
				
			||||||
    # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args)
 | 
					    esbuild: {Esbuild, :install_and_run, [:memex, ~w(--sourcemap=inline --watch)]},
 | 
				
			||||||
    # esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}
 | 
					    tailwind: {Tailwind, :install_and_run, [:memex, ~w(--watch)]}
 | 
				
			||||||
    node: [
 | 
					 | 
				
			||||||
      "node_modules/webpack/bin/webpack.js",
 | 
					 | 
				
			||||||
      "--mode",
 | 
					 | 
				
			||||||
      "development",
 | 
					 | 
				
			||||||
      "--watch",
 | 
					 | 
				
			||||||
      "--watch-options-stdin",
 | 
					 | 
				
			||||||
      cd: Path.expand("../assets", __DIR__)
 | 
					 | 
				
			||||||
    ]
 | 
					 | 
				
			||||||
  ]
 | 
					  ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# ## SSL Support
 | 
					# ## SSL Support
 | 
				
			||||||
@@ -57,10 +51,9 @@ config :memex, MemexWeb.Endpoint,
 | 
				
			|||||||
config :memex, MemexWeb.Endpoint,
 | 
					config :memex, MemexWeb.Endpoint,
 | 
				
			||||||
  live_reload: [
 | 
					  live_reload: [
 | 
				
			||||||
    patterns: [
 | 
					    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"priv/gettext/.*(po)$",
 | 
				
			||||||
      ~r"lib/memex_web/(live|views)/.*(ex)$",
 | 
					      ~r"lib/memex_web/*/.*(ex)$"
 | 
				
			||||||
      ~r"lib/memex_web/templates/.*(eex)$"
 | 
					 | 
				
			||||||
    ]
 | 
					    ]
 | 
				
			||||||
  ]
 | 
					  ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -74,3 +67,9 @@ config :phoenix, :stacktrace_depth, 20
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
# Initialize plugs at runtime for faster development compilation
 | 
					# Initialize plugs at runtime for faster development compilation
 | 
				
			||||||
config :phoenix, :plug_init_mode, :runtime
 | 
					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
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -14,6 +14,8 @@ config :memex, MemexWeb.Endpoint, cache_static_manifest: "priv/static/cache_mani
 | 
				
			|||||||
# Do not print debug messages in production
 | 
					# Do not print debug messages in production
 | 
				
			||||||
config :logger, level: :info
 | 
					config :logger, level: :info
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					config :memex, env: :prod
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# ## SSL Support
 | 
					# ## SSL Support
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
# To get SSL working, you will need to add the `https` key
 | 
					# To get SSL working, you will need to add the `https` key
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -7,11 +7,21 @@ import Config
 | 
				
			|||||||
# any compile-time configuration in here, as it won't be applied.
 | 
					# any compile-time configuration in here, as it won't be applied.
 | 
				
			||||||
# The block below contains prod specific runtime configuration.
 | 
					# The block below contains prod specific runtime configuration.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Start the phoenix server if environment is set and running in a release
 | 
					# ## Using releases
 | 
				
			||||||
if System.get_env("PHX_SERVER") && System.get_env("RELEASE_NAME") do
 | 
					#
 | 
				
			||||||
 | 
					# 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
 | 
					  config :memex, MemexWeb.Endpoint, server: true
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					config :memex, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Set default locale
 | 
					# Set default locale
 | 
				
			||||||
config :gettext, :default_locale, System.get_env("LOCALE", "en_US")
 | 
					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") ||
 | 
					    System.get_env("SECRET_KEY_BASE") ||
 | 
				
			||||||
      raise """
 | 
					      raise """
 | 
				
			||||||
      environment variable SECRET_KEY_BASE is missing.
 | 
					      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
 | 
					  config :memex, MemexWeb.Endpoint, secret_key_base: secret_key_base
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -9,8 +9,9 @@ config :bcrypt_elixir, :log_rounds, 1
 | 
				
			|||||||
# to provide built-in test partitioning in CI environment.
 | 
					# to provide built-in test partitioning in CI environment.
 | 
				
			||||||
# Run `mix help test` for more information.
 | 
					# Run `mix help test` for more information.
 | 
				
			||||||
config :memex, Memex.Repo,
 | 
					config :memex, Memex.Repo,
 | 
				
			||||||
 | 
					  pool_size: 10,
 | 
				
			||||||
  pool: Ecto.Adapters.SQL.Sandbox,
 | 
					  pool: Ecto.Adapters.SQL.Sandbox,
 | 
				
			||||||
  pool_size: 10
 | 
					  timeout: 60000
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# We don't run a server during test. If one is required,
 | 
					# We don't run a server during test. If one is required,
 | 
				
			||||||
# you can enable the server option below.
 | 
					# you can enable the server option below.
 | 
				
			||||||
@@ -19,6 +20,8 @@ config :memex, MemexWeb.Endpoint,
 | 
				
			|||||||
  secret_key_base: "S3qq9QtUdsFtlYej+HTjAVN95uP5i5tf2sPYINWSQfCKJghFj2B1+wTAoljZyHOK",
 | 
					  secret_key_base: "S3qq9QtUdsFtlYej+HTjAVN95uP5i5tf2sPYINWSQfCKJghFj2B1+wTAoljZyHOK",
 | 
				
			||||||
  server: false
 | 
					  server: false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					config :memex, env: :test
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# In test we don't send emails.
 | 
					# In test we don't send emails.
 | 
				
			||||||
config :memex, Memex.Mailer, adapter: Swoosh.Adapters.Test
 | 
					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"
 | 
					config :memex, Memex.Accounts, registration: "public"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Print only warnings and errors during test
 | 
					# Print only warnings and errors during test
 | 
				
			||||||
config :logger, level: :warn
 | 
					config :logger, level: :warning
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Initialize plugs at runtime for faster test compilation
 | 
					# Initialize plugs at runtime for faster test compilation
 | 
				
			||||||
config :phoenix, :plug_init_mode, :runtime
 | 
					config :phoenix, :plug_init_mode, :runtime
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Disable Oban
 | 
					# Disable Oban
 | 
				
			||||||
config :memex, Oban, queues: false, plugins: false
 | 
					config :memex, Oban, queues: false, plugins: false, testing: :manual
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,16 @@
 | 
				
			|||||||
# Contribution Guide
 | 
					# Contribution Guide
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Translations needed!
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[](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
 | 
					## Style Tips
 | 
				
			||||||
 | 
					
 | 
				
			||||||
- In order to keep code concise and improve readability, please try to make your
 | 
					- 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:
 | 
					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
 | 
					- `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_HOST`: The url for your SMTP email provider. Must be set
 | 
				
			||||||
- `SMTP_PORT`: The port for your SMTP relay. Defaults to `587`.
 | 
					- `SMTP_PORT`: The port for your SMTP relay. Defaults to `587`.
 | 
				
			||||||
- `SMTP_USERNAME`: The username for your SMTP relay. Must be set!
 | 
					- `SMTP_USERNAME`: The username for your SMTP relay. Must be set!
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										
											BIN
										
									
								
								home.png
									
									
									
									
									
								
							
							
						
						
									
										
											BIN
										
									
								
								home.png
									
									
									
									
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 340 KiB  | 
							
								
								
									
										30
									
								
								lib/memex.ex
									
									
									
									
									
								
							
							
						
						
									
										30
									
								
								lib/memex.ex
									
									
									
									
									
								
							@@ -6,4 +6,34 @@ defmodule Memex do
 | 
				
			|||||||
  Contexts are also responsible for managing your data, regardless
 | 
					  Contexts are also responsible for managing your data, regardless
 | 
				
			||||||
  if it comes from the database, an external API or others.
 | 
					  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
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,10 +3,9 @@ defmodule Memex.Accounts do
 | 
				
			|||||||
  The Accounts context.
 | 
					  The Accounts context.
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  import Ecto.Query, warn: false
 | 
					  use Memex, :context
 | 
				
			||||||
  alias Memex.{Mailer, Repo}
 | 
					  alias Memex.Mailer
 | 
				
			||||||
  alias Memex.Accounts.{Invite, Invites, User, UserToken}
 | 
					  alias Memex.Accounts.{Invite, Invites, UserToken}
 | 
				
			||||||
  alias Ecto.{Changeset, Multi}
 | 
					 | 
				
			||||||
  alias Oban.Job
 | 
					  alias Oban.Job
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  ## Database getters
 | 
					  ## Database getters
 | 
				
			||||||
@@ -117,7 +116,7 @@ defmodule Memex.Accounts do
 | 
				
			|||||||
    Multi.new()
 | 
					    Multi.new()
 | 
				
			||||||
    |> Multi.one(:users_count, from(u in User, select: count(u.id), distinct: true))
 | 
					    |> Multi.one(:users_count, from(u in User, select: count(u.id), distinct: true))
 | 
				
			||||||
    |> Multi.run(:use_invite, fn _changes_so_far, _repo ->
 | 
					    |> 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}
 | 
					        {:ok, nil}
 | 
				
			||||||
      else
 | 
					      else
 | 
				
			||||||
        Invites.use_invite(invite_token)
 | 
					        Invites.use_invite(invite_token)
 | 
				
			||||||
@@ -219,7 +218,7 @@ defmodule Memex.Accounts do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
 | 
					    with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
 | 
				
			||||||
         %UserToken{sent_to: email} <- Repo.one(query),
 | 
					         %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
 | 
					      :ok
 | 
				
			||||||
    else
 | 
					    else
 | 
				
			||||||
      _error_tuple -> :error
 | 
					      _error_tuple -> :error
 | 
				
			||||||
@@ -374,8 +373,8 @@ defmodule Memex.Accounts do
 | 
				
			|||||||
  @doc """
 | 
					  @doc """
 | 
				
			||||||
  Deletes the signed token with the given context.
 | 
					  Deletes the signed token with the given context.
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
  @spec delete_session_token(token :: String.t()) :: :ok
 | 
					  @spec delete_user_session_token(token :: String.t()) :: :ok
 | 
				
			||||||
  def delete_session_token(token) do
 | 
					  def delete_user_session_token(token) do
 | 
				
			||||||
    UserToken.token_and_context_query(token, "session") |> Repo.delete_all()
 | 
					    UserToken.token_and_context_query(token, "session") |> Repo.delete_all()
 | 
				
			||||||
    :ok
 | 
					    :ok
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
@@ -405,15 +404,15 @@ defmodule Memex.Accounts do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  ## Examples
 | 
					  ## Examples
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      iex> is_admin?(%User{role: :admin})
 | 
					      iex> admin?(%User{role: :admin})
 | 
				
			||||||
      true
 | 
					      true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      iex> is_admin?(%User{})
 | 
					      iex> admin?(%User{})
 | 
				
			||||||
      false
 | 
					      false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
  @spec is_admin?(User.t()) :: boolean()
 | 
					  @spec admin?(User.t()) :: boolean()
 | 
				
			||||||
  def is_admin?(%User{id: user_id}) do
 | 
					  def admin?(%User{id: user_id}) do
 | 
				
			||||||
    Repo.exists?(from u in User, where: u.id == ^user_id, where: u.role == :admin)
 | 
					    Repo.exists?(from u in User, where: u.id == ^user_id, where: u.role == :admin)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -422,16 +421,16 @@ defmodule Memex.Accounts do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  ## Examples
 | 
					  ## Examples
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      iex> is_already_admin?(%User{role: :admin})
 | 
					      iex> already_admin?(%User{role: :admin})
 | 
				
			||||||
      true
 | 
					      true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      iex> is_already_admin?(%User{})
 | 
					      iex> already_admin?(%User{})
 | 
				
			||||||
      false
 | 
					      false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
  @spec is_already_admin?(User.t() | nil) :: boolean()
 | 
					  @spec already_admin?(User.t() | nil) :: boolean()
 | 
				
			||||||
  def is_already_admin?(%User{role: :admin}), do: true
 | 
					  def already_admin?(%User{role: :admin}), do: true
 | 
				
			||||||
  def is_already_admin?(_invalid_user), do: false
 | 
					  def already_admin?(_invalid_user), do: false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  ## Confirmation
 | 
					  ## Confirmation
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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
 | 
					 | 
				
			||||||
@@ -5,24 +5,19 @@ defmodule Memex.Accounts.Invite do
 | 
				
			|||||||
  `:uses_left` is defined.
 | 
					  `:uses_left` is defined.
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  use Ecto.Schema
 | 
					  use Memex, :schema
 | 
				
			||||||
  import Ecto.Changeset
 | 
					 | 
				
			||||||
  alias Ecto.{Association, Changeset, UUID}
 | 
					 | 
				
			||||||
  alias Memex.Accounts.User
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @primary_key {:id, :binary_id, autogenerate: true}
 | 
					 | 
				
			||||||
  @foreign_key_type :binary_id
 | 
					 | 
				
			||||||
  schema "invites" do
 | 
					  schema "invites" do
 | 
				
			||||||
    field :name, :string
 | 
					    field :name, :string
 | 
				
			||||||
    field :token, :string
 | 
					    field :token, :string
 | 
				
			||||||
    field :uses_left, :integer, default: nil
 | 
					    field :uses_left, :integer, default: nil
 | 
				
			||||||
    field :disabled_at, :naive_datetime
 | 
					    field :disabled_at, :utc_datetime_usec
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    belongs_to :created_by, User
 | 
					    belongs_to :created_by, User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    has_many :users, User
 | 
					    has_many :users, User
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    timestamps()
 | 
					    timestamps(type: :utc_datetime_usec)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @type t :: %__MODULE__{
 | 
					  @type t :: %__MODULE__{
 | 
				
			||||||
@@ -30,12 +25,12 @@ defmodule Memex.Accounts.Invite do
 | 
				
			|||||||
          name: String.t(),
 | 
					          name: String.t(),
 | 
				
			||||||
          token: token(),
 | 
					          token: token(),
 | 
				
			||||||
          uses_left: integer() | nil,
 | 
					          uses_left: integer() | nil,
 | 
				
			||||||
          disabled_at: NaiveDateTime.t(),
 | 
					          disabled_at: DateTime.t(),
 | 
				
			||||||
          created_by: User.t() | nil | Association.NotLoaded.t(),
 | 
					          created_by: User.t() | nil | Association.NotLoaded.t(),
 | 
				
			||||||
          created_by_id: User.id() | nil,
 | 
					          created_by_id: User.id() | nil,
 | 
				
			||||||
          users: [User.t()] | Association.NotLoaded.t(),
 | 
					          users: [User.t()] | Association.NotLoaded.t(),
 | 
				
			||||||
          inserted_at: NaiveDateTime.t(),
 | 
					          inserted_at: DateTime.t(),
 | 
				
			||||||
          updated_at: NaiveDateTime.t()
 | 
					          updated_at: DateTime.t()
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
  @type new_invite :: %__MODULE__{}
 | 
					  @type new_invite :: %__MODULE__{}
 | 
				
			||||||
  @type id :: UUID.t()
 | 
					  @type id :: UUID.t()
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,10 +3,8 @@ defmodule Memex.Accounts.Invites do
 | 
				
			|||||||
  The Invites context.
 | 
					  The Invites context.
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  import Ecto.Query, warn: false
 | 
					  use Memex, :context
 | 
				
			||||||
  alias Ecto.Multi
 | 
					  alias Memex.Accounts.Invite
 | 
				
			||||||
  alias Memex.Accounts.{Invite, User}
 | 
					 | 
				
			||||||
  alias Memex.Repo
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @invite_token_length 20
 | 
					  @invite_token_length 20
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -125,7 +123,7 @@ defmodule Memex.Accounts.Invites do
 | 
				
			|||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  defp decrement_invite_changeset(%Invite{uses_left: 1} = invite) do
 | 
					  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})
 | 
					    invite |> Invite.update_changeset(%{uses_left: 0, disabled_at: now})
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,11 +3,8 @@ defmodule Memex.Accounts.User do
 | 
				
			|||||||
  A Memex user
 | 
					  A Memex user
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  use Ecto.Schema
 | 
					  use Memex, :schema
 | 
				
			||||||
  import Ecto.Changeset
 | 
					  alias Memex.Accounts.Invite
 | 
				
			||||||
  import MemexWeb.Gettext
 | 
					 | 
				
			||||||
  alias Ecto.{Association, Changeset, UUID}
 | 
					 | 
				
			||||||
  alias Memex.Accounts.{Invite, User}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @derive {Jason.Encoder,
 | 
					  @derive {Jason.Encoder,
 | 
				
			||||||
           only: [
 | 
					           only: [
 | 
				
			||||||
@@ -20,13 +17,12 @@ defmodule Memex.Accounts.User do
 | 
				
			|||||||
             :updated_at
 | 
					             :updated_at
 | 
				
			||||||
           ]}
 | 
					           ]}
 | 
				
			||||||
  @derive {Inspect, except: [:password]}
 | 
					  @derive {Inspect, except: [:password]}
 | 
				
			||||||
  @primary_key {:id, :binary_id, autogenerate: true}
 | 
					 | 
				
			||||||
  @foreign_key_type :binary_id
 | 
					 | 
				
			||||||
  schema "users" do
 | 
					  schema "users" do
 | 
				
			||||||
    field :email, :string
 | 
					    field :email, :string
 | 
				
			||||||
    field :password, :string, virtual: true
 | 
					    field :password, :string, virtual: true
 | 
				
			||||||
    field :hashed_password, :string
 | 
					    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 :role, Ecto.Enum, values: [:admin, :user], default: :user
 | 
				
			||||||
    field :locale, :string
 | 
					    field :locale, :string
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -34,7 +30,7 @@ defmodule Memex.Accounts.User do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    belongs_to :invite, Invite
 | 
					    belongs_to :invite, Invite
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    timestamps()
 | 
					    timestamps(type: :utc_datetime_usec)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @type t :: %User{
 | 
					  @type t :: %User{
 | 
				
			||||||
@@ -42,14 +38,14 @@ defmodule Memex.Accounts.User do
 | 
				
			|||||||
          email: String.t(),
 | 
					          email: String.t(),
 | 
				
			||||||
          password: String.t(),
 | 
					          password: String.t(),
 | 
				
			||||||
          hashed_password: String.t(),
 | 
					          hashed_password: String.t(),
 | 
				
			||||||
          confirmed_at: NaiveDateTime.t(),
 | 
					          confirmed_at: DateTime.t(),
 | 
				
			||||||
          role: role(),
 | 
					          role: role(),
 | 
				
			||||||
          locale: String.t() | nil,
 | 
					          locale: String.t() | nil,
 | 
				
			||||||
          created_invites: [Invite.t()] | Association.NotLoaded.t(),
 | 
					          created_invites: [Invite.t()] | Association.NotLoaded.t(),
 | 
				
			||||||
          invite: Invite.t() | nil | Association.NotLoaded.t(),
 | 
					          invite: Invite.t() | nil | Association.NotLoaded.t(),
 | 
				
			||||||
          invite_id: Invite.id() | nil,
 | 
					          invite_id: Invite.id() | nil,
 | 
				
			||||||
          inserted_at: NaiveDateTime.t(),
 | 
					          inserted_at: DateTime.t(),
 | 
				
			||||||
          updated_at: NaiveDateTime.t()
 | 
					          updated_at: DateTime.t()
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
  @type new_user :: %User{}
 | 
					  @type new_user :: %User{}
 | 
				
			||||||
  @type id :: UUID.t()
 | 
					  @type id :: UUID.t()
 | 
				
			||||||
@@ -140,7 +136,7 @@ defmodule Memex.Accounts.User do
 | 
				
			|||||||
    |> cast(attrs, [:email])
 | 
					    |> cast(attrs, [:email])
 | 
				
			||||||
    |> validate_email()
 | 
					    |> validate_email()
 | 
				
			||||||
    |> case do
 | 
					    |> case do
 | 
				
			||||||
      %{changes: %{email: _}} = changeset -> changeset
 | 
					      %{changes: %{email: _email}} = changeset -> changeset
 | 
				
			||||||
      %{} = changeset -> add_error(changeset, :email, dgettext("errors", "did not change"))
 | 
					      %{} = changeset -> add_error(changeset, :email, dgettext("errors", "did not change"))
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
@@ -171,7 +167,7 @@ defmodule Memex.Accounts.User do
 | 
				
			|||||||
  """
 | 
					  """
 | 
				
			||||||
  @spec confirm_changeset(t() | changeset()) :: changeset()
 | 
					  @spec confirm_changeset(t() | changeset()) :: changeset()
 | 
				
			||||||
  def confirm_changeset(user_or_changeset) do
 | 
					  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)
 | 
					    user_or_changeset |> change(confirmed_at: now)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -197,6 +193,8 @@ defmodule Memex.Accounts.User do
 | 
				
			|||||||
  """
 | 
					  """
 | 
				
			||||||
  @spec validate_current_password(changeset(), String.t()) :: changeset()
 | 
					  @spec validate_current_password(changeset(), String.t()) :: changeset()
 | 
				
			||||||
  def validate_current_password(changeset, password) do
 | 
					  def validate_current_password(changeset, password) do
 | 
				
			||||||
 | 
					    changeset = cast(changeset, %{current_password: password}, [:current_password])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if valid_password?(changeset.data, password),
 | 
					    if valid_password?(changeset.data, password),
 | 
				
			||||||
      do: changeset,
 | 
					      do: changeset,
 | 
				
			||||||
      else: changeset |> add_error(:current_password, dgettext("errors", "is not valid"))
 | 
					      else: changeset |> add_error(:current_password, dgettext("errors", "is not valid"))
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,10 +3,7 @@ defmodule Memex.Accounts.UserToken do
 | 
				
			|||||||
  Schema for a user's session token
 | 
					  Schema for a user's session token
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  use Ecto.Schema
 | 
					  use Memex, :schema
 | 
				
			||||||
  import Ecto.Query
 | 
					 | 
				
			||||||
  alias Ecto.{Association, UUID}
 | 
					 | 
				
			||||||
  alias Memex.Accounts.User
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @hash_algorithm :sha256
 | 
					  @hash_algorithm :sha256
 | 
				
			||||||
  @rand_size 32
 | 
					  @rand_size 32
 | 
				
			||||||
@@ -18,8 +15,6 @@ defmodule Memex.Accounts.UserToken do
 | 
				
			|||||||
  @change_email_validity_in_days 7
 | 
					  @change_email_validity_in_days 7
 | 
				
			||||||
  @session_validity_in_days 60
 | 
					  @session_validity_in_days 60
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @primary_key {:id, :binary_id, autogenerate: true}
 | 
					 | 
				
			||||||
  @foreign_key_type :binary_id
 | 
					 | 
				
			||||||
  schema "users_tokens" do
 | 
					  schema "users_tokens" do
 | 
				
			||||||
    field :token, :binary
 | 
					    field :token, :binary
 | 
				
			||||||
    field :context, :string
 | 
					    field :context, :string
 | 
				
			||||||
@@ -37,7 +32,7 @@ defmodule Memex.Accounts.UserToken do
 | 
				
			|||||||
          sent_to: String.t(),
 | 
					          sent_to: String.t(),
 | 
				
			||||||
          user: User.t() | Association.NotLoaded.t(),
 | 
					          user: User.t() | Association.NotLoaded.t(),
 | 
				
			||||||
          user_id: User.id() | nil,
 | 
					          user_id: User.id() | nil,
 | 
				
			||||||
          inserted_at: NaiveDateTime.t()
 | 
					          inserted_at: DateTime.t()
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
  @type new_user_token :: %__MODULE__{}
 | 
					  @type new_user_token :: %__MODULE__{}
 | 
				
			||||||
  @type id :: UUID.t()
 | 
					  @type id :: UUID.t()
 | 
				
			||||||
@@ -155,7 +150,7 @@ defmodule Memex.Accounts.UserToken do
 | 
				
			|||||||
    from t in __MODULE__, where: t.user_id == ^user.id
 | 
					    from t in __MODULE__, where: t.user_id == ^user.id
 | 
				
			||||||
  end
 | 
					  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
 | 
					    from t in __MODULE__, where: t.user_id == ^user.id and t.context in ^contexts
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -15,6 +15,7 @@ defmodule Memex.Application do
 | 
				
			|||||||
      MemexWeb.Telemetry,
 | 
					      MemexWeb.Telemetry,
 | 
				
			||||||
      # Start the PubSub system
 | 
					      # Start the PubSub system
 | 
				
			||||||
      {Phoenix.PubSub, name: Memex.PubSub},
 | 
					      {Phoenix.PubSub, name: Memex.PubSub},
 | 
				
			||||||
 | 
					      {DNSCluster, query: Application.get_env(:memex, :dns_cluster_query) || :ignore},
 | 
				
			||||||
      # Start the Endpoint (http/https)
 | 
					      # Start the Endpoint (http/https)
 | 
				
			||||||
      MemexWeb.Endpoint,
 | 
					      MemexWeb.Endpoint,
 | 
				
			||||||
      # Add Oban
 | 
					      # Add Oban
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,8 +3,8 @@ defmodule Memex.Contexts do
 | 
				
			|||||||
  The Contexts context.
 | 
					  The Contexts context.
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  import Ecto.Query, warn: false
 | 
					  use Memex, :context
 | 
				
			||||||
  alias Memex.{Accounts.User, Contexts.Context, Repo}
 | 
					  alias Memex.Contexts.Context
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @doc """
 | 
					  @doc """
 | 
				
			||||||
  Returns the list of contexts.
 | 
					  Returns the list of contexts.
 | 
				
			||||||
@@ -22,16 +22,16 @@ defmodule Memex.Contexts do
 | 
				
			|||||||
  @spec list_contexts(search :: String.t() | nil, User.t()) :: [Context.t()]
 | 
					  @spec list_contexts(search :: String.t() | nil, User.t()) :: [Context.t()]
 | 
				
			||||||
  def list_contexts(search \\ nil, user)
 | 
					  def list_contexts(search \\ nil, user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def list_contexts(search, %{id: user_id}) when search |> is_nil() or search == "" do
 | 
					  def list_contexts(search, %{id: user_id}) when user_id |> is_binary() and search in [nil, ""] do
 | 
				
			||||||
    Repo.all(from c in Context, where: c.user_id == ^user_id, order_by: c.slug)
 | 
					    Repo.all(from c in Context, order_by: c.slug)
 | 
				
			||||||
  end
 | 
					  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)
 | 
					    trimmed_search = String.trim(search)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Repo.all(
 | 
					    Repo.all(
 | 
				
			||||||
      from c in Context,
 | 
					      from c in Context,
 | 
				
			||||||
        where: c.user_id == ^user_id,
 | 
					 | 
				
			||||||
        where:
 | 
					        where:
 | 
				
			||||||
          fragment(
 | 
					          fragment(
 | 
				
			||||||
            "search @@ websearch_to_tsquery('english', ?)",
 | 
					            "search @@ websearch_to_tsquery('english', ?)",
 | 
				
			||||||
@@ -63,7 +63,7 @@ defmodule Memex.Contexts do
 | 
				
			|||||||
  @spec list_public_contexts(search :: String.t() | nil) :: [Context.t()]
 | 
					  @spec list_public_contexts(search :: String.t() | nil) :: [Context.t()]
 | 
				
			||||||
  def list_public_contexts(search \\ nil)
 | 
					  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)
 | 
					    Repo.all(from c in Context, where: c.visibility == :public, order_by: c.slug)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -88,6 +88,42 @@ defmodule Memex.Contexts do
 | 
				
			|||||||
    )
 | 
					    )
 | 
				
			||||||
  end
 | 
					  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 """
 | 
					  @doc """
 | 
				
			||||||
  Gets a single context.
 | 
					  Gets a single context.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -103,12 +139,8 @@ defmodule Memex.Contexts do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
  @spec get_context!(Context.id(), User.t()) :: Context.t()
 | 
					  @spec get_context!(Context.id(), User.t()) :: Context.t()
 | 
				
			||||||
  def get_context!(id, %{id: user_id}) do
 | 
					  def get_context!(id, %{id: user_id}) when user_id |> is_binary() do
 | 
				
			||||||
    Repo.one!(
 | 
					    Repo.one!(from c in Context, where: c.id == ^id)
 | 
				
			||||||
      from c in Context,
 | 
					 | 
				
			||||||
        where: c.id == ^id,
 | 
					 | 
				
			||||||
        where: c.user_id == ^user_id or c.visibility in [:public, :unlisted]
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def get_context!(id, _invalid_user) do
 | 
					  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
 | 
					  @spec get_context_by_slug(Context.slug(), User.t()) :: Context.t() | nil
 | 
				
			||||||
  def get_context_by_slug(slug, %{id: user_id}) do
 | 
					  def get_context_by_slug(slug, %{id: user_id}) when user_id |> is_binary() do
 | 
				
			||||||
    Repo.one(
 | 
					    Repo.one(from c in Context, where: c.slug == ^slug)
 | 
				
			||||||
      from c in Context,
 | 
					 | 
				
			||||||
        where: c.slug == ^slug,
 | 
					 | 
				
			||||||
        where: c.user_id == ^user_id or c.visibility in [:public, :unlisted]
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def get_context_by_slug(slug, _invalid_user) do
 | 
					  def get_context_by_slug(slug, _invalid_user) do
 | 
				
			||||||
@@ -194,23 +222,16 @@ defmodule Memex.Contexts do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  ## Examples
 | 
					  ## 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})
 | 
					      iex> delete_context(%Context{}, %User{id: 123})
 | 
				
			||||||
 | 
					      {:ok, %Context{}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      iex> delete_context(%Context{}, nil)
 | 
				
			||||||
      {:error, %Ecto.Changeset{}}
 | 
					      {:error, %Ecto.Changeset{}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
  @spec delete_context(Context.t(), User.t()) ::
 | 
					  @spec delete_context(Context.t(), User.t()) ::
 | 
				
			||||||
          {:ok, Context.t()} | {:error, Context.changeset()}
 | 
					          {:ok, Context.t()} | {:error, Context.changeset()}
 | 
				
			||||||
  def delete_context(%Context{user_id: user_id} = context, %{id: user_id}) do
 | 
					  def delete_context(%Context{} = context, %{id: user_id}) when user_id |> is_binary() do
 | 
				
			||||||
    context |> Repo.delete()
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  def delete_context(%Context{} = context, %{role: :admin}) do
 | 
					 | 
				
			||||||
    context |> Repo.delete()
 | 
					    context |> Repo.delete()
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -228,13 +249,4 @@ defmodule Memex.Contexts do
 | 
				
			|||||||
  def change_context(%Context{} = context, attrs \\ %{}, user) do
 | 
					  def change_context(%Context{} = context, attrs \\ %{}, user) do
 | 
				
			||||||
    context |> Context.update_changeset(attrs, user)
 | 
					    context |> Context.update_changeset(attrs, user)
 | 
				
			||||||
  end
 | 
					  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
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,12 +3,10 @@ defmodule Memex.Contexts.Context do
 | 
				
			|||||||
  Represents a document that synthesizes multiple concepts as defined by notes
 | 
					  Represents a document that synthesizes multiple concepts as defined by notes
 | 
				
			||||||
  into a single consideration
 | 
					  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,
 | 
					  @derive {Jason.Encoder,
 | 
				
			||||||
           only: [
 | 
					           only: [
 | 
				
			||||||
             :slug,
 | 
					             :slug,
 | 
				
			||||||
@@ -18,8 +16,6 @@ defmodule Memex.Contexts.Context do
 | 
				
			|||||||
             :inserted_at,
 | 
					             :inserted_at,
 | 
				
			||||||
             :updated_at
 | 
					             :updated_at
 | 
				
			||||||
           ]}
 | 
					           ]}
 | 
				
			||||||
  @primary_key {:id, :binary_id, autogenerate: true}
 | 
					 | 
				
			||||||
  @foreign_key_type :binary_id
 | 
					 | 
				
			||||||
  schema "contexts" do
 | 
					  schema "contexts" do
 | 
				
			||||||
    field :slug, :string
 | 
					    field :slug, :string
 | 
				
			||||||
    field :content, :string
 | 
					    field :content, :string
 | 
				
			||||||
@@ -29,7 +25,7 @@ defmodule Memex.Contexts.Context do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    field :user_id, :binary_id
 | 
					    field :user_id, :binary_id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    timestamps()
 | 
					    timestamps(type: :utc_datetime_usec)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @type t :: %__MODULE__{
 | 
					  @type t :: %__MODULE__{
 | 
				
			||||||
@@ -39,8 +35,8 @@ defmodule Memex.Contexts.Context do
 | 
				
			|||||||
          tags_string: String.t() | nil,
 | 
					          tags_string: String.t() | nil,
 | 
				
			||||||
          visibility: :public | :private | :unlisted,
 | 
					          visibility: :public | :private | :unlisted,
 | 
				
			||||||
          user_id: User.id(),
 | 
					          user_id: User.id(),
 | 
				
			||||||
          inserted_at: NaiveDateTime.t(),
 | 
					          inserted_at: DateTime.t(),
 | 
				
			||||||
          updated_at: NaiveDateTime.t()
 | 
					          updated_at: DateTime.t()
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
  @type id :: UUID.t()
 | 
					  @type id :: UUID.t()
 | 
				
			||||||
  @type slug :: String.t()
 | 
					  @type slug :: String.t()
 | 
				
			||||||
@@ -56,33 +52,34 @@ defmodule Memex.Contexts.Context do
 | 
				
			|||||||
    |> validate_format(:slug, ~r/^[\p{L}\p{N}\-]+$/,
 | 
					    |> validate_format(:slug, ~r/^[\p{L}\p{N}\-]+$/,
 | 
				
			||||||
      message: dgettext("errors", "invalid format: only numbers, letters and hyphen are accepted")
 | 
					      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)
 | 
					    |> unique_constraint(:slug)
 | 
				
			||||||
    |> unsafe_validate_unique(:slug, Repo)
 | 
					    |> unsafe_validate_unique(:slug, Memex.Repo)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @spec update_changeset(t(), attrs :: map(), User.t()) :: changeset()
 | 
					  @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__{} = context, attrs, %User{id: user_id})
 | 
				
			||||||
    note
 | 
					      when user_id |> is_binary() do
 | 
				
			||||||
 | 
					    context
 | 
				
			||||||
    |> cast(attrs, [:slug, :content, :tags, :visibility])
 | 
					    |> cast(attrs, [:slug, :content, :tags, :visibility])
 | 
				
			||||||
    |> cast_tags_string(attrs)
 | 
					    |> cast_tags_string(attrs)
 | 
				
			||||||
    |> validate_format(:slug, ~r/^[\p{L}\p{N}\-]+$/,
 | 
					    |> validate_format(:slug, ~r/^[\p{L}\p{N}\-]+$/,
 | 
				
			||||||
      message: dgettext("errors", "invalid format: only numbers, letters and hyphen are accepted")
 | 
					      message: dgettext("errors", "invalid format: only numbers, letters and hyphen are accepted")
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    |> validate_required([:slug, :content, :visibility])
 | 
					    |> validate_required([:slug, :visibility])
 | 
				
			||||||
    |> unique_constraint(:slug)
 | 
					    |> unique_constraint(:slug)
 | 
				
			||||||
    |> unsafe_validate_unique(:slug, Repo)
 | 
					    |> unsafe_validate_unique(:slug, Memex.Repo)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  defp cast_tags_string(changeset, attrs) do
 | 
					  defp cast_tags_string(changeset, attrs) do
 | 
				
			||||||
    changeset
 | 
					    changeset
 | 
				
			||||||
    |> put_change(:tags_string, changeset |> get_field(:tags) |> get_tags_string())
 | 
					    |> put_change(:tags_string, changeset |> get_field(:tags) |> get_tags_string())
 | 
				
			||||||
    |> cast(attrs, [: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:
 | 
					      message:
 | 
				
			||||||
        dgettext(
 | 
					        dgettext(
 | 
				
			||||||
          "errors",
 | 
					          "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()
 | 
					    |> cast_tags()
 | 
				
			||||||
@@ -97,9 +94,9 @@ defmodule Memex.Contexts.Context do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  defp process_tags(tags_string) when tags_string |> is_binary() do
 | 
					  defp process_tags(tags_string) when tags_string |> is_binary() do
 | 
				
			||||||
    tags_string
 | 
					    tags_string
 | 
				
			||||||
    |> String.split(",", trim: true)
 | 
					    |> String.split([",", " "], trim: true)
 | 
				
			||||||
    |> Enum.map(fn str -> str |> String.trim() end)
 | 
					    |> 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()
 | 
					    |> Enum.sort()
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										56
									
								
								lib/memex/email.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								lib/memex/email.ex
									
									
									
									
									
										Normal 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
 | 
				
			||||||
@@ -14,17 +14,17 @@ defmodule Memex.Logger do
 | 
				
			|||||||
      |> Map.put(:stacktrace, Exception.format_stacktrace(stacktrace))
 | 
					      |> Map.put(:stacktrace, Exception.format_stacktrace(stacktrace))
 | 
				
			||||||
      |> pretty_encode()
 | 
					      |> pretty_encode()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Logger.error(meta.reason, data: data)
 | 
					    Logger.error("#{meta.reason} #{data}")
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def handle_event([:oban, :job, :start], measure, meta, _config) do
 | 
					  def handle_event([:oban, :job, :start], measure, meta, _config) do
 | 
				
			||||||
    data = get_oban_job_data(meta, measure) |> pretty_encode()
 | 
					    data = get_oban_job_data(meta, measure) |> pretty_encode()
 | 
				
			||||||
    Logger.info("Started oban job", data: data)
 | 
					    Logger.info("Started oban job: #{data}")
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def handle_event([:oban, :job, :stop], measure, meta, _config) do
 | 
					  def handle_event([:oban, :job, :stop], measure, meta, _config) do
 | 
				
			||||||
    data = get_oban_job_data(meta, measure) |> pretty_encode()
 | 
					    data = get_oban_job_data(meta, measure) |> pretty_encode()
 | 
				
			||||||
    Logger.info("Finished oban job", data: data)
 | 
					    Logger.info("Finished oban job: #{data}")
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def handle_event([:oban, :job, unhandled_event], measure, meta, _config) do
 | 
					  def handle_event([:oban, :job, unhandled_event], measure, meta, _config) do
 | 
				
			||||||
@@ -33,7 +33,7 @@ defmodule Memex.Logger do
 | 
				
			|||||||
      |> Map.put(:event, unhandled_event)
 | 
					      |> Map.put(:event, unhandled_event)
 | 
				
			||||||
      |> pretty_encode()
 | 
					      |> pretty_encode()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Logger.warning("Unhandled oban job event", data: data)
 | 
					    Logger.warning("Unhandled oban job event: #{data}")
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def handle_event(unhandled_event, measure, meta, config) do
 | 
					  def handle_event(unhandled_event, measure, meta, config) do
 | 
				
			||||||
@@ -45,7 +45,7 @@ defmodule Memex.Logger do
 | 
				
			|||||||
        config: config
 | 
					        config: config
 | 
				
			||||||
      })
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Logger.warning("Unhandled telemetry event", data: data)
 | 
					    Logger.warning("Unhandled telemetry event: #{data}")
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  defp get_oban_job_data(%{job: job}, measure) do
 | 
					  defp get_oban_job_data(%{job: job}, measure) do
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,8 +3,8 @@ defmodule Memex.Notes do
 | 
				
			|||||||
  The Notes context.
 | 
					  The Notes context.
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  import Ecto.Query, warn: false
 | 
					  use Memex, :context
 | 
				
			||||||
  alias Memex.{Accounts.User, Notes.Note, Repo}
 | 
					  alias Memex.Notes.Note
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @doc """
 | 
					  @doc """
 | 
				
			||||||
  Returns the list of notes.
 | 
					  Returns the list of notes.
 | 
				
			||||||
@@ -22,16 +22,15 @@ defmodule Memex.Notes do
 | 
				
			|||||||
  @spec list_notes(search :: String.t() | nil, User.t()) :: [Note.t()]
 | 
					  @spec list_notes(search :: String.t() | nil, User.t()) :: [Note.t()]
 | 
				
			||||||
  def list_notes(search \\ nil, user)
 | 
					  def list_notes(search \\ nil, user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def list_notes(search, %{id: user_id}) when search |> is_nil() or search == "" do
 | 
					  def list_notes(search, %{id: user_id}) when user_id |> is_binary() and search in [nil, ""] do
 | 
				
			||||||
    Repo.all(from n in Note, where: n.user_id == ^user_id, order_by: n.slug)
 | 
					    Repo.all(from n in Note, order_by: n.slug)
 | 
				
			||||||
  end
 | 
					  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)
 | 
					    trimmed_search = String.trim(search)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Repo.all(
 | 
					    Repo.all(
 | 
				
			||||||
      from n in Note,
 | 
					      from n in Note,
 | 
				
			||||||
        where: n.user_id == ^user_id,
 | 
					 | 
				
			||||||
        where:
 | 
					        where:
 | 
				
			||||||
          fragment(
 | 
					          fragment(
 | 
				
			||||||
            "search @@ websearch_to_tsquery('english', ?)",
 | 
					            "search @@ websearch_to_tsquery('english', ?)",
 | 
				
			||||||
@@ -62,7 +61,7 @@ defmodule Memex.Notes do
 | 
				
			|||||||
  @spec list_public_notes(search :: String.t() | nil) :: [Note.t()]
 | 
					  @spec list_public_notes(search :: String.t() | nil) :: [Note.t()]
 | 
				
			||||||
  def list_public_notes(search \\ nil)
 | 
					  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)
 | 
					    Repo.all(from n in Note, where: n.visibility == :public, order_by: n.slug)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -87,6 +86,42 @@ defmodule Memex.Notes do
 | 
				
			|||||||
    )
 | 
					    )
 | 
				
			||||||
  end
 | 
					  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 """
 | 
					  @doc """
 | 
				
			||||||
  Gets a single note.
 | 
					  Gets a single note.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -102,12 +137,8 @@ defmodule Memex.Notes do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
  @spec get_note!(Note.id(), User.t()) :: Note.t()
 | 
					  @spec get_note!(Note.id(), User.t()) :: Note.t()
 | 
				
			||||||
  def get_note!(id, %{id: user_id}) do
 | 
					  def get_note!(id, %{id: user_id}) when user_id |> is_binary() do
 | 
				
			||||||
    Repo.one!(
 | 
					    Repo.one!(from n in Note, where: n.id == ^id)
 | 
				
			||||||
      from n in Note,
 | 
					 | 
				
			||||||
        where: n.id == ^id,
 | 
					 | 
				
			||||||
        where: n.user_id == ^user_id or n.visibility in [:public, :unlisted]
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def get_note!(id, _invalid_user) do
 | 
					  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
 | 
					  @spec get_note_by_slug(Note.slug(), User.t()) :: Note.t() | nil
 | 
				
			||||||
  def get_note_by_slug(slug, %{id: user_id}) do
 | 
					  def get_note_by_slug(slug, %{id: user_id}) when user_id |> is_binary() do
 | 
				
			||||||
    Repo.one(
 | 
					    Repo.one(from n in Note, where: n.slug == ^slug)
 | 
				
			||||||
      from n in Note,
 | 
					 | 
				
			||||||
        where: n.slug == ^slug,
 | 
					 | 
				
			||||||
        where: n.user_id == ^user_id or n.visibility in [:public, :unlisted]
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def get_note_by_slug(slug, _invalid_user) do
 | 
					  def get_note_by_slug(slug, _invalid_user) do
 | 
				
			||||||
@@ -192,22 +219,15 @@ defmodule Memex.Notes do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  ## Examples
 | 
					  ## 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})
 | 
					      iex> delete_note(%Note{}, %User{id: 123})
 | 
				
			||||||
 | 
					      {:ok, %Note{}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      iex> delete_note(%Note{}, nil)
 | 
				
			||||||
      {:error, %Ecto.Changeset{}}
 | 
					      {:error, %Ecto.Changeset{}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
  @spec delete_note(Note.t(), User.t()) :: {:ok, Note.t()} | {:error, Note.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
 | 
					  def delete_note(%Note{} = note, %{id: user_id}) when user_id |> is_binary() do
 | 
				
			||||||
    note |> Repo.delete()
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  def delete_note(%Note{} = note, %{role: :admin}) do
 | 
					 | 
				
			||||||
    note |> Repo.delete()
 | 
					    note |> Repo.delete()
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -228,13 +248,4 @@ defmodule Memex.Notes do
 | 
				
			|||||||
  def change_note(%Note{} = note, attrs \\ %{}, user) do
 | 
					  def change_note(%Note{} = note, attrs \\ %{}, user) do
 | 
				
			||||||
    note |> Note.update_changeset(attrs, user)
 | 
					    note |> Note.update_changeset(attrs, user)
 | 
				
			||||||
  end
 | 
					  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
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,12 +2,10 @@ defmodule Memex.Notes.Note do
 | 
				
			|||||||
  @moduledoc """
 | 
					  @moduledoc """
 | 
				
			||||||
  Schema for a user-written note
 | 
					  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,
 | 
					  @derive {Jason.Encoder,
 | 
				
			||||||
           only: [
 | 
					           only: [
 | 
				
			||||||
             :slug,
 | 
					             :slug,
 | 
				
			||||||
@@ -17,8 +15,6 @@ defmodule Memex.Notes.Note do
 | 
				
			|||||||
             :inserted_at,
 | 
					             :inserted_at,
 | 
				
			||||||
             :updated_at
 | 
					             :updated_at
 | 
				
			||||||
           ]}
 | 
					           ]}
 | 
				
			||||||
  @primary_key {:id, :binary_id, autogenerate: true}
 | 
					 | 
				
			||||||
  @foreign_key_type :binary_id
 | 
					 | 
				
			||||||
  schema "notes" do
 | 
					  schema "notes" do
 | 
				
			||||||
    field :slug, :string
 | 
					    field :slug, :string
 | 
				
			||||||
    field :content, :string
 | 
					    field :content, :string
 | 
				
			||||||
@@ -28,7 +24,7 @@ defmodule Memex.Notes.Note do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    field :user_id, :binary_id
 | 
					    field :user_id, :binary_id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    timestamps()
 | 
					    timestamps(type: :utc_datetime_usec)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @type t :: %__MODULE__{
 | 
					  @type t :: %__MODULE__{
 | 
				
			||||||
@@ -38,8 +34,8 @@ defmodule Memex.Notes.Note do
 | 
				
			|||||||
          tags_string: String.t() | nil,
 | 
					          tags_string: String.t() | nil,
 | 
				
			||||||
          visibility: :public | :private | :unlisted,
 | 
					          visibility: :public | :private | :unlisted,
 | 
				
			||||||
          user_id: User.id(),
 | 
					          user_id: User.id(),
 | 
				
			||||||
          inserted_at: NaiveDateTime.t(),
 | 
					          inserted_at: DateTime.t(),
 | 
				
			||||||
          updated_at: NaiveDateTime.t()
 | 
					          updated_at: DateTime.t()
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
  @type id :: UUID.t()
 | 
					  @type id :: UUID.t()
 | 
				
			||||||
  @type slug :: String.t()
 | 
					  @type slug :: String.t()
 | 
				
			||||||
@@ -55,33 +51,34 @@ defmodule Memex.Notes.Note do
 | 
				
			|||||||
    |> validate_format(:slug, ~r/^[\p{L}\p{N}\-]+$/,
 | 
					    |> validate_format(:slug, ~r/^[\p{L}\p{N}\-]+$/,
 | 
				
			||||||
      message: dgettext("errors", "invalid format: only numbers, letters and hyphen are accepted")
 | 
					      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)
 | 
					    |> unique_constraint(:slug)
 | 
				
			||||||
    |> unsafe_validate_unique(:slug, Repo)
 | 
					    |> unsafe_validate_unique(:slug, Memex.Repo)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @spec update_changeset(t(), attrs :: map(), User.t()) :: changeset()
 | 
					  @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
 | 
					    note
 | 
				
			||||||
    |> cast(attrs, [:slug, :content, :tags, :visibility])
 | 
					    |> cast(attrs, [:slug, :content, :tags, :visibility])
 | 
				
			||||||
    |> cast_tags_string(attrs)
 | 
					    |> cast_tags_string(attrs)
 | 
				
			||||||
    |> validate_format(:slug, ~r/^[\p{L}\p{N}\-]+$/,
 | 
					    |> validate_format(:slug, ~r/^[\p{L}\p{N}\-]+$/,
 | 
				
			||||||
      message: dgettext("errors", "invalid format: only numbers, letters and hyphen are accepted")
 | 
					      message: dgettext("errors", "invalid format: only numbers, letters and hyphen are accepted")
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    |> validate_required([:slug, :content, :visibility])
 | 
					    |> validate_required([:slug, :visibility])
 | 
				
			||||||
    |> unique_constraint(:slug)
 | 
					    |> unique_constraint(:slug)
 | 
				
			||||||
    |> unsafe_validate_unique(:slug, Repo)
 | 
					    |> unsafe_validate_unique(:slug, Memex.Repo)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  defp cast_tags_string(changeset, attrs) do
 | 
					  defp cast_tags_string(changeset, attrs) do
 | 
				
			||||||
    changeset
 | 
					    changeset
 | 
				
			||||||
    |> put_change(:tags_string, changeset |> get_field(:tags) |> get_tags_string())
 | 
					    |> put_change(:tags_string, changeset |> get_field(:tags) |> get_tags_string())
 | 
				
			||||||
    |> cast(attrs, [: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:
 | 
					      message:
 | 
				
			||||||
        dgettext(
 | 
					        dgettext(
 | 
				
			||||||
          "errors",
 | 
					          "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()
 | 
					    |> cast_tags()
 | 
				
			||||||
@@ -96,9 +93,9 @@ defmodule Memex.Notes.Note do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  defp process_tags(tags_string) when tags_string |> is_binary() do
 | 
					  defp process_tags(tags_string) when tags_string |> is_binary() do
 | 
				
			||||||
    tags_string
 | 
					    tags_string
 | 
				
			||||||
    |> String.split(",", trim: true)
 | 
					    |> String.split([",", " "], trim: true)
 | 
				
			||||||
    |> Enum.map(fn str -> str |> String.trim() end)
 | 
					    |> 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()
 | 
					    |> Enum.sort()
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,8 +3,8 @@ defmodule Memex.Pipelines do
 | 
				
			|||||||
  The Pipelines context.
 | 
					  The Pipelines context.
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  import Ecto.Query, warn: false
 | 
					  use Memex, :context
 | 
				
			||||||
  alias Memex.{Accounts.User, Pipelines.Pipeline, Repo}
 | 
					  alias Memex.Pipelines.Pipeline
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @doc """
 | 
					  @doc """
 | 
				
			||||||
  Returns the list of pipelines.
 | 
					  Returns the list of pipelines.
 | 
				
			||||||
@@ -22,16 +22,17 @@ defmodule Memex.Pipelines do
 | 
				
			|||||||
  @spec list_pipelines(search :: String.t() | nil, User.t()) :: [Pipeline.t()]
 | 
					  @spec list_pipelines(search :: String.t() | nil, User.t()) :: [Pipeline.t()]
 | 
				
			||||||
  def list_pipelines(search \\ nil, user)
 | 
					  def list_pipelines(search \\ nil, user)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def list_pipelines(search, %{id: user_id}) when search |> is_nil() or search == "" do
 | 
					  def list_pipelines(search, %{id: user_id})
 | 
				
			||||||
    Repo.all(from p in Pipeline, where: p.user_id == ^user_id, order_by: p.slug)
 | 
					      when user_id |> is_binary() and search in [nil, ""] do
 | 
				
			||||||
 | 
					    Repo.all(from p in Pipeline, order_by: p.slug)
 | 
				
			||||||
  end
 | 
					  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)
 | 
					    trimmed_search = String.trim(search)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Repo.all(
 | 
					    Repo.all(
 | 
				
			||||||
      from p in Pipeline,
 | 
					      from p in Pipeline,
 | 
				
			||||||
        where: p.user_id == ^user_id,
 | 
					 | 
				
			||||||
        where:
 | 
					        where:
 | 
				
			||||||
          fragment(
 | 
					          fragment(
 | 
				
			||||||
            "search @@ websearch_to_tsquery('english', ?)",
 | 
					            "search @@ websearch_to_tsquery('english', ?)",
 | 
				
			||||||
@@ -62,7 +63,7 @@ defmodule Memex.Pipelines do
 | 
				
			|||||||
  @spec list_public_pipelines(search :: String.t() | nil) :: [Pipeline.t()]
 | 
					  @spec list_public_pipelines(search :: String.t() | nil) :: [Pipeline.t()]
 | 
				
			||||||
  def list_public_pipelines(search \\ nil)
 | 
					  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)
 | 
					    Repo.all(from p in Pipeline, where: p.visibility == :public, order_by: p.slug)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -102,12 +103,8 @@ defmodule Memex.Pipelines do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
  @spec get_pipeline!(Pipeline.id(), User.t()) :: Pipeline.t()
 | 
					  @spec get_pipeline!(Pipeline.id(), User.t()) :: Pipeline.t()
 | 
				
			||||||
  def get_pipeline!(id, %{id: user_id}) do
 | 
					  def get_pipeline!(id, %{id: user_id}) when user_id |> is_binary() do
 | 
				
			||||||
    Repo.one!(
 | 
					    Repo.one!(from p in Pipeline, where: p.id == ^id)
 | 
				
			||||||
      from p in Pipeline,
 | 
					 | 
				
			||||||
        where: p.id == ^id,
 | 
					 | 
				
			||||||
        where: p.user_id == ^user_id or p.visibility in [:public, :unlisted]
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def get_pipeline!(id, _invalid_user) do
 | 
					  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
 | 
					  @spec get_pipeline_by_slug(Pipeline.slug(), User.t()) :: Pipeline.t() | nil
 | 
				
			||||||
  def get_pipeline_by_slug(slug, %{id: user_id}) do
 | 
					  def get_pipeline_by_slug(slug, %{id: user_id}) when user_id |> is_binary() do
 | 
				
			||||||
    Repo.one(
 | 
					    Repo.one(from p in Pipeline, where: p.slug == ^slug)
 | 
				
			||||||
      from p in Pipeline,
 | 
					 | 
				
			||||||
        where: p.slug == ^slug,
 | 
					 | 
				
			||||||
        where: p.user_id == ^user_id or p.visibility in [:public, :unlisted]
 | 
					 | 
				
			||||||
    )
 | 
					 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def get_pipeline_by_slug(slug, _invalid_user) do
 | 
					  def get_pipeline_by_slug(slug, _invalid_user) do
 | 
				
			||||||
@@ -149,6 +142,50 @@ defmodule Memex.Pipelines do
 | 
				
			|||||||
    )
 | 
					    )
 | 
				
			||||||
  end
 | 
					  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 """
 | 
					  @doc """
 | 
				
			||||||
  Creates a pipeline.
 | 
					  Creates a pipeline.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -193,23 +230,16 @@ defmodule Memex.Pipelines do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  ## Examples
 | 
					  ## 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})
 | 
					      iex> delete_pipeline(%Pipeline{}, %User{id: 123})
 | 
				
			||||||
 | 
					      {:ok, %Pipeline{}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      iex> delete_pipeline(%Pipeline{}, nil)
 | 
				
			||||||
      {:error, %Ecto.Changeset{}}
 | 
					      {:error, %Ecto.Changeset{}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
  @spec delete_pipeline(Pipeline.t(), User.t()) ::
 | 
					  @spec delete_pipeline(Pipeline.t(), User.t()) ::
 | 
				
			||||||
          {:ok, Pipeline.t()} | {:error, Pipeline.changeset()}
 | 
					          {:ok, Pipeline.t()} | {:error, Pipeline.changeset()}
 | 
				
			||||||
  def delete_pipeline(%Pipeline{user_id: user_id} = pipeline, %{id: user_id}) do
 | 
					  def delete_pipeline(%Pipeline{} = pipeline, %{id: user_id}) when user_id |> is_binary() do
 | 
				
			||||||
    pipeline |> Repo.delete()
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  def delete_pipeline(%Pipeline{} = pipeline, %{role: :admin}) do
 | 
					 | 
				
			||||||
    pipeline |> Repo.delete()
 | 
					    pipeline |> Repo.delete()
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -230,13 +260,4 @@ defmodule Memex.Pipelines do
 | 
				
			|||||||
  def change_pipeline(%Pipeline{} = pipeline, attrs \\ %{}, user) do
 | 
					  def change_pipeline(%Pipeline{} = pipeline, attrs \\ %{}, user) do
 | 
				
			||||||
    pipeline |> Pipeline.update_changeset(attrs, user)
 | 
					    pipeline |> Pipeline.update_changeset(attrs, user)
 | 
				
			||||||
  end
 | 
					  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
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,12 +2,11 @@ defmodule Memex.Pipelines.Pipeline do
 | 
				
			|||||||
  @moduledoc """
 | 
					  @moduledoc """
 | 
				
			||||||
  Represents a chain of considerations to take to accomplish a task
 | 
					  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,
 | 
					  @derive {Jason.Encoder,
 | 
				
			||||||
           only: [
 | 
					           only: [
 | 
				
			||||||
             :slug,
 | 
					             :slug,
 | 
				
			||||||
@@ -18,8 +17,6 @@ defmodule Memex.Pipelines.Pipeline do
 | 
				
			|||||||
             :steps,
 | 
					             :steps,
 | 
				
			||||||
             :updated_at
 | 
					             :updated_at
 | 
				
			||||||
           ]}
 | 
					           ]}
 | 
				
			||||||
  @primary_key {:id, :binary_id, autogenerate: true}
 | 
					 | 
				
			||||||
  @foreign_key_type :binary_id
 | 
					 | 
				
			||||||
  schema "pipelines" do
 | 
					  schema "pipelines" do
 | 
				
			||||||
    field :slug, :string
 | 
					    field :slug, :string
 | 
				
			||||||
    field :description, :string
 | 
					    field :description, :string
 | 
				
			||||||
@@ -31,7 +28,7 @@ defmodule Memex.Pipelines.Pipeline do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    has_many :steps, Step, preload_order: [asc: :position]
 | 
					    has_many :steps, Step, preload_order: [asc: :position]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    timestamps()
 | 
					    timestamps(type: :utc_datetime_usec)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @type t :: %__MODULE__{
 | 
					  @type t :: %__MODULE__{
 | 
				
			||||||
@@ -41,8 +38,8 @@ defmodule Memex.Pipelines.Pipeline do
 | 
				
			|||||||
          tags_string: String.t() | nil,
 | 
					          tags_string: String.t() | nil,
 | 
				
			||||||
          visibility: :public | :private | :unlisted,
 | 
					          visibility: :public | :private | :unlisted,
 | 
				
			||||||
          user_id: User.id(),
 | 
					          user_id: User.id(),
 | 
				
			||||||
          inserted_at: NaiveDateTime.t(),
 | 
					          inserted_at: DateTime.t(),
 | 
				
			||||||
          updated_at: NaiveDateTime.t()
 | 
					          updated_at: DateTime.t()
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
  @type id :: UUID.t()
 | 
					  @type id :: UUID.t()
 | 
				
			||||||
  @type slug :: String.t()
 | 
					  @type slug :: String.t()
 | 
				
			||||||
@@ -60,11 +57,12 @@ defmodule Memex.Pipelines.Pipeline do
 | 
				
			|||||||
    )
 | 
					    )
 | 
				
			||||||
    |> validate_required([:slug, :user_id, :visibility])
 | 
					    |> validate_required([:slug, :user_id, :visibility])
 | 
				
			||||||
    |> unique_constraint(:slug)
 | 
					    |> unique_constraint(:slug)
 | 
				
			||||||
    |> unsafe_validate_unique(:slug, Repo)
 | 
					    |> unsafe_validate_unique(:slug, Memex.Repo)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @spec update_changeset(t(), attrs :: map(), User.t()) :: changeset()
 | 
					  @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
 | 
					    pipeline
 | 
				
			||||||
    |> cast(attrs, [:slug, :description, :tags, :visibility])
 | 
					    |> cast(attrs, [:slug, :description, :tags, :visibility])
 | 
				
			||||||
    |> cast_tags_string(attrs)
 | 
					    |> cast_tags_string(attrs)
 | 
				
			||||||
@@ -73,18 +71,18 @@ defmodule Memex.Pipelines.Pipeline do
 | 
				
			|||||||
    )
 | 
					    )
 | 
				
			||||||
    |> validate_required([:slug, :visibility])
 | 
					    |> validate_required([:slug, :visibility])
 | 
				
			||||||
    |> unique_constraint(:slug)
 | 
					    |> unique_constraint(:slug)
 | 
				
			||||||
    |> unsafe_validate_unique(:slug, Repo)
 | 
					    |> unsafe_validate_unique(:slug, Memex.Repo)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  defp cast_tags_string(changeset, attrs) do
 | 
					  defp cast_tags_string(changeset, attrs) do
 | 
				
			||||||
    changeset
 | 
					    changeset
 | 
				
			||||||
    |> put_change(:tags_string, changeset |> get_field(:tags) |> get_tags_string())
 | 
					    |> put_change(:tags_string, changeset |> get_field(:tags) |> get_tags_string())
 | 
				
			||||||
    |> cast(attrs, [: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:
 | 
					      message:
 | 
				
			||||||
        dgettext(
 | 
					        dgettext(
 | 
				
			||||||
          "errors",
 | 
					          "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()
 | 
					    |> cast_tags()
 | 
				
			||||||
@@ -99,9 +97,9 @@ defmodule Memex.Pipelines.Pipeline do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  defp process_tags(tags_string) when tags_string |> is_binary() do
 | 
					  defp process_tags(tags_string) when tags_string |> is_binary() do
 | 
				
			||||||
    tags_string
 | 
					    tags_string
 | 
				
			||||||
    |> String.split(",", trim: true)
 | 
					    |> String.split([",", " "], trim: true)
 | 
				
			||||||
    |> Enum.map(fn str -> str |> String.trim() end)
 | 
					    |> 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()
 | 
					    |> Enum.sort()
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,10 +2,9 @@ defmodule Memex.Pipelines.Steps.Step do
 | 
				
			|||||||
  @moduledoc """
 | 
					  @moduledoc """
 | 
				
			||||||
  Represents a step taken while executing a pipeline
 | 
					  Represents a step taken while executing a pipeline
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
  use Ecto.Schema
 | 
					
 | 
				
			||||||
  import Ecto.Changeset
 | 
					  use Memex, :schema
 | 
				
			||||||
  alias Ecto.{Changeset, UUID}
 | 
					  alias Memex.Pipelines.Pipeline
 | 
				
			||||||
  alias Memex.{Accounts.User, Pipelines.Pipeline}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @derive {Jason.Encoder,
 | 
					  @derive {Jason.Encoder,
 | 
				
			||||||
           only: [
 | 
					           only: [
 | 
				
			||||||
@@ -15,8 +14,6 @@ defmodule Memex.Pipelines.Steps.Step do
 | 
				
			|||||||
             :inserted_at,
 | 
					             :inserted_at,
 | 
				
			||||||
             :updated_at
 | 
					             :updated_at
 | 
				
			||||||
           ]}
 | 
					           ]}
 | 
				
			||||||
  @primary_key {:id, :binary_id, autogenerate: true}
 | 
					 | 
				
			||||||
  @foreign_key_type :binary_id
 | 
					 | 
				
			||||||
  schema "steps" do
 | 
					  schema "steps" do
 | 
				
			||||||
    field :title, :string
 | 
					    field :title, :string
 | 
				
			||||||
    field :content, :string
 | 
					    field :content, :string
 | 
				
			||||||
@@ -25,7 +22,7 @@ defmodule Memex.Pipelines.Steps.Step do
 | 
				
			|||||||
    belongs_to :pipeline, Pipeline
 | 
					    belongs_to :pipeline, Pipeline
 | 
				
			||||||
    field :user_id, :binary_id
 | 
					    field :user_id, :binary_id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    timestamps()
 | 
					    timestamps(type: :utc_datetime_usec)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @type t :: %__MODULE__{
 | 
					  @type t :: %__MODULE__{
 | 
				
			||||||
@@ -35,8 +32,8 @@ defmodule Memex.Pipelines.Steps.Step do
 | 
				
			|||||||
          pipeline: Pipeline.t() | Ecto.Association.NotLoaded.t(),
 | 
					          pipeline: Pipeline.t() | Ecto.Association.NotLoaded.t(),
 | 
				
			||||||
          pipeline_id: Pipeline.id(),
 | 
					          pipeline_id: Pipeline.id(),
 | 
				
			||||||
          user_id: User.id(),
 | 
					          user_id: User.id(),
 | 
				
			||||||
          inserted_at: NaiveDateTime.t(),
 | 
					          inserted_at: DateTime.t(),
 | 
				
			||||||
          updated_at: NaiveDateTime.t()
 | 
					          updated_at: DateTime.t()
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
  @type id :: UUID.t()
 | 
					  @type id :: UUID.t()
 | 
				
			||||||
  @type changeset :: Changeset.t(t())
 | 
					  @type changeset :: Changeset.t(t())
 | 
				
			||||||
@@ -44,35 +41,32 @@ defmodule Memex.Pipelines.Steps.Step do
 | 
				
			|||||||
  @doc false
 | 
					  @doc false
 | 
				
			||||||
  @spec create_changeset(attrs :: map(), position :: non_neg_integer(), Pipeline.t(), User.t()) ::
 | 
					  @spec create_changeset(attrs :: map(), position :: non_neg_integer(), Pipeline.t(), User.t()) ::
 | 
				
			||||||
          changeset()
 | 
					          changeset()
 | 
				
			||||||
  def create_changeset(attrs, position, %Pipeline{id: pipeline_id, user_id: user_id}, %User{
 | 
					  def create_changeset(
 | 
				
			||||||
        id: user_id
 | 
					        attrs,
 | 
				
			||||||
      }) do
 | 
					        position,
 | 
				
			||||||
 | 
					        %Pipeline{id: pipeline_id, user_id: user_id},
 | 
				
			||||||
 | 
					        %User{id: user_id}
 | 
				
			||||||
 | 
					      ) do
 | 
				
			||||||
    %__MODULE__{}
 | 
					    %__MODULE__{}
 | 
				
			||||||
    |> cast(attrs, [:title, :content])
 | 
					    |> cast(attrs, [:title, :content])
 | 
				
			||||||
    |> change(pipeline_id: pipeline_id, user_id: user_id, position: position)
 | 
					    |> 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
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @spec update_changeset(t(), attrs :: map(), User.t()) ::
 | 
					  @spec update_changeset(t(), attrs :: map(), User.t()) ::
 | 
				
			||||||
          changeset()
 | 
					          changeset()
 | 
				
			||||||
  def update_changeset(
 | 
					  def update_changeset(%__MODULE__{} = step, attrs, %User{id: user_id})
 | 
				
			||||||
        %{user_id: user_id} = step,
 | 
					      when user_id |> is_binary() do
 | 
				
			||||||
        attrs,
 | 
					 | 
				
			||||||
        %User{id: user_id}
 | 
					 | 
				
			||||||
      ) do
 | 
					 | 
				
			||||||
    step
 | 
					    step
 | 
				
			||||||
    |> cast(attrs, [:title, :content])
 | 
					    |> cast(attrs, [:title, :content])
 | 
				
			||||||
    |> validate_required([:title, :content, :user_id, :position])
 | 
					    |> validate_required([:title, :user_id, :position])
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @spec position_changeset(t(), position :: non_neg_integer(), User.t()) :: changeset()
 | 
					  @spec position_changeset(t(), position :: non_neg_integer(), User.t()) :: changeset()
 | 
				
			||||||
  def position_changeset(
 | 
					  def position_changeset(%__MODULE__{} = step, position, %User{id: user_id})
 | 
				
			||||||
        %{user_id: user_id} = step,
 | 
					      when user_id |> is_binary() do
 | 
				
			||||||
        position,
 | 
					 | 
				
			||||||
        %User{id: user_id}
 | 
					 | 
				
			||||||
      ) do
 | 
					 | 
				
			||||||
    step
 | 
					    step
 | 
				
			||||||
    |> change(position: position)
 | 
					    |> change(position: position)
 | 
				
			||||||
    |> validate_required([:title, :content, :user_id, :position])
 | 
					    |> validate_required([:title, :user_id, :position])
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,9 +3,7 @@ defmodule Memex.Pipelines.Steps do
 | 
				
			|||||||
  The context for steps within a pipeline
 | 
					  The context for steps within a pipeline
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  import Ecto.Query, warn: false
 | 
					  use Memex, :context
 | 
				
			||||||
  alias Ecto.Multi
 | 
					 | 
				
			||||||
  alias Memex.{Accounts.User, Repo}
 | 
					 | 
				
			||||||
  alias Memex.Pipelines.{Pipeline, Steps.Step}
 | 
					  alias Memex.Pipelines.{Pipeline, Steps.Step}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @doc """
 | 
					  @doc """
 | 
				
			||||||
@@ -21,11 +19,10 @@ defmodule Memex.Pipelines.Steps do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
  @spec list_steps(Pipeline.t(), User.t()) :: [Step.t()]
 | 
					  @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(
 | 
					    Repo.all(
 | 
				
			||||||
      from s in Step,
 | 
					      from s in Step,
 | 
				
			||||||
        where: s.pipeline_id == ^pipeline_id,
 | 
					        where: s.pipeline_id == ^pipeline_id,
 | 
				
			||||||
        where: s.user_id == ^user_id,
 | 
					 | 
				
			||||||
        order_by: s.position
 | 
					        order_by: s.position
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
@@ -62,8 +59,8 @@ defmodule Memex.Pipelines.Steps do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
  @spec get_step!(Step.id(), User.t()) :: Step.t()
 | 
					  @spec get_step!(Step.id(), User.t()) :: Step.t()
 | 
				
			||||||
  def get_step!(id, %{id: user_id}) do
 | 
					  def get_step!(id, %{id: user_id}) when user_id |> is_binary() do
 | 
				
			||||||
    Repo.one!(from n in Step, where: n.id == ^id, where: n.user_id == ^user_id)
 | 
					    Repo.one!(from n in Step, where: n.id == ^id)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def get_step!(id, _invalid_user) do
 | 
					  def get_step!(id, _invalid_user) do
 | 
				
			||||||
@@ -119,22 +116,15 @@ defmodule Memex.Pipelines.Steps do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  ## Examples
 | 
					  ## 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})
 | 
					      iex> delete_step(%Step{}, %User{id: 123})
 | 
				
			||||||
 | 
					      {:ok, %Step{}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      iex> delete_step(%Step{}, nil)
 | 
				
			||||||
      {:error, %Ecto.Changeset{}}
 | 
					      {:error, %Ecto.Changeset{}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
  @spec delete_step(Step.t(), User.t()) :: {:ok, Step.t()} | {:error, Step.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
 | 
					  def delete_step(%Step{} = step, %{id: user_id}) when user_id |> is_binary() do
 | 
				
			||||||
    delete_step(step)
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  def delete_step(%Step{} = step, %{role: :admin}) do
 | 
					 | 
				
			||||||
    delete_step(step)
 | 
					    delete_step(step)
 | 
				
			||||||
  end
 | 
					  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: 0} = step, :up, _user), do: {:error, step}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def reorder_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,
 | 
					        :up,
 | 
				
			||||||
        %{id: user_id} = user
 | 
					        %{id: user_id} = user
 | 
				
			||||||
      ) do
 | 
					      )
 | 
				
			||||||
 | 
					      when user_id |> is_binary() do
 | 
				
			||||||
    Multi.new()
 | 
					    Multi.new()
 | 
				
			||||||
    |> Multi.update_all(
 | 
					    |> Multi.update_all(
 | 
				
			||||||
      :reorder_steps,
 | 
					      :reorder_steps,
 | 
				
			||||||
@@ -207,10 +198,11 @@ defmodule Memex.Pipelines.Steps do
 | 
				
			|||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def reorder_step(
 | 
					  def reorder_step(
 | 
				
			||||||
        %Step{pipeline_id: pipeline_id, position: position, user_id: user_id} = step,
 | 
					        %Step{pipeline_id: pipeline_id, position: position} = step,
 | 
				
			||||||
        :down,
 | 
					        :down,
 | 
				
			||||||
        %{id: user_id} = user
 | 
					        %{id: user_id} = user
 | 
				
			||||||
      ) do
 | 
					      )
 | 
				
			||||||
 | 
					      when user_id |> is_binary() do
 | 
				
			||||||
    Multi.new()
 | 
					    Multi.new()
 | 
				
			||||||
    |> Multi.one(
 | 
					    |> Multi.one(
 | 
				
			||||||
      :step_count,
 | 
					      :step_count,
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										109
									
								
								lib/memex_web.ex
									
									
									
									
									
								
							
							
						
						
									
										109
									
								
								lib/memex_web.ex
									
									
									
									
									
								
							@@ -1,54 +1,62 @@
 | 
				
			|||||||
defmodule MemexWeb do
 | 
					defmodule MemexWeb do
 | 
				
			||||||
  @moduledoc """
 | 
					  @moduledoc """
 | 
				
			||||||
  The entrypoint for defining your web interface, such
 | 
					  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:
 | 
					  This can be used in your application as:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      use MemexWeb, :controller
 | 
					      use MemexWeb, :controller
 | 
				
			||||||
      use MemexWeb, :view
 | 
					      use MemexWeb, :html
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  The definitions below will be executed for every view,
 | 
					  The definitions below will be executed for every controller,
 | 
				
			||||||
  controller, etc, so keep them short and clean, focused
 | 
					  component, etc, so keep them short and clean, focused
 | 
				
			||||||
  on imports, uses and aliases.
 | 
					  on imports, uses and aliases.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Do NOT define functions inside the quoted expressions
 | 
					  Do NOT define functions inside the quoted expressions
 | 
				
			||||||
  below. Instead, define any helper function in modules
 | 
					  below. Instead, define additional modules and import
 | 
				
			||||||
  and import those modules here.
 | 
					  those modules here.
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def controller do
 | 
					  def static_paths, do: ~w(assets fonts images favicon.ico robots.txt webfonts)
 | 
				
			||||||
    quote do
 | 
					 | 
				
			||||||
      use Phoenix.Controller, namespace: MemexWeb
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def router do
 | 
				
			||||||
 | 
					    quote do
 | 
				
			||||||
 | 
					      use Phoenix.Router, helpers: false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      # Import common connection and controller functions to use in pipelines
 | 
				
			||||||
      import Plug.Conn
 | 
					      import Plug.Conn
 | 
				
			||||||
      import MemexWeb.Gettext
 | 
					      import Phoenix.Controller
 | 
				
			||||||
      alias MemexWeb.Endpoint
 | 
					      import Phoenix.LiveView.Router
 | 
				
			||||||
      alias MemexWeb.Router.Helpers, as: Routes
 | 
					 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def view do
 | 
					  def channel do
 | 
				
			||||||
    quote do
 | 
					    quote do
 | 
				
			||||||
      use Phoenix.View,
 | 
					      use Phoenix.Channel
 | 
				
			||||||
        root: "lib/memex_web/templates",
 | 
					    end
 | 
				
			||||||
        namespace: MemexWeb
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      # Import convenience functions from controllers
 | 
					  def controller do
 | 
				
			||||||
      import Phoenix.Controller,
 | 
					    quote do
 | 
				
			||||||
        only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1]
 | 
					      use Phoenix.Controller,
 | 
				
			||||||
 | 
					        formats: [:html, :json],
 | 
				
			||||||
 | 
					        layouts: [html: MemexWeb.Layouts]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      # Include shared imports and aliases for views
 | 
					      use Gettext, backend: MemexWeb.Gettext
 | 
				
			||||||
      unquote(view_helpers())
 | 
					
 | 
				
			||||||
 | 
					      import MemexWeb.ControllerHelpers
 | 
				
			||||||
 | 
					      import Plug.Conn
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      unquote(verified_routes())
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def live_view do
 | 
					  def live_view do
 | 
				
			||||||
    quote do
 | 
					    quote do
 | 
				
			||||||
      use Phoenix.LiveView, layout: {MemexWeb.LayoutView, :live}
 | 
					      use Phoenix.LiveView,
 | 
				
			||||||
 | 
					        layout: {MemexWeb.Layouts, :app}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      on_mount MemexWeb.InitAssigns
 | 
					      unquote(html_helpers())
 | 
				
			||||||
      unquote(view_helpers())
 | 
					 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -56,50 +64,45 @@ defmodule MemexWeb do
 | 
				
			|||||||
    quote do
 | 
					    quote do
 | 
				
			||||||
      use Phoenix.LiveComponent
 | 
					      use Phoenix.LiveComponent
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      unquote(view_helpers())
 | 
					      unquote(html_helpers())
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def component do
 | 
					  def html do
 | 
				
			||||||
    quote do
 | 
					    quote do
 | 
				
			||||||
 | 
					      # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
 | 
				
			||||||
      use Phoenix.Component
 | 
					      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
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def router do
 | 
					  defp html_helpers do
 | 
				
			||||||
    quote 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}
 | 
					      # Shortcut for generating JS commands
 | 
				
			||||||
      # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
 | 
					      alias Phoenix.LiveView.JS
 | 
				
			||||||
      import Plug.Conn
 | 
					
 | 
				
			||||||
 | 
					      # Routes generation with the ~p sigil
 | 
				
			||||||
 | 
					      unquote(verified_routes())
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def channel do
 | 
					  def verified_routes do
 | 
				
			||||||
    quote do
 | 
					    quote do
 | 
				
			||||||
      use Phoenix.Channel
 | 
					      use Phoenix.VerifiedRoutes,
 | 
				
			||||||
      # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse
 | 
					        endpoint: MemexWeb.Endpoint,
 | 
				
			||||||
      import MemexWeb.Gettext
 | 
					        router: MemexWeb.Router,
 | 
				
			||||||
    end
 | 
					        statics: MemexWeb.static_paths()
 | 
				
			||||||
  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
 | 
					 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -37,7 +37,7 @@ defmodule MemexWeb.Components.ContextsTableComponent do
 | 
				
			|||||||
         } = socket
 | 
					         } = socket
 | 
				
			||||||
       ) do
 | 
					       ) do
 | 
				
			||||||
    columns =
 | 
					    columns =
 | 
				
			||||||
      if actions == [] or current_user |> is_nil() do
 | 
					      if actions == [] or !current_user do
 | 
				
			||||||
        []
 | 
					        []
 | 
				
			||||||
      else
 | 
					      else
 | 
				
			||||||
        [%{label: gettext("actions"), key: :actions, sortable: false}]
 | 
					        [%{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()) ::
 | 
					  @spec get_value_for_key(atom(), Context.t(), additional_data :: map()) ::
 | 
				
			||||||
          any() | {any(), Rendered.t()}
 | 
					          any() | {any(), Rendered.t()}
 | 
				
			||||||
  defp get_value_for_key(:slug, %{slug: slug}, _additional_data) do
 | 
					  defp get_value_for_key(:slug, %{slug: slug} = assigns, _additional_data) do
 | 
				
			||||||
    assigns = %{slug: slug}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    slug_block = ~H"""
 | 
					    slug_block = ~H"""
 | 
				
			||||||
    <.link navigate={Routes.context_show_path(Endpoint, :show, @slug)} class="link">
 | 
					    <.link navigate={~p"/context/#{@slug}"} class="link">
 | 
				
			||||||
      <%= @slug %>
 | 
					      {@slug}
 | 
				
			||||||
    </.link>
 | 
					    </.link>
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    {slug, slug_block}
 | 
					    {slug, slug_block}
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  defp get_value_for_key(:tags, %{tags: tags}, _additional_data) do
 | 
					  defp get_value_for_key(:tags, assigns, _additional_data) do
 | 
				
			||||||
    assigns = %{tags: tags}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    ~H"""
 | 
					    ~H"""
 | 
				
			||||||
    <div class="flex flex-wrap justify-center space-x-1">
 | 
					    <div class="flex flex-wrap justify-center space-x-1">
 | 
				
			||||||
      <.link
 | 
					      <.link :for={tag <- @tags} patch={~p"/contexts/#{tag}"} class="link">
 | 
				
			||||||
        :for={tag <- @tags}
 | 
					        {tag}
 | 
				
			||||||
        patch={Routes.context_index_path(Endpoint, :search, tag)}
 | 
					 | 
				
			||||||
        class="link"
 | 
					 | 
				
			||||||
      >
 | 
					 | 
				
			||||||
        <%= tag %>
 | 
					 | 
				
			||||||
      </.link>
 | 
					      </.link>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
@@ -121,7 +113,7 @@ defmodule MemexWeb.Components.ContextsTableComponent do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    ~H"""
 | 
					    ~H"""
 | 
				
			||||||
    <div class="flex justify-center items-center space-x-4">
 | 
					    <div class="flex justify-center items-center space-x-4">
 | 
				
			||||||
      <%= render_slot(@actions, @context) %>
 | 
					      {render_slot(@actions, @context)}
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,14 +2,15 @@ defmodule MemexWeb.CoreComponents do
 | 
				
			|||||||
  @moduledoc """
 | 
					  @moduledoc """
 | 
				
			||||||
  Provides core UI components.
 | 
					  Provides core UI components.
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
 | 
					  use PhoenixHTMLHelpers
 | 
				
			||||||
  use Phoenix.Component
 | 
					  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.{Accounts, Accounts.Invite, Accounts.User}
 | 
				
			||||||
  alias Memex.Contexts.Context
 | 
					  alias Memex.Contexts.Context
 | 
				
			||||||
  alias Memex.Notes.Note
 | 
					  alias Memex.Notes.Note
 | 
				
			||||||
  alias Memex.Pipelines.Steps.Step
 | 
					  alias Memex.Pipelines.{Pipeline, Steps.Step}
 | 
				
			||||||
  alias MemexWeb.{Endpoint, HomeLive}
 | 
					 | 
				
			||||||
  alias MemexWeb.Router.Helpers, as: Routes
 | 
					 | 
				
			||||||
  alias Phoenix.HTML
 | 
					  alias Phoenix.HTML
 | 
				
			||||||
  alias Phoenix.LiveView.JS
 | 
					  alias Phoenix.LiveView.JS
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -31,13 +32,13 @@ defmodule MemexWeb.CoreComponents do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  ## Examples
 | 
					  ## Examples
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <.modal return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)}>
 | 
					      <.modal return_to={~p"/\#{<%= schema.plural %>}"}>
 | 
				
			||||||
        <.live_component
 | 
					        <.live_component
 | 
				
			||||||
          module={<%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.FormComponent}
 | 
					          module={<%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.FormComponent}
 | 
				
			||||||
          id={@<%= schema.singular %>.id || :new}
 | 
					          id={@<%= schema.singular %>.id || :new}
 | 
				
			||||||
          title={@page_title}
 | 
					          title={@page_title}
 | 
				
			||||||
          action={@live_action}
 | 
					          action={@live_action}
 | 
				
			||||||
          return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)}
 | 
					          return_to={~p"/\#{<%= schema.singular %>}"}
 | 
				
			||||||
          <%= schema.singular %>: @<%= schema.singular %>
 | 
					          <%= schema.singular %>: @<%= schema.singular %>
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
      </.modal>
 | 
					      </.modal>
 | 
				
			||||||
@@ -56,24 +57,6 @@ defmodule MemexWeb.CoreComponents do
 | 
				
			|||||||
  attr :id, :string, default: nil
 | 
					  attr :id, :string, default: nil
 | 
				
			||||||
  slot(:inner_block)
 | 
					  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)
 | 
					  def user_card(assigns)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  attr :invite, Invite, required: true
 | 
					  attr :invite, Invite, required: true
 | 
				
			||||||
@@ -88,14 +71,14 @@ defmodule MemexWeb.CoreComponents do
 | 
				
			|||||||
  attr :datetime, :any, required: true, doc: "A `DateTime` struct or nil"
 | 
					  attr :datetime, :any, required: true, doc: "A `DateTime` struct or nil"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @doc """
 | 
					  @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
 | 
					  user's local timezone
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
  def datetime(assigns)
 | 
					  def datetime(assigns)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @spec cast_datetime(NaiveDateTime.t() | nil) :: String.t()
 | 
					  @spec cast_datetime(DateTime.t() | nil) :: String.t()
 | 
				
			||||||
  defp cast_datetime(%NaiveDateTime{} = datetime) do
 | 
					  defp cast_datetime(%DateTime{} = datetime) do
 | 
				
			||||||
    datetime |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_iso8601(:extended)
 | 
					    datetime |> DateTime.to_iso8601(:extended)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  defp cast_datetime(_datetime), do: ""
 | 
					  defp cast_datetime(_datetime), do: ""
 | 
				
			||||||
@@ -131,53 +114,128 @@ defmodule MemexWeb.CoreComponents do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  def step_content(assigns)
 | 
					  def step_content(assigns)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  defp add_links_to_content(content, data_qa_prefix) do
 | 
					  attr :pipeline, Pipeline, required: true
 | 
				
			||||||
    # replace links
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # link regex from
 | 
					  def pipeline_content(assigns)
 | 
				
			||||||
    # 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
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    content =
 | 
					  defp display_links(record) do
 | 
				
			||||||
      Regex.replace(
 | 
					    record
 | 
				
			||||||
        ~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()@:%_\+.~#?&//=]*))>,
 | 
					    |> get_content()
 | 
				
			||||||
        content,
 | 
					    |> Phoenix.HTML.html_escape()
 | 
				
			||||||
        fn _whole_match, link ->
 | 
					    |> Phoenix.HTML.safe_to_string()
 | 
				
			||||||
          link =
 | 
					    |> replace_hyperlinks(record)
 | 
				
			||||||
            HTML.Link.link(
 | 
					    |> replace_triple_links(record)
 | 
				
			||||||
              link,
 | 
					    |> replace_double_links(record)
 | 
				
			||||||
              to: link,
 | 
					    |> replace_single_links(record)
 | 
				
			||||||
              class: "link inline",
 | 
					    |> HTML.raw()
 | 
				
			||||||
              target: "_blank",
 | 
					  end
 | 
				
			||||||
              rel: "noopener noreferrer"
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
            |> HTML.Safe.to_iodata()
 | 
					 | 
				
			||||||
            |> IO.iodata_to_binary()
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
          "</p>#{link}<p class=\"inline\">"
 | 
					  defp get_content(%{content: content}), do: content |> get_text()
 | 
				
			||||||
        end
 | 
					  defp get_content(%{description: description}), do: description |> get_text()
 | 
				
			||||||
      )
 | 
					  defp get_content(_fallthrough), do: nil |> get_text()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    content =
 | 
					  defp get_text(string) when is_binary(string), do: string
 | 
				
			||||||
      Regex.replace(
 | 
					  defp get_text(_fallthrough), do: ""
 | 
				
			||||||
        ~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()
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
          "</p>#{link}<p class=\"inline\">"
 | 
					  # replaces hyperlinks like https://bubbletea.dev
 | 
				
			||||||
        end
 | 
					  #
 | 
				
			||||||
      )
 | 
					  # 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
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,8 +1,11 @@
 | 
				
			|||||||
<div
 | 
					<div
 | 
				
			||||||
 | 
					  :if={@context.content}
 | 
				
			||||||
  id={"show-context-content-#{@context.id}"}
 | 
					  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"
 | 
					  class="inline-block overflow-y-auto overflow-x-hidden whitespace-pre-wrap resize-y input input-primary h-128 min-h-128"
 | 
				
			||||||
  phx-hook="MaintainAttrs"
 | 
					 | 
				
			||||||
  phx-update="ignore"
 | 
					  phx-update="ignore"
 | 
				
			||||||
  readonly
 | 
					  readonly
 | 
				
			||||||
  phx-no-format
 | 
					  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>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,3 @@
 | 
				
			|||||||
<time :if={@date} id={@id} datetime={Date.to_iso8601(@date, :extended)} phx-hook="Date">
 | 
					<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>
 | 
					</time>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,3 +1,3 @@
 | 
				
			|||||||
<time :if={@datetime} id={@id} datetime={cast_datetime(@datetime)} phx-hook="DateTime">
 | 
					<time :if={@datetime} id={@id} datetime={cast_datetime(@datetime)} phx-hook="DateTime">
 | 
				
			||||||
  <%= cast_datetime(@datetime) %>
 | 
					  {cast_datetime(@datetime)}
 | 
				
			||||||
</time>
 | 
					</time>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,48 +1,44 @@
 | 
				
			|||||||
<div class="px-8 py-4 flex flex-col justify-center items-center space-y-4
 | 
					<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">
 | 
				
			||||||
  bg-primary-900
 | 
					  <h1 class="text-xl title">
 | 
				
			||||||
  border border-gray-400 rounded-lg shadow-lg hover:shadow-md
 | 
					    {@invite.name}
 | 
				
			||||||
  transition-all duration-300 ease-in-out">
 | 
					 | 
				
			||||||
  <h1 class="title text-xl">
 | 
					 | 
				
			||||||
    <%= @invite.name %>
 | 
					 | 
				
			||||||
  </h1>
 | 
					  </h1>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <%= if @invite.disabled_at |> is_nil() do %>
 | 
					  <%= if @invite.disabled_at do %>
 | 
				
			||||||
    <h2 class="title text-md">
 | 
					    <h2 class="title text-md">
 | 
				
			||||||
      <%= if @invite.uses_left do %>
 | 
					      {gettext("invite disabled")}
 | 
				
			||||||
        <%= gettext(
 | 
					 | 
				
			||||||
          "uses left: %{uses_left_count}",
 | 
					 | 
				
			||||||
          uses_left_count: @invite.uses_left
 | 
					 | 
				
			||||||
        ) %>
 | 
					 | 
				
			||||||
      <% else %>
 | 
					 | 
				
			||||||
        <%= gettext("uses left: unlimited") %>
 | 
					 | 
				
			||||||
      <% end %>
 | 
					 | 
				
			||||||
    </h2>
 | 
					    </h2>
 | 
				
			||||||
  <% else %>
 | 
					  <% else %>
 | 
				
			||||||
    <h2 class="title text-md">
 | 
					    <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>
 | 
					    </h2>
 | 
				
			||||||
  <% end %>
 | 
					  <% end %>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <.qr_code
 | 
					  <.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}
 | 
					    filename={@invite.name}
 | 
				
			||||||
  />
 | 
					  />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <h2 :if={@use_count && @use_count != 0} class="title text-md">
 | 
					  <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>
 | 
					  </h2>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <div class="flex flex-row flex-wrap justify-center items-center">
 | 
					  <div class="flex flex-row flex-wrap justify-center items-center">
 | 
				
			||||||
    <code
 | 
					    <code
 | 
				
			||||||
      id={"code-#{@invite.id}"}
 | 
					      id={"code-#{@invite.id}"}
 | 
				
			||||||
      class="mx-2 my-1 text-xs px-4 py-2 rounded-lg text-center break-all
 | 
					      class="px-4 py-2 mx-2 my-1 text-xs text-center break-all rounded-lg text-primary-400 bg-primary-800"
 | 
				
			||||||
        text-primary-400 bg-primary-800"
 | 
					 | 
				
			||||||
      phx-no-format
 | 
					      phx-no-format
 | 
				
			||||||
    ><%= Routes.user_registration_url(Endpoint, :new, invite: @invite.token) %></code>
 | 
					    ><%= url(MemexWeb.Endpoint, ~p"/users/register?invite=#{@invite.token}") %></code>
 | 
				
			||||||
    <%= if @code_actions, do: render_slot(@code_actions) %>
 | 
					    {if @code_actions, do: render_slot(@code_actions)}
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <div :if={@inner_block} class="flex space-x-4 justify-center items-center">
 | 
					  <div :if={@inner_block} class="flex justify-center items-center space-x-4">
 | 
				
			||||||
    <%= render_slot(@inner_block) %>
 | 
					    {render_slot(@inner_block)}
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -28,7 +28,7 @@
 | 
				
			|||||||
      patch={@return_to}
 | 
					      patch={@return_to}
 | 
				
			||||||
      id="close"
 | 
					      id="close"
 | 
				
			||||||
      class="absolute top-8 right-10
 | 
					      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"
 | 
					        transition-all duration-500 ease-in-out"
 | 
				
			||||||
      phx-remove={hide_modal()}
 | 
					      phx-remove={hide_modal()}
 | 
				
			||||||
      aria-label={gettext("close modal")}
 | 
					      aria-label={gettext("close modal")}
 | 
				
			||||||
@@ -37,7 +37,7 @@
 | 
				
			|||||||
    </.link>
 | 
					    </.link>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <div class="overflow-x-hidden overflow-y-auto w-full p-8 flex flex-col space-y-4 justify-start items-stretch">
 | 
					    <div class="overflow-x-hidden overflow-y-auto w-full p-8 flex flex-col space-y-4 justify-start items-stretch">
 | 
				
			||||||
      <%= render_slot(@inner_block) %>
 | 
					      {render_slot(@inner_block)}
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,8 +1,11 @@
 | 
				
			|||||||
<div
 | 
					<div
 | 
				
			||||||
 | 
					  :if={@note.content}
 | 
				
			||||||
  id={"show-note-content-#{@note.id}"}
 | 
					  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"
 | 
					  class="inline-block overflow-y-auto overflow-x-hidden whitespace-pre-wrap resize-y input input-primary h-128 min-h-128"
 | 
				
			||||||
  phx-hook="MaintainAttrs"
 | 
					 | 
				
			||||||
  phx-update="ignore"
 | 
					  phx-update="ignore"
 | 
				
			||||||
  readonly
 | 
					  readonly
 | 
				
			||||||
  phx-no-format
 | 
					  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>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
				
			||||||
@@ -1,8 +1,8 @@
 | 
				
			|||||||
<div
 | 
					<div
 | 
				
			||||||
 | 
					  :if={@step.content}
 | 
				
			||||||
  id={"show-step-content-#{@step.id}"}
 | 
					  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"
 | 
					  class="inline-block overflow-y-auto overflow-x-hidden h-32 whitespace-pre-wrap resize-y input input-primary min-h-32"
 | 
				
			||||||
  phx-hook="MaintainAttrs"
 | 
					 | 
				
			||||||
  phx-update="ignore"
 | 
					  phx-update="ignore"
 | 
				
			||||||
  readonly
 | 
					  readonly
 | 
				
			||||||
  phx-no-format
 | 
					  phx-no-format
 | 
				
			||||||
><p class="inline"><%= add_links_to_content(@step.content, "step-context") %></p></div>
 | 
					><p class="inline"><%= display_links(@step) %></p></div>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
					 | 
				
			||||||
@@ -1,18 +1,15 @@
 | 
				
			|||||||
<nav role="navigation" class="mb-8 px-8 py-4 w-full bg-primary-900 text-primary-400">
 | 
					<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="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">
 | 
					    <div class="mb-4 sm:mb-0 sm:mr-8 flex flex-row justify-start items-center space-x-2">
 | 
				
			||||||
      <.link
 | 
					      <.link navigate={~p"/"} class="mx-2 my-1 leading-5 text-xl text-primary-400 hover:underline">
 | 
				
			||||||
        navigate={Routes.live_path(Endpoint, HomeLive)}
 | 
					        {gettext("memEx")}
 | 
				
			||||||
        class="mx-2 my-1 leading-5 text-xl text-primary-400 hover:underline"
 | 
					 | 
				
			||||||
      >
 | 
					 | 
				
			||||||
        <%= gettext("memEx") %>
 | 
					 | 
				
			||||||
      </.link>
 | 
					      </.link>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <%= if @title_content do %>
 | 
					      <%= if @title_content do %>
 | 
				
			||||||
        <span class="mx-2 my-1">
 | 
					        <span class="mx-2 my-1">
 | 
				
			||||||
          |
 | 
					          |
 | 
				
			||||||
        </span>
 | 
					        </span>
 | 
				
			||||||
        <%= @title_content %>
 | 
					        {@title_content}
 | 
				
			||||||
      <% end %>
 | 
					      <% end %>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -21,55 +18,40 @@
 | 
				
			|||||||
    <ul class="flex flex-row flex-wrap justify-center items-center
 | 
					    <ul class="flex flex-row flex-wrap justify-center items-center
 | 
				
			||||||
      text-lg text-primary-400 text-ellipsis">
 | 
					      text-lg text-primary-400 text-ellipsis">
 | 
				
			||||||
      <li class="mx-2 my-1">
 | 
					      <li class="mx-2 my-1">
 | 
				
			||||||
        <.link
 | 
					        <.link navigate={~p"/notes"} class="text-primary-400 hover:underline truncate">
 | 
				
			||||||
          navigate={Routes.note_index_path(Endpoint, :index)}
 | 
					          {gettext("notes")}
 | 
				
			||||||
          class="text-primary-400 hover:underline truncate"
 | 
					 | 
				
			||||||
        >
 | 
					 | 
				
			||||||
          <%= gettext("notes") %>
 | 
					 | 
				
			||||||
        </.link>
 | 
					        </.link>
 | 
				
			||||||
      </li>
 | 
					      </li>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <li class="mx-2 my-1">
 | 
					      <li class="mx-2 my-1">
 | 
				
			||||||
        <.link
 | 
					        <.link navigate={~p"/contexts"} class="text-primary-400 hover:underline truncate">
 | 
				
			||||||
          navigate={Routes.context_index_path(Endpoint, :index)}
 | 
					          {gettext("contexts")}
 | 
				
			||||||
          class="text-primary-400 hover:underline truncate"
 | 
					 | 
				
			||||||
        >
 | 
					 | 
				
			||||||
          <%= gettext("contexts") %>
 | 
					 | 
				
			||||||
        </.link>
 | 
					        </.link>
 | 
				
			||||||
      </li>
 | 
					      </li>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <li class="mx-2 my-1">
 | 
					      <li class="mx-2 my-1">
 | 
				
			||||||
        <.link
 | 
					        <.link navigate={~p"/pipelines"} class="text-primary-400 hover:underline truncate">
 | 
				
			||||||
          navigate={Routes.pipeline_index_path(Endpoint, :index)}
 | 
					          {gettext("pipelines")}
 | 
				
			||||||
          class="text-primary-400 hover:underline truncate"
 | 
					 | 
				
			||||||
        >
 | 
					 | 
				
			||||||
          <%= gettext("pipelines") %>
 | 
					 | 
				
			||||||
        </.link>
 | 
					        </.link>
 | 
				
			||||||
      </li>
 | 
					      </li>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <li class="mx-2 my-1 border-left border border-primary-700"></li>
 | 
					      <li class="mx-2 my-1 border-left border border-primary-700"></li>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <%= if @current_user do %>
 | 
					      <%= if @current_user do %>
 | 
				
			||||||
        <li :if={@current_user |> Accounts.is_already_admin?()} class="mx-2 my-1">
 | 
					        <li :if={@current_user |> Accounts.already_admin?()} class="mx-2 my-1">
 | 
				
			||||||
          <.link
 | 
					          <.link navigate={~p"/invites"} class="text-primary-400 hover:underline">
 | 
				
			||||||
            navigate={Routes.invite_index_path(Endpoint, :index)}
 | 
					            {gettext("invites")}
 | 
				
			||||||
            class="text-primary-400 hover:underline"
 | 
					 | 
				
			||||||
          >
 | 
					 | 
				
			||||||
            <%= gettext("invites") %>
 | 
					 | 
				
			||||||
          </.link>
 | 
					          </.link>
 | 
				
			||||||
        </li>
 | 
					        </li>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <li class="mx-2 my-1">
 | 
					        <li class="mx-2 my-1">
 | 
				
			||||||
          <.link
 | 
					          <.link navigate={~p"/users/settings"} class="text-primary-400 hover:underline truncate">
 | 
				
			||||||
            navigate={Routes.user_settings_path(Endpoint, :edit)}
 | 
					            {@current_user.email}
 | 
				
			||||||
            class="text-primary-400 hover:underline truncate"
 | 
					 | 
				
			||||||
          >
 | 
					 | 
				
			||||||
            <%= @current_user.email %>
 | 
					 | 
				
			||||||
          </.link>
 | 
					          </.link>
 | 
				
			||||||
        </li>
 | 
					        </li>
 | 
				
			||||||
        <li class="mx-2 my-1">
 | 
					        <li class="mx-2 my-1">
 | 
				
			||||||
          <.link
 | 
					          <.link
 | 
				
			||||||
            href={Routes.user_session_path(Endpoint, :delete)}
 | 
					            href={~p"/users/log_out"}
 | 
				
			||||||
            method="delete"
 | 
					            method="delete"
 | 
				
			||||||
            data-confirm={dgettext("prompts", "are you sure you want to log out?")}
 | 
					            data-confirm={dgettext("prompts", "are you sure you want to log out?")}
 | 
				
			||||||
            aria-label={gettext("log out")}
 | 
					            aria-label={gettext("log out")}
 | 
				
			||||||
@@ -84,7 +66,7 @@
 | 
				
			|||||||
          class="mx-2 my-1"
 | 
					          class="mx-2 my-1"
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <.link
 | 
					          <.link
 | 
				
			||||||
            navigate={Routes.live_dashboard_path(Endpoint, :home)}
 | 
					            navigate={~p"/dashboard"}
 | 
				
			||||||
            class="text-primary-400 hover:underline"
 | 
					            class="text-primary-400 hover:underline"
 | 
				
			||||||
            aria-label={gettext("live dashboard")}
 | 
					            aria-label={gettext("live dashboard")}
 | 
				
			||||||
          >
 | 
					          >
 | 
				
			||||||
@@ -93,20 +75,14 @@
 | 
				
			|||||||
        </li>
 | 
					        </li>
 | 
				
			||||||
      <% else %>
 | 
					      <% else %>
 | 
				
			||||||
        <li :if={Accounts.allow_registration?()} class="mx-2 my-1">
 | 
					        <li :if={Accounts.allow_registration?()} class="mx-2 my-1">
 | 
				
			||||||
          <.link
 | 
					          <.link href={~p"/users/register"} class="text-primary-400 hover:underline truncate">
 | 
				
			||||||
            href={Routes.user_registration_path(Endpoint, :new)}
 | 
					            {dgettext("actions", "register")}
 | 
				
			||||||
            class="text-primary-400 hover:underline truncate"
 | 
					 | 
				
			||||||
          >
 | 
					 | 
				
			||||||
            <%= dgettext("actions", "register") %>
 | 
					 | 
				
			||||||
          </.link>
 | 
					          </.link>
 | 
				
			||||||
        </li>
 | 
					        </li>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <li class="mx-2 my-1">
 | 
					        <li class="mx-2 my-1">
 | 
				
			||||||
          <.link
 | 
					          <.link href={~p"/users/log_in"} class="text-primary-400 hover:underline truncate">
 | 
				
			||||||
            href={Routes.user_session_path(Endpoint, :new)}
 | 
					            {dgettext("actions", "log in")}
 | 
				
			||||||
            class="text-primary-400 hover:underline truncate"
 | 
					 | 
				
			||||||
          >
 | 
					 | 
				
			||||||
            <%= dgettext("actions", "log in") %>
 | 
					 | 
				
			||||||
          </.link>
 | 
					          </.link>
 | 
				
			||||||
        </li>
 | 
					        </li>
 | 
				
			||||||
      <% end %>
 | 
					      <% end %>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,36 +2,36 @@
 | 
				
			|||||||
  id={"user-#{@user.id}"}
 | 
					  id={"user-#{@user.id}"}
 | 
				
			||||||
  class="px-8 py-4 flex flex-col justify-center items-center text-center
 | 
					  class="px-8 py-4 flex flex-col justify-center items-center text-center
 | 
				
			||||||
    bg-primary-900
 | 
					    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"
 | 
					    transition-all duration-300 ease-in-out"
 | 
				
			||||||
>
 | 
					>
 | 
				
			||||||
  <h1 class="px-4 py-2 rounded-lg title text-xl break-all">
 | 
					  <h1 class="px-4 py-2 rounded-lg title text-xl break-all">
 | 
				
			||||||
    <%= @user.email %>
 | 
					    {@user.email}
 | 
				
			||||||
  </h1>
 | 
					  </h1>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <h3 class="px-4 py-2 rounded-lg title text-lg">
 | 
					  <h3 class="px-4 py-2 rounded-lg title text-lg">
 | 
				
			||||||
    <p>
 | 
					    <p>
 | 
				
			||||||
      <%= if @user.confirmed_at do %>
 | 
					      <%= if @user.confirmed_at do %>
 | 
				
			||||||
        <%= gettext(
 | 
					        {gettext(
 | 
				
			||||||
          "user confirmed on%{confirmed_datetime}",
 | 
					          "user confirmed on%{confirmed_datetime}",
 | 
				
			||||||
          confirmed_datetime: ""
 | 
					          confirmed_datetime: ""
 | 
				
			||||||
        ) %>
 | 
					        )}
 | 
				
			||||||
        <.datetime id={"#{@user.id}-confirmed-at"} datetime={@user.confirmed_at} />
 | 
					        <.datetime id={"#{@user.id}-confirmed-at"} datetime={@user.confirmed_at} />
 | 
				
			||||||
      <% else %>
 | 
					      <% else %>
 | 
				
			||||||
        <%= gettext("email unconfirmed") %>
 | 
					        {gettext("email unconfirmed")}
 | 
				
			||||||
      <% end %>
 | 
					      <% end %>
 | 
				
			||||||
    </p>
 | 
					    </p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <p>
 | 
					    <p>
 | 
				
			||||||
      <%= gettext(
 | 
					      {gettext(
 | 
				
			||||||
        "user registered on%{registered_datetime}",
 | 
					        "user registered on%{registered_datetime}",
 | 
				
			||||||
        registered_datetime: ""
 | 
					        registered_datetime: ""
 | 
				
			||||||
      ) %>
 | 
					      )}
 | 
				
			||||||
      <.datetime id={"#{@user.id}-inserted-at"} datetime={@user.inserted_at} />
 | 
					      <.datetime id={"#{@user.id}-inserted-at"} datetime={@user.inserted_at} />
 | 
				
			||||||
    </p>
 | 
					    </p>
 | 
				
			||||||
  </h3>
 | 
					  </h3>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <div :if={@inner_block} class="px-4 py-2 flex space-x-4 justify-center items-center">
 | 
					  <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>
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										17
									
								
								lib/memex_web/components/layouts.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								lib/memex_web/components/layouts.ex
									
									
									
									
									
										Normal 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
 | 
				
			||||||
@@ -1,38 +1,38 @@
 | 
				
			|||||||
<main class="pb-8 min-w-full">
 | 
					<main role="main" class="pb-8 min-w-full">
 | 
				
			||||||
  <header>
 | 
					  <header>
 | 
				
			||||||
    <.topbar current_user={assigns[:current_user]} />
 | 
					    <.topbar current_user={assigns[:current_user]} />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <div class="mx-8 my-2 flex flex-col space-y-4 text-center">
 | 
					    <div class="mx-8 my-2 flex flex-col space-y-4 text-center">
 | 
				
			||||||
      <p
 | 
					      <p
 | 
				
			||||||
        :if={@flash && @flash |> Map.has_key?("info")}
 | 
					        :if={@flash && @flash |> Map.has_key?("info")}
 | 
				
			||||||
        class="alert alert-info"
 | 
					        class="alert alert-info cursor-pointer"
 | 
				
			||||||
        role="alert"
 | 
					        role="alert"
 | 
				
			||||||
        phx-click="lv:clear-flash"
 | 
					        phx-click="lv:clear-flash"
 | 
				
			||||||
        phx-value-key="info"
 | 
					        phx-value-key="info"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <%= live_flash(@flash, "info") %>
 | 
					        {Phoenix.Flash.get(@flash, :info)}
 | 
				
			||||||
      </p>
 | 
					      </p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <p
 | 
					      <p
 | 
				
			||||||
        :if={@flash && @flash |> Map.has_key?("error")}
 | 
					        :if={@flash && @flash |> Map.has_key?("error")}
 | 
				
			||||||
        class="alert alert-danger"
 | 
					        class="alert alert-danger cursor-pointer"
 | 
				
			||||||
        role="alert"
 | 
					        role="alert"
 | 
				
			||||||
        phx-click="lv:clear-flash"
 | 
					        phx-click="lv:clear-flash"
 | 
				
			||||||
        phx-value-key="error"
 | 
					        phx-value-key="error"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <%= live_flash(@flash, "error") %>
 | 
					        {Phoenix.Flash.get(@flash, :error)}
 | 
				
			||||||
      </p>
 | 
					      </p>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </header>
 | 
					  </header>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <div class="mx-4 sm:mx-8 md:mx-16">
 | 
					  <div class="mx-4 sm:mx-8 md:mx-16">
 | 
				
			||||||
    <%= @inner_content %>
 | 
					    {@inner_content}
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
</main>
 | 
					</main>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div
 | 
					<div
 | 
				
			||||||
  id="disconnect"
 | 
					  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
 | 
					  border border-primary-400 shadow-lg rounded-lg bg-primary-900 text-primary-400
 | 
				
			||||||
  flex justify-center items-center space-x-4
 | 
					  flex justify-center items-center space-x-4
 | 
				
			||||||
  transition-opacity ease-in-out duration-500 delay-[2000ms]"
 | 
					  transition-opacity ease-in-out duration-500 delay-[2000ms]"
 | 
				
			||||||
@@ -40,6 +40,6 @@
 | 
				
			|||||||
  <i class="fas fa-fade text-md fa-satellite-dish"></i>
 | 
					  <i class="fas fa-fade text-md fa-satellite-dish"></i>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <h1 class="title text-md">
 | 
					  <h1 class="title text-md">
 | 
				
			||||||
    <%= gettext("Reconnecting...") %>
 | 
					    {gettext("Reconnecting...")}
 | 
				
			||||||
  </h1>
 | 
					  </h1>
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
@@ -1,19 +1,16 @@
 | 
				
			|||||||
<html>
 | 
					<html>
 | 
				
			||||||
  <head>
 | 
					  <head>
 | 
				
			||||||
    <title>
 | 
					    <title>
 | 
				
			||||||
      <%= @email.subject %>
 | 
					      {@email.subject}
 | 
				
			||||||
    </title>
 | 
					    </title>
 | 
				
			||||||
  </head>
 | 
					  </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;">
 | 
					  <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;" />
 | 
					    <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)}>
 | 
					    <a style="color: rgb(161, 161, 170);" href={~p"/"}>
 | 
				
			||||||
      <%= dgettext(
 | 
					      {dgettext("emails", "this email was sent from memEx")}
 | 
				
			||||||
        "emails",
 | 
					 | 
				
			||||||
        "This email was sent from memEx"
 | 
					 | 
				
			||||||
      ) %>
 | 
					 | 
				
			||||||
    </a>
 | 
					    </a>
 | 
				
			||||||
  </body>
 | 
					  </body>
 | 
				
			||||||
</html>
 | 
					</html>
 | 
				
			||||||
							
								
								
									
										9
									
								
								lib/memex_web/components/layouts/email_text.txt.eex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								lib/memex_web/components/layouts/email_text.txt.eex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
				
			|||||||
 | 
					<%= @email.subject %>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					====================
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<%= @inner_content %>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					=====================
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<%= dgettext("emails", "this email was sent from memEx at %{url}", url: ~p"/") %>
 | 
				
			||||||
							
								
								
									
										1
									
								
								lib/memex_web/components/layouts/empty.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								lib/memex_web/components/layouts/empty.html.heex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					{@inner_content}
 | 
				
			||||||
							
								
								
									
										19
									
								
								lib/memex_web/components/layouts/root.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								lib/memex_web/components/layouts/root.html.heex
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
@@ -37,7 +37,7 @@ defmodule MemexWeb.Components.NotesTableComponent do
 | 
				
			|||||||
         } = socket
 | 
					         } = socket
 | 
				
			||||||
       ) do
 | 
					       ) do
 | 
				
			||||||
    columns =
 | 
					    columns =
 | 
				
			||||||
      if actions == [] or current_user |> is_nil() do
 | 
					      if actions == [] or !current_user do
 | 
				
			||||||
        []
 | 
					        []
 | 
				
			||||||
      else
 | 
					      else
 | 
				
			||||||
        [%{label: gettext("actions"), key: :actions, sortable: false}]
 | 
					        [%{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()) ::
 | 
					  @spec get_value_for_key(atom(), Note.t(), additional_data :: map()) ::
 | 
				
			||||||
          any() | {any(), Rendered.t()}
 | 
					          any() | {any(), Rendered.t()}
 | 
				
			||||||
  defp get_value_for_key(:slug, %{slug: slug}, _additional_data) do
 | 
					  defp get_value_for_key(:slug, %{slug: slug} = assigns, _additional_data) do
 | 
				
			||||||
    assigns = %{slug: slug}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    slug_block = ~H"""
 | 
					    slug_block = ~H"""
 | 
				
			||||||
    <.link navigate={Routes.note_show_path(Endpoint, :show, @slug)} class="link">
 | 
					    <.link navigate={~p"/note/#{@slug}"} class="link">
 | 
				
			||||||
      <%= @slug %>
 | 
					      {@slug}
 | 
				
			||||||
    </.link>
 | 
					    </.link>
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    {slug, slug_block}
 | 
					    {slug, slug_block}
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  defp get_value_for_key(:tags, %{tags: tags}, _additional_data) do
 | 
					  defp get_value_for_key(:tags, assigns, _additional_data) do
 | 
				
			||||||
    assigns = %{tags: tags}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    ~H"""
 | 
					    ~H"""
 | 
				
			||||||
    <div class="flex flex-wrap justify-center space-x-1">
 | 
					    <div class="flex flex-wrap justify-center space-x-1">
 | 
				
			||||||
      <.link :for={tag <- @tags} patch={Routes.note_index_path(Endpoint, :search, tag)} class="link">
 | 
					      <.link :for={tag <- @tags} patch={~p"/notes/#{tag}"} class="link">
 | 
				
			||||||
        <%= tag %>
 | 
					        {tag}
 | 
				
			||||||
      </.link>
 | 
					      </.link>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
@@ -117,7 +113,7 @@ defmodule MemexWeb.Components.NotesTableComponent do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    ~H"""
 | 
					    ~H"""
 | 
				
			||||||
    <div class="flex justify-center items-center space-x-4">
 | 
					    <div class="flex justify-center items-center space-x-4">
 | 
				
			||||||
      <%= render_slot(@actions, @note) %>
 | 
					      {render_slot(@actions, @note)}
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -37,7 +37,7 @@ defmodule MemexWeb.Components.PipelinesTableComponent do
 | 
				
			|||||||
         } = socket
 | 
					         } = socket
 | 
				
			||||||
       ) do
 | 
					       ) do
 | 
				
			||||||
    columns =
 | 
					    columns =
 | 
				
			||||||
      if actions == [] or current_user |> is_nil() do
 | 
					      if actions == [] or !current_user do
 | 
				
			||||||
        []
 | 
					        []
 | 
				
			||||||
      else
 | 
					      else
 | 
				
			||||||
        [%{label: gettext("actions"), key: :actions, sortable: false}]
 | 
					        [%{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()) ::
 | 
					  @spec get_value_for_key(atom(), Pipeline.t(), additional_data :: map()) ::
 | 
				
			||||||
          any() | {any(), Rendered.t()}
 | 
					          any() | {any(), Rendered.t()}
 | 
				
			||||||
  defp get_value_for_key(:slug, %{slug: slug}, _additional_data) do
 | 
					  defp get_value_for_key(:slug, %{slug: slug} = assigns, _additional_data) do
 | 
				
			||||||
    assigns = %{slug: slug}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    slug_block = ~H"""
 | 
					    slug_block = ~H"""
 | 
				
			||||||
    <.link navigate={Routes.pipeline_show_path(Endpoint, :show, @slug)} class="link">
 | 
					    <.link navigate={~p"/pipeline/#{@slug}"} class="link">
 | 
				
			||||||
      <%= @slug %>
 | 
					      {@slug}
 | 
				
			||||||
    </.link>
 | 
					    </.link>
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    {slug, slug_block}
 | 
					    {slug, slug_block}
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  defp get_value_for_key(:description, %{description: description}, _additional_data) do
 | 
					  defp get_value_for_key(:description, %{description: description} = assigns, _additional_data) do
 | 
				
			||||||
    assigns = %{description: description}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    description_block = ~H"""
 | 
					    description_block = ~H"""
 | 
				
			||||||
    <div class="truncate max-w-sm">
 | 
					    <div class="max-w-sm truncate">
 | 
				
			||||||
      <%= @description %>
 | 
					      {@description}
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    {description, description_block}
 | 
					    {description, description_block}
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  defp get_value_for_key(:tags, %{tags: tags}, _additional_data) do
 | 
					  defp get_value_for_key(:tags, assigns, _additional_data) do
 | 
				
			||||||
    assigns = %{tags: tags}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    ~H"""
 | 
					    ~H"""
 | 
				
			||||||
    <div class="flex flex-wrap justify-center space-x-1">
 | 
					    <div class="flex flex-wrap justify-center space-x-1">
 | 
				
			||||||
      <.link
 | 
					      <.link :for={tag <- @tags} patch={~p"/pipelines/#{tag}"} class="link">
 | 
				
			||||||
        :for={tag <- @tags}
 | 
					        {tag}
 | 
				
			||||||
        patch={Routes.pipeline_index_path(Endpoint, :search, tag)}
 | 
					 | 
				
			||||||
        class="link"
 | 
					 | 
				
			||||||
      >
 | 
					 | 
				
			||||||
        <%= tag %>
 | 
					 | 
				
			||||||
      </.link>
 | 
					      </.link>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
@@ -134,7 +124,7 @@ defmodule MemexWeb.Components.PipelinesTableComponent do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    ~H"""
 | 
					    ~H"""
 | 
				
			||||||
    <div class="flex justify-center items-center space-x-4">
 | 
					    <div class="flex justify-center items-center space-x-4">
 | 
				
			||||||
      <%= render_slot(@actions, @pipeline) %>
 | 
					      {render_slot(@actions, @pipeline)}
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -135,4 +135,25 @@ defmodule MemexWeb.Components.TableComponent do
 | 
				
			|||||||
      sort_mode
 | 
					      sort_mode
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
  end
 | 
					  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
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -12,7 +12,7 @@
 | 
				
			|||||||
                phx-target={@myself}
 | 
					                phx-target={@myself}
 | 
				
			||||||
              >
 | 
					              >
 | 
				
			||||||
                <i class="w-0 float-right fas fa-sm fa-chevron-up opacity-0"></i>
 | 
					                <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 %>
 | 
					                <%= if @last_sort_key == key do %>
 | 
				
			||||||
                  <%= case @sort_mode do %>
 | 
					                  <%= case @sort_mode do %>
 | 
				
			||||||
                    <% :asc -> %>
 | 
					                    <% :asc -> %>
 | 
				
			||||||
@@ -27,7 +27,7 @@
 | 
				
			|||||||
            </th>
 | 
					            </th>
 | 
				
			||||||
          <% else %>
 | 
					          <% else %>
 | 
				
			||||||
            <th class={["p-2 cursor-not-allowed", column[:class]]}>
 | 
					            <th class={["p-2 cursor-not-allowed", column[:class]]}>
 | 
				
			||||||
              <%= label %>
 | 
					              {label}
 | 
				
			||||||
            </th>
 | 
					            </th>
 | 
				
			||||||
          <% end %>
 | 
					          <% end %>
 | 
				
			||||||
        <% end %>
 | 
					        <% end %>
 | 
				
			||||||
@@ -41,9 +41,9 @@
 | 
				
			|||||||
        <td :for={%{key: key} = value <- @columns} class={["p-2", value[:class]]}>
 | 
					        <td :for={%{key: key} = value <- @columns} class={["p-2", value[:class]]}>
 | 
				
			||||||
          <%= case values |> Map.get(key) do %>
 | 
					          <%= case values |> Map.get(key) do %>
 | 
				
			||||||
            <% {_custom_sort_value, value} -> %>
 | 
					            <% {_custom_sort_value, value} -> %>
 | 
				
			||||||
              <%= value %>
 | 
					              {value}
 | 
				
			||||||
            <% value -> %>
 | 
					            <% value -> %>
 | 
				
			||||||
              <%= value %>
 | 
					              {value}
 | 
				
			||||||
          <% end %>
 | 
					          <% end %>
 | 
				
			||||||
        </td>
 | 
					        </td>
 | 
				
			||||||
      </tr>
 | 
					      </tr>
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										13
									
								
								lib/memex_web/controller_helpers.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								lib/memex_web/controller_helpers.ex
									
									
									
									
									
										Normal 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
 | 
				
			||||||
@@ -6,7 +6,8 @@ defmodule MemexWeb.EmailController do
 | 
				
			|||||||
  use MemexWeb, :controller
 | 
					  use MemexWeb, :controller
 | 
				
			||||||
  alias Memex.Accounts.User
 | 
					  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 %{
 | 
					  @sample_assigns %{
 | 
				
			||||||
    email: %{subject: "Example subject"},
 | 
					    email: %{subject: "Example subject"},
 | 
				
			||||||
@@ -18,6 +19,6 @@ defmodule MemexWeb.EmailController do
 | 
				
			|||||||
  Debug route used to preview emails
 | 
					  Debug route used to preview emails
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
  def preview(conn, %{"id" => template}) do
 | 
					  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
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										10
									
								
								lib/memex_web/controllers/email_html.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								lib/memex_web/controllers/email_html.ex
									
									
									
									
									
										Normal 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
 | 
				
			||||||
							
								
								
									
										23
									
								
								lib/memex_web/controllers/email_html/confirm_email.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								lib/memex_web/controllers/email_html/confirm_email.html.heex
									
									
									
									
									
										Normal 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>
 | 
				
			||||||
@@ -9,4 +9,4 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<%= dgettext("emails",
 | 
					<%= dgettext("emails",
 | 
				
			||||||
  "If you didn't create an account at %{url}, please ignore this.",
 | 
					  "If you didn't create an account at %{url}, please ignore this.",
 | 
				
			||||||
  url: Routes.live_url(Endpoint, HomeLive)) %>
 | 
					  url: ~p"/") %>
 | 
				
			||||||
@@ -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>
 | 
				
			||||||
@@ -7,4 +7,4 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<%= dgettext("emails",
 | 
					<%= dgettext("emails",
 | 
				
			||||||
  "If you didn't request this change from %{url}, please ignore this.",
 | 
					  "If you didn't request this change from %{url}, please ignore this.",
 | 
				
			||||||
  url: Routes.live_url(Endpoint, HomeLive)) %>
 | 
					  url: ~p"/") %>
 | 
				
			||||||
@@ -1,20 +1,20 @@
 | 
				
			|||||||
<div style="display: flex; flex-direction: column; justify-content: center; align-items: center;">
 | 
					<div style="display: flex; flex-direction: column; justify-content: center; align-items: center;">
 | 
				
			||||||
  <span style="margin-bottom: 0.5em; font-size: 1.5em;">
 | 
					  <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>
 | 
					  </span>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <br />
 | 
					  <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 />
 | 
					  <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 />
 | 
					  <br />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <%= dgettext(
 | 
					  {dgettext(
 | 
				
			||||||
    "emails",
 | 
					    "emails",
 | 
				
			||||||
    "If you didn't request this change from memEx, please ignore this."
 | 
					    "If you didn't request this change from memEx, please ignore this."
 | 
				
			||||||
  ) %>
 | 
					  )}
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
@@ -7,4 +7,4 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
<%= dgettext("emails",
 | 
					<%= dgettext("emails",
 | 
				
			||||||
  "If you didn't request this change from %{url}, please ignore this.",
 | 
					  "If you didn't request this change from %{url}, please ignore this.",
 | 
				
			||||||
  url: Routes.live_url(Endpoint, HomeLive)) %>
 | 
					  url: ~p"/") %>
 | 
				
			||||||
@@ -1,15 +1,16 @@
 | 
				
			|||||||
defmodule MemexWeb.ErrorView do
 | 
					defmodule MemexWeb.ErrorHTML do
 | 
				
			||||||
  use MemexWeb, :view
 | 
					  use MemexWeb, :html
 | 
				
			||||||
  alias MemexWeb.HomeLive
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def template_not_found(error_path, _assigns) do
 | 
					  embed_templates "error_html/*"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def render(template, _assigns) do
 | 
				
			||||||
    error_string =
 | 
					    error_string =
 | 
				
			||||||
      case error_path do
 | 
					      case template do
 | 
				
			||||||
        "404.html" -> dgettext("errors", "not found")
 | 
					        "404.html" -> dgettext("errors", "not found")
 | 
				
			||||||
        "401.html" -> dgettext("errors", "unauthorized")
 | 
					        "401.html" -> dgettext("errors", "unauthorized")
 | 
				
			||||||
        _other_path -> dgettext("errors", "internal server error")
 | 
					        _other_path -> dgettext("errors", "internal server error")
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    render("error.html", %{error_string: error_string})
 | 
					    error(%{error_string: error_string})
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
@@ -5,7 +5,7 @@
 | 
				
			|||||||
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
 | 
					    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
 | 
				
			||||||
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 | 
					    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 | 
				
			||||||
    <title>
 | 
					    <title>
 | 
				
			||||||
      <%= dgettext("errors", "Error") %> | <%= gettext("memEx") %>
 | 
					      {dgettext("errors", "Error")} | {gettext("memEx")}
 | 
				
			||||||
    </title>
 | 
					    </title>
 | 
				
			||||||
    <link rel="stylesheet" href="/css/app.css" />
 | 
					    <link rel="stylesheet" href="/css/app.css" />
 | 
				
			||||||
    <script defer type="text/javascript" src="/js/app.js">
 | 
					    <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="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">
 | 
					      <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">
 | 
					        <h1 class="title text-primary-400 text-3xl">
 | 
				
			||||||
          <%= @error_string %>
 | 
					          {@error_string}
 | 
				
			||||||
        </h1>
 | 
					        </h1>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <hr class="w-full hr" />
 | 
					        <hr class="w-full hr" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <.link
 | 
					        <.link href={~p"/"} class="link title text-primary-400 text-lg">
 | 
				
			||||||
          href={Routes.live_path(Endpoint, HomeLive)}
 | 
					          {dgettext("errors", "go back home")}
 | 
				
			||||||
          class="link title text-primary-400 text-lg"
 | 
					 | 
				
			||||||
        >
 | 
					 | 
				
			||||||
          <%= dgettext("errors", "go back home") %>
 | 
					 | 
				
			||||||
        </.link>
 | 
					        </.link>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
							
								
								
									
										14
									
								
								lib/memex_web/controllers/error_json.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								lib/memex_web/controllers/error_json.ex
									
									
									
									
									
										Normal 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
 | 
				
			||||||
@@ -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
 | 
					 | 
				
			||||||
							
								
								
									
										5
									
								
								lib/memex_web/controllers/home_html.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								lib/memex_web/controllers/home_html.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,5 @@
 | 
				
			|||||||
 | 
					defmodule MemexWeb.HomeHTML do
 | 
				
			||||||
 | 
					  use MemexWeb, :html
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  embed_templates "home_html/*"
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
@@ -3,12 +3,11 @@ defmodule MemexWeb.UserAuth do
 | 
				
			|||||||
  Functions for user session and authentication
 | 
					  Functions for user session and authentication
 | 
				
			||||||
  """
 | 
					  """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  use MemexWeb, :verified_routes
 | 
				
			||||||
 | 
					  use Gettext, backend: MemexWeb.Gettext
 | 
				
			||||||
  import Plug.Conn
 | 
					  import Plug.Conn
 | 
				
			||||||
  import Phoenix.Controller
 | 
					  import Phoenix.Controller
 | 
				
			||||||
  import MemexWeb.Gettext
 | 
					 | 
				
			||||||
  alias Memex.{Accounts, Accounts.User}
 | 
					  alias Memex.{Accounts, Accounts.User}
 | 
				
			||||||
  alias MemexWeb.HomeLive
 | 
					 | 
				
			||||||
  alias MemexWeb.Router.Helpers, as: Routes
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  # Make the remember me cookie valid for 60 days.
 | 
					  # Make the remember me cookie valid for 60 days.
 | 
				
			||||||
  # If you want bump or reduce this value, also change
 | 
					  # 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.")
 | 
					      dgettext("errors", "You must confirm your account and log in to access this page.")
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    |> maybe_store_return_to()
 | 
					    |> maybe_store_return_to()
 | 
				
			||||||
    |> redirect(to: Routes.user_session_path(conn, :new))
 | 
					    |> redirect(to: ~p"/users/log_in")
 | 
				
			||||||
    |> halt()
 | 
					    |> halt()
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -49,8 +48,7 @@ defmodule MemexWeb.UserAuth do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    conn
 | 
					    conn
 | 
				
			||||||
    |> renew_session()
 | 
					    |> renew_session()
 | 
				
			||||||
    |> put_session(:user_token, token)
 | 
					    |> put_token_in_session(token)
 | 
				
			||||||
    |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
 | 
					 | 
				
			||||||
    |> maybe_write_remember_me_cookie(token, params)
 | 
					    |> maybe_write_remember_me_cookie(token, params)
 | 
				
			||||||
    |> redirect(to: user_return_to || signed_in_path(conn))
 | 
					    |> redirect(to: user_return_to || signed_in_path(conn))
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
@@ -96,7 +94,7 @@ defmodule MemexWeb.UserAuth do
 | 
				
			|||||||
  """
 | 
					  """
 | 
				
			||||||
  def log_out_user(conn) do
 | 
					  def log_out_user(conn) do
 | 
				
			||||||
    user_token = get_session(conn, :user_token)
 | 
					    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
 | 
					    if live_socket_id = get_session(conn, :live_socket_id) do
 | 
				
			||||||
      MemexWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
 | 
					      MemexWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
 | 
				
			||||||
@@ -105,7 +103,7 @@ defmodule MemexWeb.UserAuth do
 | 
				
			|||||||
    conn
 | 
					    conn
 | 
				
			||||||
    |> renew_session()
 | 
					    |> renew_session()
 | 
				
			||||||
    |> delete_resp_cookie(@remember_me_cookie)
 | 
					    |> delete_resp_cookie(@remember_me_cookie)
 | 
				
			||||||
    |> redirect(to: "/")
 | 
					    |> redirect(to: ~p"/")
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @doc """
 | 
					  @doc """
 | 
				
			||||||
@@ -119,19 +117,110 @@ defmodule MemexWeb.UserAuth do
 | 
				
			|||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  defp ensure_user_token(conn) do
 | 
					  defp ensure_user_token(conn) do
 | 
				
			||||||
    if user_token = get_session(conn, :user_token) do
 | 
					    if token = get_session(conn, :user_token) do
 | 
				
			||||||
      {user_token, conn}
 | 
					      {token, conn}
 | 
				
			||||||
    else
 | 
					    else
 | 
				
			||||||
      conn = fetch_cookies(conn, signed: [@remember_me_cookie])
 | 
					      conn = fetch_cookies(conn, signed: [@remember_me_cookie])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if user_token = conn.cookies[@remember_me_cookie] do
 | 
					      if token = conn.cookies[@remember_me_cookie] do
 | 
				
			||||||
        {user_token, put_session(conn, :user_token, user_token)}
 | 
					        {token, put_token_in_session(conn, token)}
 | 
				
			||||||
      else
 | 
					      else
 | 
				
			||||||
        {nil, conn}
 | 
					        {nil, conn}
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
    end
 | 
					    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 """
 | 
					  @doc """
 | 
				
			||||||
  Used for routes that require the user to not be authenticated.
 | 
					  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.")
 | 
					        dgettext("errors", "You must confirm your account and log in to access this page.")
 | 
				
			||||||
      )
 | 
					      )
 | 
				
			||||||
      |> maybe_store_return_to()
 | 
					      |> maybe_store_return_to()
 | 
				
			||||||
      |> redirect(to: Routes.user_session_path(conn, :new))
 | 
					      |> redirect(to: ~p"/users/log_in")
 | 
				
			||||||
      |> halt()
 | 
					      |> halt()
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
@@ -176,16 +265,34 @@ defmodule MemexWeb.UserAuth do
 | 
				
			|||||||
      conn
 | 
					      conn
 | 
				
			||||||
      |> put_flash(:error, dgettext("errors", "You are not authorized to view this page."))
 | 
					      |> put_flash(:error, dgettext("errors", "You are not authorized to view this page."))
 | 
				
			||||||
      |> maybe_store_return_to()
 | 
					      |> maybe_store_return_to()
 | 
				
			||||||
      |> redirect(to: Routes.live_path(conn, HomeLive))
 | 
					      |> redirect(to: ~p"/")
 | 
				
			||||||
      |> halt()
 | 
					      |> halt()
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  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
 | 
					  defp maybe_store_return_to(%{method: "GET"} = conn) do
 | 
				
			||||||
    put_session(conn, :user_return_to, current_path(conn))
 | 
					    put_session(conn, :user_return_to, current_path(conn))
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  defp maybe_store_return_to(conn), do: conn
 | 
					  defp maybe_store_return_to(conn), do: conn
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  defp signed_in_path(_conn), do: "/"
 | 
					  defp signed_in_path(_conn), do: ~p"/"
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,18 +1,17 @@
 | 
				
			|||||||
defmodule MemexWeb.UserConfirmationController do
 | 
					defmodule MemexWeb.UserConfirmationController do
 | 
				
			||||||
  use MemexWeb, :controller
 | 
					  use MemexWeb, :controller
 | 
				
			||||||
 | 
					  use Gettext, backend: MemexWeb.Gettext
 | 
				
			||||||
  import MemexWeb.Gettext
 | 
					 | 
				
			||||||
  alias Memex.Accounts
 | 
					  alias Memex.Accounts
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def new(conn, _params) do
 | 
					  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
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def create(conn, %{"user" => %{"email" => email}}) do
 | 
					  def create(conn, %{"user" => %{"email" => email}}) do
 | 
				
			||||||
    if user = Accounts.get_user_by_email(email) do
 | 
					    if user = Accounts.get_user_by_email(email) do
 | 
				
			||||||
      Accounts.deliver_user_confirmation_instructions(
 | 
					      Accounts.deliver_user_confirmation_instructions(
 | 
				
			||||||
        user,
 | 
					        user,
 | 
				
			||||||
        &Routes.user_confirmation_url(conn, :confirm, &1)
 | 
					        fn token -> url(MemexWeb.Endpoint, ~p"/users/confirm/#{token}") end
 | 
				
			||||||
      )
 | 
					      )
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -22,11 +21,10 @@ defmodule MemexWeb.UserConfirmationController do
 | 
				
			|||||||
      :info,
 | 
					      :info,
 | 
				
			||||||
      dgettext(
 | 
					      dgettext(
 | 
				
			||||||
        "prompts",
 | 
					        "prompts",
 | 
				
			||||||
        "If your email is in our system and it has not been confirmed yet, " <>
 | 
					        "if your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly."
 | 
				
			||||||
          "you will receive an email with instructions shortly."
 | 
					 | 
				
			||||||
      )
 | 
					      )
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    |> redirect(to: "/")
 | 
					    |> redirect(to: ~p"/")
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  # Do not log in the user after confirmation to avoid a
 | 
					  # Do not log in the user after confirmation to avoid a
 | 
				
			||||||
@@ -36,7 +34,7 @@ defmodule MemexWeb.UserConfirmationController do
 | 
				
			|||||||
      {:ok, %{email: email}} ->
 | 
					      {:ok, %{email: email}} ->
 | 
				
			||||||
        conn
 | 
					        conn
 | 
				
			||||||
        |> put_flash(:info, dgettext("prompts", "%{email} confirmed successfully.", email: email))
 | 
					        |> put_flash(:info, dgettext("prompts", "%{email} confirmed successfully.", email: email))
 | 
				
			||||||
        |> redirect(to: "/")
 | 
					        |> redirect(to: ~p"/")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      :error ->
 | 
					      :error ->
 | 
				
			||||||
        # If there is a current user and the account was already confirmed,
 | 
					        # 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
 | 
					        # by some automation or by the user themselves, so we redirect without
 | 
				
			||||||
        # a warning message.
 | 
					        # a warning message.
 | 
				
			||||||
        case conn.assigns do
 | 
					        case conn.assigns do
 | 
				
			||||||
          %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
 | 
					          %{current_user: %{confirmed_at: %{}}} ->
 | 
				
			||||||
            redirect(conn, to: "/")
 | 
					            redirect(conn, to: ~p"/")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          %{} ->
 | 
					          %{} ->
 | 
				
			||||||
            conn
 | 
					            conn
 | 
				
			||||||
            |> put_flash(
 | 
					            |> put_flash(
 | 
				
			||||||
              :error,
 | 
					              :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
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										6
									
								
								lib/memex_web/controllers/user_confirmation_html.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								lib/memex_web/controllers/user_confirmation_html.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
				
			|||||||
 | 
					defmodule MemexWeb.UserConfirmationHTML do
 | 
				
			||||||
 | 
					  use MemexWeb, :html
 | 
				
			||||||
 | 
					  alias Memex.Accounts
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  embed_templates "user_confirmation_html/*"
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
@@ -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>
 | 
				
			||||||
@@ -1,16 +1,16 @@
 | 
				
			|||||||
defmodule MemexWeb.UserRegistrationController do
 | 
					defmodule MemexWeb.UserRegistrationController do
 | 
				
			||||||
  use MemexWeb, :controller
 | 
					  use MemexWeb, :controller
 | 
				
			||||||
  import MemexWeb.Gettext
 | 
					  use Gettext, backend: MemexWeb.Gettext
 | 
				
			||||||
 | 
					  alias Ecto.Changeset
 | 
				
			||||||
  alias Memex.{Accounts, Accounts.Invites}
 | 
					  alias Memex.{Accounts, Accounts.Invites}
 | 
				
			||||||
  alias MemexWeb.HomeLive
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def new(conn, %{"invite" => invite_token}) do
 | 
					  def new(conn, %{"invite" => invite_token}) do
 | 
				
			||||||
    if Invites.valid_invite_token?(invite_token) do
 | 
					    if Invites.valid_invite_token?(invite_token) do
 | 
				
			||||||
      conn |> render_new(invite_token)
 | 
					      conn |> render_new(invite_token)
 | 
				
			||||||
    else
 | 
					    else
 | 
				
			||||||
      conn
 | 
					      conn
 | 
				
			||||||
      |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
 | 
					      |> put_flash(:error, dgettext("errors", "sorry, this invite was not found or expired"))
 | 
				
			||||||
      |> redirect(to: Routes.live_path(Endpoint, HomeLive))
 | 
					      |> redirect(to: ~p"/")
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -19,14 +19,14 @@ defmodule MemexWeb.UserRegistrationController do
 | 
				
			|||||||
      conn |> render_new()
 | 
					      conn |> render_new()
 | 
				
			||||||
    else
 | 
					    else
 | 
				
			||||||
      conn
 | 
					      conn
 | 
				
			||||||
      |> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled"))
 | 
					      |> put_flash(:error, dgettext("errors", "sorry, public registration is disabled"))
 | 
				
			||||||
      |> redirect(to: Routes.live_path(Endpoint, HomeLive))
 | 
					      |> redirect(to: ~p"/")
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  # renders new user registration page
 | 
					  # renders new user registration page
 | 
				
			||||||
  defp render_new(conn, invite_token \\ nil) do
 | 
					  defp render_new(conn, invite_token \\ nil) do
 | 
				
			||||||
    render(conn, "new.html",
 | 
					    render(conn, :new,
 | 
				
			||||||
      changeset: Accounts.change_user_registration(),
 | 
					      changeset: Accounts.change_user_registration(),
 | 
				
			||||||
      invite_token: invite_token,
 | 
					      invite_token: invite_token,
 | 
				
			||||||
      page_title: gettext("register")
 | 
					      page_title: gettext("register")
 | 
				
			||||||
@@ -38,8 +38,8 @@ defmodule MemexWeb.UserRegistrationController do
 | 
				
			|||||||
      conn |> create_user(attrs, invite_token)
 | 
					      conn |> create_user(attrs, invite_token)
 | 
				
			||||||
    else
 | 
					    else
 | 
				
			||||||
      conn
 | 
					      conn
 | 
				
			||||||
      |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired"))
 | 
					      |> put_flash(:error, dgettext("errors", "sorry, this invite was not found or expired"))
 | 
				
			||||||
      |> redirect(to: Routes.live_path(Endpoint, HomeLive))
 | 
					      |> redirect(to: ~p"/")
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -48,8 +48,8 @@ defmodule MemexWeb.UserRegistrationController do
 | 
				
			|||||||
      conn |> create_user(attrs)
 | 
					      conn |> create_user(attrs)
 | 
				
			||||||
    else
 | 
					    else
 | 
				
			||||||
      conn
 | 
					      conn
 | 
				
			||||||
      |> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled"))
 | 
					      |> put_flash(:error, dgettext("errors", "sorry, public registration is disabled"))
 | 
				
			||||||
      |> redirect(to: Routes.live_path(Endpoint, HomeLive))
 | 
					      |> redirect(to: ~p"/")
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -58,20 +58,20 @@ defmodule MemexWeb.UserRegistrationController do
 | 
				
			|||||||
      {:ok, user} ->
 | 
					      {:ok, user} ->
 | 
				
			||||||
        Accounts.deliver_user_confirmation_instructions(
 | 
					        Accounts.deliver_user_confirmation_instructions(
 | 
				
			||||||
          user,
 | 
					          user,
 | 
				
			||||||
          &Routes.user_confirmation_url(conn, :confirm, &1)
 | 
					          fn token -> url(MemexWeb.Endpoint, ~p"/users/confirm/#{token}") end
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        conn
 | 
					        conn
 | 
				
			||||||
        |> put_flash(:info, dgettext("prompts", "please check your email to verify your account"))
 | 
					        |> put_flash(:info, dgettext("prompts", "please check your email to verify your account"))
 | 
				
			||||||
        |> redirect(to: Routes.user_session_path(Endpoint, :new))
 | 
					        |> redirect(to: ~p"/users/log_in")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {:error, :invalid_token} ->
 | 
					      {:error, :invalid_token} ->
 | 
				
			||||||
        conn
 | 
					        conn
 | 
				
			||||||
        |> put_flash(:error, dgettext("errors", "sorry, this invite was not found or expired"))
 | 
					        |> 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} ->
 | 
					      {:error, %Changeset{} = changeset} ->
 | 
				
			||||||
        conn |> render("new.html", changeset: changeset, invite_token: invite_token)
 | 
					        conn |> render(:new, changeset: changeset, invite_token: invite_token)
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										5
									
								
								lib/memex_web/controllers/user_registration_html.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								lib/memex_web/controllers/user_registration_html.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,5 @@
 | 
				
			|||||||
 | 
					defmodule MemexWeb.UserRegistrationHTML do
 | 
				
			||||||
 | 
					  use MemexWeb, :html
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  embed_templates "user_registration_html/*"
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
@@ -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>
 | 
				
			||||||
@@ -6,14 +6,14 @@ defmodule MemexWeb.UserResetPasswordController do
 | 
				
			|||||||
  plug :get_user_by_reset_password_token when action in [:edit, :update]
 | 
					  plug :get_user_by_reset_password_token when action in [:edit, :update]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def new(conn, _params) do
 | 
					  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
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def create(conn, %{"user" => %{"email" => email}}) do
 | 
					  def create(conn, %{"user" => %{"email" => email}}) do
 | 
				
			||||||
    if user = Accounts.get_user_by_email(email) do
 | 
					    if user = Accounts.get_user_by_email(email) do
 | 
				
			||||||
      Accounts.deliver_user_reset_password_instructions(
 | 
					      Accounts.deliver_user_reset_password_instructions(
 | 
				
			||||||
        user,
 | 
					        user,
 | 
				
			||||||
        &Routes.user_reset_password_url(conn, :edit, &1)
 | 
					        fn token -> url(MemexWeb.Endpoint, ~p"/users/reset_password/#{token}") end
 | 
				
			||||||
      )
 | 
					      )
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -23,17 +23,16 @@ defmodule MemexWeb.UserResetPasswordController do
 | 
				
			|||||||
      :info,
 | 
					      :info,
 | 
				
			||||||
      dgettext(
 | 
					      dgettext(
 | 
				
			||||||
        "prompts",
 | 
					        "prompts",
 | 
				
			||||||
        "If your email is in our system, you will receive instructions to " <>
 | 
					        "if your email is in our system, you will receive instructions to reset your password shortly."
 | 
				
			||||||
          "reset your password shortly."
 | 
					 | 
				
			||||||
      )
 | 
					      )
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
    |> redirect(to: "/")
 | 
					    |> redirect(to: ~p"/")
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def edit(conn, _params) do
 | 
					  def edit(conn, _params) do
 | 
				
			||||||
    render(conn, "edit.html",
 | 
					    render(conn, :edit,
 | 
				
			||||||
      changeset: Accounts.change_user_password(conn.assigns.user),
 | 
					      changeset: Accounts.change_user_password(conn.assigns.user),
 | 
				
			||||||
      page_title: gettext("Reset your password")
 | 
					      page_title: gettext("reset your password")
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -41,13 +40,13 @@ defmodule MemexWeb.UserResetPasswordController do
 | 
				
			|||||||
  # leaked token giving the user access to the account.
 | 
					  # leaked token giving the user access to the account.
 | 
				
			||||||
  def update(conn, %{"user" => user_params}) do
 | 
					  def update(conn, %{"user" => user_params}) do
 | 
				
			||||||
    case Accounts.reset_user_password(conn.assigns.user, user_params) do
 | 
					    case Accounts.reset_user_password(conn.assigns.user, user_params) do
 | 
				
			||||||
      {:ok, _} ->
 | 
					      {:ok, _user} ->
 | 
				
			||||||
        conn
 | 
					        conn
 | 
				
			||||||
        |> put_flash(:info, dgettext("prompts", "Password reset successfully."))
 | 
					        |> put_flash(:info, dgettext("prompts", "password reset successfully."))
 | 
				
			||||||
        |> redirect(to: Routes.user_session_path(conn, :new))
 | 
					        |> redirect(to: ~p"/users/log_in")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {:error, changeset} ->
 | 
					      {:error, changeset} ->
 | 
				
			||||||
        render(conn, "edit.html", changeset: changeset)
 | 
					        render(conn, :edit, changeset: changeset)
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -55,14 +54,14 @@ defmodule MemexWeb.UserResetPasswordController do
 | 
				
			|||||||
    %{"token" => token} = conn.params
 | 
					    %{"token" => token} = conn.params
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if user = Accounts.get_user_by_reset_password_token(token) do
 | 
					    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
 | 
					    else
 | 
				
			||||||
      conn
 | 
					      conn
 | 
				
			||||||
      |> put_flash(
 | 
					      |> put_flash(
 | 
				
			||||||
        :error,
 | 
					        :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()
 | 
					      |> halt()
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										6
									
								
								lib/memex_web/controllers/user_reset_password_html.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								lib/memex_web/controllers/user_reset_password_html.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
				
			|||||||
 | 
					defmodule MemexWeb.UserResetPasswordHTML do
 | 
				
			||||||
 | 
					  use MemexWeb, :html
 | 
				
			||||||
 | 
					  alias Memex.Accounts
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  embed_templates "user_reset_password_html/*"
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
@@ -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>
 | 
				
			||||||
@@ -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>
 | 
				
			||||||
@@ -5,7 +5,7 @@ defmodule MemexWeb.UserSessionController do
 | 
				
			|||||||
  alias MemexWeb.UserAuth
 | 
					  alias MemexWeb.UserAuth
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def new(conn, _params) do
 | 
					  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
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def create(conn, %{"user" => user_params}) do
 | 
					  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
 | 
					    if user = Accounts.get_user_by_email_and_password(email, password) do
 | 
				
			||||||
      UserAuth.log_in_user(conn, user, user_params)
 | 
					      UserAuth.log_in_user(conn, user, user_params)
 | 
				
			||||||
    else
 | 
					    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
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										6
									
								
								lib/memex_web/controllers/user_session_html.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								lib/memex_web/controllers/user_session_html.ex
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
				
			|||||||
 | 
					defmodule MemexWeb.UserSessionHTML do
 | 
				
			||||||
 | 
					  use MemexWeb, :html
 | 
				
			||||||
 | 
					  alias Memex.Accounts
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  embed_templates "user_session_html/*"
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
							
								
								
									
										41
									
								
								lib/memex_web/controllers/user_session_html/new.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								lib/memex_web/controllers/user_session_html/new.html.heex
									
									
									
									
									
										Normal 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
		Reference in New Issue
	
	Block a user