30 Commits

Author SHA1 Message Date
926d4f9837 fix style
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-04-05 04:02:14 +00:00
128498eac7 Added translation using Weblate (German)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-04-05 03:42:51 +00:00
32094221c2 update deps
Some checks are pending
continuous-integration/drone/push Build is running
2025-04-05 03:42:43 +00:00
4cca4ee3b7 remove unused toggle button component 2025-04-05 03:22:10 +00:00
da717013de improve accuracy of timestamps 2025-04-05 03:17:56 +00:00
7096e6abeb remove extra file 2025-04-05 03:00:06 +00:00
e379896512 use dynamic dispatch 2025-04-05 02:59:27 +00:00
0c5442f0cd add backlinks
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-02-15 06:01:03 +00:00
6c2aba84ef fix visibility issues with multiple users 2025-02-15 06:01:03 +00:00
3e686fa199 better code style 2025-02-15 06:01:03 +00:00
2a8a1d11b8 mark required fields as required
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-02-15 02:50:57 +00:00
c3d066016b add placeholder for empty notes and contexts 2025-02-15 02:50:57 +00:00
64bf39da29 fix content not escaping html properly 2025-02-15 02:50:56 +00:00
c25e02dee1 update dependencies 2025-02-15 02:50:45 +00:00
5be05ceea6 fix broken install step
All checks were successful
continuous-integration/drone/push Build is passing
2025-02-13 22:01:54 +00:00
e8a041024c add missing license, whoops!!
All checks were successful
continuous-integration/drone/push Build is passing
2024-12-31 00:32:20 -05:00
36f385c7f3 update js dependencies
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2024-12-30 20:09:00 -05:00
ddb8bbec53 downgrade elixir version for images
Some checks reported errors
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build was killed
2024-12-30 19:45:33 -05:00
1e55039a67 fix style issues
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2024-12-30 19:38:34 -05:00
2346a82a46 fix new invite button not working 2024-12-30 19:38:33 -05:00
b63c6bd318 fix descriptions possibly overflowing widths 2024-12-30 19:38:24 -05:00
b72a79c380 update gettext syntax 2024-12-30 19:38:11 -05:00
5cd7a7eef0 update dependencies 2024-12-30 19:05:01 -05:00
f6dc41498b update versions 2024-12-30 19:02:04 -05:00
1c912a1600 improve testing db timeout 2024-07-28 13:49:22 -04:00
eeef7c94cd fix emails
All checks were successful
continuous-integration/drone/push Build is passing
2024-07-28 13:35:34 -04:00
3c3391b3a6 update deps 2024-07-28 13:35:34 -04:00
52460024b9 update versions 2024-07-28 13:35:34 -04:00
48f7c8d18e fix empty invite index page
All checks were successful
continuous-integration/drone/push Build is passing
2024-02-24 13:14:38 -05:00
571e0b65b6 fix faq page copy 2024-02-23 23:36:00 -05:00
150 changed files with 4509 additions and 29167 deletions

View File

@ -17,7 +17,7 @@ steps:
- .mix - .mix
- name: test - name: test
image: elixir:1.16.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

9
.gitignore vendored
View File

@ -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/

View File

@ -1,3 +1,3 @@
elixir 1.16.1-otp-26 elixir 1.18.3-otp-27
erlang 26.2.2 erlang 27.3.1
nodejs 21.6.2 nodejs 23.10.0

View File

@ -1,4 +1,4 @@
FROM elixir:1.16.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,13 +25,12 @@ 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
@ -43,6 +42,8 @@ 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 ./

View File

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

View File

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

View File

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

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

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

View File

@ -1,6 +1,3 @@
// We import the CSS which is extracted to its own file by esbuild.
// Remove this line if you add a your own CSS build pipeline (e.g postcss).
import '../css/app.scss'
import '@fontsource/nunito-sans' 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`

29025
assets/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,47 +1,18 @@
{ {
"repository": {},
"description": " ",
"license": "MIT",
"engines": { "engines": {
"node": "v21.6.2", "node": "v23.10.0"
"npm": "10.2.4"
}, },
"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": "^5.0.8", "@fontsource/nunito-sans": "^5.2.5",
"@fortawesome/fontawesome-free": "^6.5.1", "@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.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.23.9", "npm-check-updates": "^17.1.16",
"@babel/preset-env": "^7.23.9", "standard": "^17.1.2"
"autoprefixer": "^10.4.17",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^12.0.2",
"css-loader": "^6.10.0",
"css-minimizer-webpack-plugin": "^6.0.0",
"file-loader": "^6.2.0",
"mini-css-extract-plugin": "^2.8.0",
"npm-check-updates": "^16.14.15",
"postcss": "^8.4.35",
"postcss-import": "^16.0.1",
"postcss-loader": "^8.1.0",
"postcss-preset-env": "^9.4.0",
"sass": "^1.71.1",
"sass-loader": "^14.1.1",
"standard": "^17.1.0",
"tailwindcss": "^3.4.1",
"terser-webpack-plugin": "^5.3.10",
"webpack": "^5.90.3",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.2"
} }
} }

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

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

View File

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

View File

@ -1,3 +1,28 @@
# 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 # v0.1.15
- Sanitize titles while they are being typed - Sanitize titles while they are being typed
- Sanitize tags while they are being typed - Sanitize tags while they are being typed

View File

@ -8,13 +8,14 @@
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",
@ -28,9 +29,10 @@ config :memex, MemexWeb.Endpoint,
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
# #
@ -54,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,

View File

@ -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,7 +51,7 @@ 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/*/.*(ex)$" ~r"lib/memex_web/*/.*(ex)$"
] ]
@ -73,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

View File

@ -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

View File

@ -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

View File

@ -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
@ -32,4 +35,4 @@ config :logger, level: :warning
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

View File

@ -113,7 +113,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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 340 KiB

View File

@ -6,4 +6,34 @@ defmodule Memex do
Contexts are also responsible for managing your data, regardless 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

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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"))

View File

@ -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

View File

@ -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

View File

@ -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 owner_or_admin?(Context.t(), User.t()) :: boolean()
def owner_or_admin?(%{user_id: user_id}, %{id: user_id}), do: true
def owner_or_admin?(_context, %{role: :admin}), do: true
def owner_or_admin?(_context, _other_user), do: false
@spec owner?(Context.t(), User.t()) :: boolean()
def owner?(%{user_id: user_id}, %{id: user_id}), do: true
def owner?(_context, _other_user), do: false
end end

View File

@ -3,11 +3,8 @@ 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 use Memex, :schema
import MemexWeb.Gettext
alias Ecto.{Changeset, UUID}
alias Memex.{Accounts.User, Repo}
@derive {Phoenix.Param, key: :slug} @derive {Phoenix.Param, key: :slug}
@derive {Jason.Encoder, @derive {Jason.Encoder,
@ -19,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
@ -30,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__{
@ -40,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()
@ -59,12 +54,13 @@ defmodule Memex.Contexts.Context 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} = 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}\-]+$/,
@ -72,7 +68,7 @@ defmodule Memex.Contexts.Context 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

View File

@ -7,8 +7,8 @@ defmodule Memex.Email do
`lib/memex_web/components/layouts/email_text.txt.eex` for text emails. `lib/memex_web/components/layouts/email_text.txt.eex` for text emails.
""" """
use Gettext, backend: MemexWeb.Gettext
import Swoosh.Email import Swoosh.Email
import MemexWeb.Gettext
import Phoenix.Template import Phoenix.Template
alias Memex.Accounts.User alias Memex.Accounts.User
alias MemexWeb.{EmailHTML, Layouts} alias MemexWeb.{EmailHTML, Layouts}

View File

@ -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 owner_or_admin?(Note.t(), User.t()) :: boolean()
def owner_or_admin?(%{user_id: user_id}, %{id: user_id}), do: true
def owner_or_admin?(_context, %{role: :admin}), do: true
def owner_or_admin?(_context, _other_user), do: false
@spec owner?(Note.t(), User.t()) :: boolean()
def owner?(%{user_id: user_id}, %{id: user_id}), do: true
def owner?(_context, _other_user), do: false
end end

View File

@ -2,11 +2,8 @@ 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 use Memex, :schema
import MemexWeb.Gettext
alias Ecto.{Changeset, UUID}
alias Memex.{Accounts.User, Repo}
@derive {Phoenix.Param, key: :slug} @derive {Phoenix.Param, key: :slug}
@derive {Jason.Encoder, @derive {Jason.Encoder,
@ -18,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
@ -29,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__{
@ -39,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()
@ -58,11 +53,12 @@ defmodule Memex.Notes.Note 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} = 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)
@ -71,7 +67,7 @@ defmodule Memex.Notes.Note 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

View File

@ -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 owner_or_admin?(Pipeline.t(), User.t()) :: boolean()
def owner_or_admin?(%{user_id: user_id}, %{id: user_id}), do: true
def owner_or_admin?(_context, %{role: :admin}), do: true
def owner_or_admin?(_context, _other_user), do: false
@spec owner?(Pipeline.t(), User.t()) :: boolean()
def owner?(%{user_id: user_id}, %{id: user_id}), do: true
def owner?(_context, _other_user), do: false
end end

View File

@ -2,11 +2,9 @@ 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 use Memex, :schema
import MemexWeb.Gettext alias Memex.Pipelines.Steps.Step
alias Ecto.{Changeset, UUID}
alias Memex.{Accounts.User, Pipelines.Steps.Step, Repo}
@derive {Phoenix.Param, key: :slug} @derive {Phoenix.Param, key: :slug}
@derive {Jason.Encoder, @derive {Jason.Encoder,
@ -19,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
@ -32,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__{
@ -42,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()
@ -61,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)
@ -74,7 +71,7 @@ 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

View File

@ -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,9 +41,12 @@ 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)
@ -55,22 +55,16 @@ defmodule Memex.Pipelines.Steps.Step do
@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, :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, :user_id, :position]) |> validate_required([:title, :user_id, :position])

View File

@ -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,

View File

@ -17,7 +17,7 @@ defmodule MemexWeb do
those modules here. those modules here.
""" """
def static_paths, do: ~w(css js fonts images favicon.ico robots.txt) def static_paths, do: ~w(assets fonts images favicon.ico robots.txt webfonts)
def router do def router do
quote do quote do
@ -42,9 +42,10 @@ defmodule MemexWeb do
formats: [:html, :json], formats: [:html, :json],
layouts: [html: MemexWeb.Layouts] layouts: [html: MemexWeb.Layouts]
# credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse use Gettext, backend: MemexWeb.Gettext
import MemexWeb.ControllerHelpers
import Plug.Conn import Plug.Conn
import MemexWeb.Gettext
unquote(verified_routes()) unquote(verified_routes())
end end
@ -84,8 +85,9 @@ defmodule MemexWeb do
defp html_helpers do defp html_helpers do
quote do quote do
use PhoenixHTMLHelpers use PhoenixHTMLHelpers
use Gettext, backend: MemexWeb.Gettext
import Phoenix.{Component, HTML, HTML.Form} import Phoenix.{Component, HTML, HTML.Form}
import MemexWeb.{ErrorHelpers, Gettext, CoreComponents, HTMLHelpers} import MemexWeb.{ErrorHelpers, CoreComponents, HTMLHelpers}
# Shortcut for generating JS commands # Shortcut for generating JS commands
alias Phoenix.LiveView.JS alias Phoenix.LiveView.JS

View File

@ -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}]
@ -91,7 +91,7 @@ defmodule MemexWeb.Components.ContextsTableComponent do
defp get_value_for_key(:slug, %{slug: slug} = assigns, _additional_data) do defp get_value_for_key(:slug, %{slug: slug} = assigns, _additional_data) do
slug_block = ~H""" slug_block = ~H"""
<.link navigate={~p"/context/#{@slug}"} class="link"> <.link navigate={~p"/context/#{@slug}"} class="link">
<%= @slug %> {@slug}
</.link> </.link>
""" """
@ -102,7 +102,7 @@ defmodule MemexWeb.Components.ContextsTableComponent do
~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={~p"/contexts/#{tag}"} class="link"> <.link :for={tag <- @tags} patch={~p"/contexts/#{tag}"} class="link">
<%= tag %> {tag}
</.link> </.link>
</div> </div>
""" """
@ -113,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

View File

@ -5,7 +5,8 @@ defmodule MemexWeb.CoreComponents do
use PhoenixHTMLHelpers use PhoenixHTMLHelpers
use Phoenix.Component use Phoenix.Component
use MemexWeb, :verified_routes use MemexWeb, :verified_routes
import MemexWeb.{Gettext, HTMLHelpers} 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
@ -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: ""
@ -138,6 +121,8 @@ defmodule MemexWeb.CoreComponents do
defp display_links(record) do defp display_links(record) do
record record
|> get_content() |> get_content()
|> Phoenix.HTML.html_escape()
|> Phoenix.HTML.safe_to_string()
|> replace_hyperlinks(record) |> replace_hyperlinks(record)
|> replace_triple_links(record) |> replace_triple_links(record)
|> replace_double_links(record) |> replace_double_links(record)
@ -167,14 +152,14 @@ defmodule MemexWeb.CoreComponents do
link( link(
link, link,
to: link, to: link,
class: "link inline", class: "link inline break-words",
target: "_blank", target: "_blank",
rel: "noopener noreferrer" rel: "noopener noreferrer"
) )
|> HTML.Safe.to_iodata() |> HTML.Safe.to_iodata()
|> IO.iodata_to_binary() |> IO.iodata_to_binary()
"</p>#{link}<p class=\"inline\">" "</p>#{link}<p class=\"inline break-words\">"
end end
) )
end end
@ -189,12 +174,12 @@ defmodule MemexWeb.CoreComponents do
link( link(
"[[[#{slug}]]]", "[[[#{slug}]]]",
to: ~p"/note/#{slug}", to: ~p"/note/#{slug}",
class: "link inline" class: "link inline break-words"
) )
|> HTML.Safe.to_iodata() |> HTML.Safe.to_iodata()
|> IO.iodata_to_binary() |> IO.iodata_to_binary()
"#{prefix}</p>#{link}<p class=\"inline\">#{suffix}" "#{prefix}</p>#{link}<p class=\"inline break-words\">#{suffix}"
end end
) )
end end
@ -216,12 +201,12 @@ defmodule MemexWeb.CoreComponents do
link( link(
"[[#{slug}]]", "[[#{slug}]]",
to: target, to: target,
class: "link inline" class: "link inline break-words"
) )
|> HTML.Safe.to_iodata() |> HTML.Safe.to_iodata()
|> IO.iodata_to_binary() |> IO.iodata_to_binary()
"#{prefix}</p>#{link}<p class=\"inline\">#{suffix}" "#{prefix}</p>#{link}<p class=\"inline break-words\">#{suffix}"
end end
) )
end end
@ -244,12 +229,12 @@ defmodule MemexWeb.CoreComponents do
link( link(
"[#{slug}]", "[#{slug}]",
to: target, to: target,
class: "link inline" class: "link inline break-words"
) )
|> HTML.Safe.to_iodata() |> HTML.Safe.to_iodata()
|> IO.iodata_to_binary() |> IO.iodata_to_binary()
"#{prefix}</p>#{link}<p class=\"inline\">#{suffix}" "#{prefix}</p>#{link}<p class=\"inline break-words\">#{suffix}"
end end
) )
end end

View File

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

View File

@ -1,3 +1,3 @@
<time :if={@date} id={@id} datetime={Date.to_iso8601(@date, :extended)} phx-hook="Date"> <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>

View File

@ -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>

View File

@ -1,25 +1,22 @@
<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 %>
@ -29,20 +26,19 @@
/> />
<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
><%= url(MemexWeb.Endpoint, ~p"/users/register?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>

View File

@ -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>

View File

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

View File

@ -1,7 +1,7 @@
<div <div
:if={@pipeline.description} :if={@pipeline.description}
id={"show-pipeline-description-#{@pipeline.id}"} id={"show-pipeline-description-#{@pipeline.id}"}
class="input input-primary h-32 min-h-32 inline-block whitespace-pre-wrap overflow-x-hidden overflow-y-auto resize-y" 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" phx-update="ignore"
readonly readonly
phx-no-format phx-no-format

View File

@ -1,7 +1,7 @@
<div <div
:if={@step.content} :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 resize-y" 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" phx-update="ignore"
readonly readonly
phx-no-format phx-no-format

View File

@ -1,30 +0,0 @@
<label for={@id || @action} class="relative inline-flex 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 whitespace-nowrap"
>
<%= render_slot(@inner_block) %>
</span>
</label>

View File

@ -2,14 +2,14 @@
<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 navigate={~p"/"} class="mx-2 my-1 leading-5 text-xl text-primary-400 hover:underline"> <.link navigate={~p"/"} class="mx-2 my-1 leading-5 text-xl text-primary-400 hover:underline">
<%= gettext("memEx") %> {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>
@ -19,19 +19,19 @@
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 navigate={~p"/notes"} class="text-primary-400 hover:underline truncate"> <.link navigate={~p"/notes"} class="text-primary-400 hover:underline truncate">
<%= gettext("notes") %> {gettext("notes")}
</.link> </.link>
</li> </li>
<li class="mx-2 my-1"> <li class="mx-2 my-1">
<.link navigate={~p"/contexts"} class="text-primary-400 hover:underline truncate"> <.link navigate={~p"/contexts"} class="text-primary-400 hover:underline truncate">
<%= gettext("contexts") %> {gettext("contexts")}
</.link> </.link>
</li> </li>
<li class="mx-2 my-1"> <li class="mx-2 my-1">
<.link navigate={~p"/pipelines"} class="text-primary-400 hover:underline truncate"> <.link navigate={~p"/pipelines"} class="text-primary-400 hover:underline truncate">
<%= gettext("pipelines") %> {gettext("pipelines")}
</.link> </.link>
</li> </li>
@ -40,13 +40,13 @@
<%= if @current_user do %> <%= if @current_user do %>
<li :if={@current_user |> Accounts.already_admin?()} class="mx-2 my-1"> <li :if={@current_user |> Accounts.already_admin?()} class="mx-2 my-1">
<.link navigate={~p"/invites"} class="text-primary-400 hover:underline"> <.link navigate={~p"/invites"} class="text-primary-400 hover:underline">
<%= gettext("invites") %> {gettext("invites")}
</.link> </.link>
</li> </li>
<li class="mx-2 my-1"> <li class="mx-2 my-1">
<.link navigate={~p"/users/settings"} class="text-primary-400 hover:underline truncate"> <.link navigate={~p"/users/settings"} class="text-primary-400 hover:underline truncate">
<%= @current_user.email %> {@current_user.email}
</.link> </.link>
</li> </li>
<li class="mx-2 my-1"> <li class="mx-2 my-1">
@ -76,13 +76,13 @@
<% else %> <% else %>
<li :if={Accounts.allow_registration?()} class="mx-2 my-1"> <li :if={Accounts.allow_registration?()} class="mx-2 my-1">
<.link href={~p"/users/register"} class="text-primary-400 hover:underline truncate"> <.link href={~p"/users/register"} class="text-primary-400 hover:underline truncate">
<%= dgettext("actions", "register") %> {dgettext("actions", "register")}
</.link> </.link>
</li> </li>
<li class="mx-2 my-1"> <li class="mx-2 my-1">
<.link href={~p"/users/log_in"} class="text-primary-400 hover:underline truncate"> <.link href={~p"/users/log_in"} class="text-primary-400 hover:underline truncate">
<%= dgettext("actions", "log in") %> {dgettext("actions", "log in")}
</.link> </.link>
</li> </li>
<% end %> <% end %>

View File

@ -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>

View File

@ -10,7 +10,7 @@
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
@ -20,13 +20,13 @@
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>
@ -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>

View File

@ -1,16 +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={~p"/"}> <a style="color: rgb(161, 161, 170);" href={~p"/"}>
<%= dgettext("emails", "this email was sent from memEx") %> {dgettext("emails", "this email was sent from memEx")}
</a> </a>
</body> </body>
</html> </html>

View File

@ -1 +1 @@
<%= @inner_content %> {@inner_content}

View File

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

View File

@ -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}]
@ -91,7 +91,7 @@ defmodule MemexWeb.Components.NotesTableComponent do
defp get_value_for_key(:slug, %{slug: slug} = assigns, _additional_data) do defp get_value_for_key(:slug, %{slug: slug} = assigns, _additional_data) do
slug_block = ~H""" slug_block = ~H"""
<.link navigate={~p"/note/#{@slug}"} class="link"> <.link navigate={~p"/note/#{@slug}"} class="link">
<%= @slug %> {@slug}
</.link> </.link>
""" """
@ -102,7 +102,7 @@ defmodule MemexWeb.Components.NotesTableComponent do
~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={~p"/notes/#{tag}"} class="link"> <.link :for={tag <- @tags} patch={~p"/notes/#{tag}"} class="link">
<%= tag %> {tag}
</.link> </.link>
</div> </div>
""" """
@ -113,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

View File

@ -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}]
@ -92,7 +92,7 @@ defmodule MemexWeb.Components.PipelinesTableComponent do
defp get_value_for_key(:slug, %{slug: slug} = assigns, _additional_data) do defp get_value_for_key(:slug, %{slug: slug} = assigns, _additional_data) do
slug_block = ~H""" slug_block = ~H"""
<.link navigate={~p"/pipeline/#{@slug}"} class="link"> <.link navigate={~p"/pipeline/#{@slug}"} class="link">
<%= @slug %> {@slug}
</.link> </.link>
""" """
@ -101,8 +101,8 @@ defmodule MemexWeb.Components.PipelinesTableComponent do
defp get_value_for_key(:description, %{description: description} = assigns, _additional_data) do defp get_value_for_key(:description, %{description: description} = assigns, _additional_data) do
description_block = ~H""" description_block = ~H"""
<div class="truncate max-w-sm"> <div class="max-w-sm truncate">
<%= @description %> {@description}
</div> </div>
""" """
@ -113,7 +113,7 @@ defmodule MemexWeb.Components.PipelinesTableComponent do
~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={~p"/pipelines/#{tag}"} class="link"> <.link :for={tag <- @tags} patch={~p"/pipelines/#{tag}"} class="link">
<%= tag %> {tag}
</.link> </.link>
</div> </div>
""" """
@ -124,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

View File

@ -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>

View File

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

View File

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

View File

@ -1,17 +1,17 @@
<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 reset your password by visiting the URL below:") %> {dgettext("emails", "You can reset your password by visiting the URL below:")}
<br /> <br />
<a style="margin: 1em; color: rgb(161, 161, 170);" href={@url}><%= @url %></a> <a style="margin: 1em; color: rgb(161, 161, 170);" href={@url}>{@url}</a>
<br /> <br />
<%= dgettext("emails", "If you didn't request this change from memEx, please ignore this.") %> {dgettext("emails", "If you didn't request this change from memEx, please ignore this.")}
</div> </div>

View File

@ -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(161, 161, 170);" 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>

View File

@ -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,13 +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 href={~p"/"} class="link title text-primary-400 text-lg"> <.link href={~p"/"} class="link title text-primary-400 text-lg">
<%= dgettext("errors", "go back home") %> {dgettext("errors", "go back home")}
</.link> </.link>
</div> </div>
</div> </div>

View File

@ -1,5 +1,5 @@
defmodule MemexWeb.ErrorJSON do defmodule MemexWeb.ErrorJSON do
import MemexWeb.Gettext use Gettext, backend: MemexWeb.Gettext
def render(template, _assigns) do def render(template, _assigns) do
error_string = error_string =

View File

@ -4,9 +4,9 @@ defmodule MemexWeb.UserAuth do
""" """
use MemexWeb, :verified_routes 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}
# Make the remember me cookie valid for 60 days. # Make the remember me cookie valid for 60 days.

View File

@ -1,7 +1,6 @@
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
@ -43,7 +42,7 @@ 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: ~p"/") redirect(conn, to: ~p"/")
%{} -> %{} ->

View File

@ -1,6 +1,6 @@
<div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> <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"> <h1 class="title text-primary-400 text-xl">
<%= dgettext("actions", "Resend confirmation instructions") %> {dgettext("actions", "Resend confirmation instructions")}
</h1> </h1>
<.form <.form
@ -10,22 +10,22 @@
action={~p"/users/confirm"} 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" 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") %> {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") %> {email_input(f, :email, required: true, class: "input input-primary col-span-2")}
<%= submit(dgettext("actions", "Resend confirmation instructions"), {submit(dgettext("actions", "Resend confirmation instructions"),
class: "mx-auto btn btn-primary col-span-3" class: "mx-auto btn btn-primary col-span-3"
) %> )}
</.form> </.form>
<hr class="hr" /> <hr class="hr" />
<div class="flex flex-row justify-center items-center space-x-4"> <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"> <.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary">
<%= dgettext("actions", "register") %> {dgettext("actions", "register")}
</.link> </.link>
<.link href={~p"/users/log_in"} class="btn btn-primary"> <.link href={~p"/users/log_in"} class="btn btn-primary">
<%= dgettext("actions", "log in") %> {dgettext("actions", "log in")}
</.link> </.link>
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
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 Ecto.Changeset
alias Memex.{Accounts, Accounts.Invites} alias Memex.{Accounts, Accounts.Invites}

View File

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

View File

@ -40,7 +40,7 @@ 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: ~p"/users/log_in") |> redirect(to: ~p"/users/log_in")
@ -54,7 +54,7 @@ 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(

View File

@ -1,6 +1,6 @@
<div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> <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"> <h1 class="title text-primary-400 text-xl">
<%= dgettext("actions", "reset password") %> {dgettext("actions", "reset password")}
</h1> </h1>
<.form <.form
@ -9,36 +9,36 @@
action={~p"/users/reset_password/#{@token}"} 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" 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"> <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.") %> {dgettext("errors", "oops, something went wrong! please check the errors below.")}
</p> </p>
<%= label(f, :password, gettext("new password"), class: "title text-lg text-primary-400") %> {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") %> {password_input(f, :password, required: true, class: "input input-primary col-span-2")}
<%= error_tag(f, :password, "col-span-3") %> {error_tag(f, :password, "col-span-3")}
<%= label(f, :password_confirmation, gettext("confirm new password"), {label(f, :password_confirmation, gettext("confirm new password"),
class: "title text-lg text-primary-400" class: "title text-lg text-primary-400"
) %> )}
<%= password_input(f, :password_confirmation, {password_input(f, :password_confirmation,
required: true, required: true,
class: "input input-primary col-span-2" class: "input input-primary col-span-2"
) %> )}
<%= error_tag(f, :password_confirmation, "col-span-3") %> {error_tag(f, :password_confirmation, "col-span-3")}
<%= submit(dgettext("actions", "reset password"), {submit(dgettext("actions", "reset password"),
class: "mx-auto btn btn-primary col-span-3" class: "mx-auto btn btn-primary col-span-3"
) %> )}
</.form> </.form>
<hr class="hr" /> <hr class="hr" />
<div class="flex flex-row justify-center items-center space-x-4"> <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"> <.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary">
<%= dgettext("actions", "register") %> {dgettext("actions", "register")}
</.link> </.link>
<.link href={~p"/users/log_in"} class="btn btn-primary"> <.link href={~p"/users/log_in"} class="btn btn-primary">
<%= dgettext("actions", "log in") %> {dgettext("actions", "log in")}
</.link> </.link>
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
<div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> <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"> <h1 class="title text-primary-400 text-xl">
<%= dgettext("actions", "forgot your password?") %> {dgettext("actions", "forgot your password?")}
</h1> </h1>
<.form <.form
@ -10,22 +10,22 @@
action={~p"/users/reset_password"} 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" 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") %> {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") %> {email_input(f, :email, required: true, class: "input input-primary col-span-2")}
<%= submit(dgettext("actions", "send instructions to reset password"), {submit(dgettext("actions", "send instructions to reset password"),
class: "mx-auto btn btn-primary col-span-3" class: "mx-auto btn btn-primary col-span-3"
) %> )}
</.form> </.form>
<hr class="hr" /> <hr class="hr" />
<div class="flex flex-row justify-center items-center space-x-4"> <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"> <.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary">
<%= dgettext("actions", "register") %> {dgettext("actions", "register")}
</.link> </.link>
<.link href={~p"/users/log_in"} class="btn btn-primary"> <.link href={~p"/users/log_in"} class="btn btn-primary">
<%= dgettext("actions", "log in") %> {dgettext("actions", "log in")}
</.link> </.link>
</div> </div>
</div> </div>

View File

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

View File

@ -1,6 +1,6 @@
defmodule MemexWeb.UserSettingsController do defmodule MemexWeb.UserSettingsController do
use MemexWeb, :controller use MemexWeb, :controller
import MemexWeb.Gettext use Gettext, backend: MemexWeb.Gettext
alias Memex.Accounts alias Memex.Accounts
alias MemexWeb.UserAuth alias MemexWeb.UserAuth
@ -103,8 +103,10 @@ defmodule MemexWeb.UserSettingsController do
defp assign_email_and_password_changesets(%{assigns: %{current_user: user}} = conn, _opts) do defp assign_email_and_password_changesets(%{assigns: %{current_user: user}} = conn, _opts) do
conn conn
|> assign(:email_changeset, Accounts.change_user_email(user)) |> assign(
|> assign(:password_changeset, Accounts.change_user_password(user)) email_changeset: Accounts.change_user_email(user),
|> assign(:locale_changeset, Accounts.change_user_locale(user)) locale_changeset: Accounts.change_user_locale(user),
password_changeset: Accounts.change_user_password(user)
)
end end
end end

View File

@ -1,6 +1,6 @@
<div class="mx-auto pb-8 max-w-3xl flex flex-col justify-center items-stretch text-right space-y-4"> <div class="mx-auto pb-8 max-w-3xl flex flex-col justify-center items-stretch text-right space-y-4">
<h1 class="title text-primary-400 text-xl text-left"> <h1 class="title text-primary-400 text-xl text-left">
<%= gettext("settings") %> {gettext("settings")}
</h1> </h1>
<hr class="hr" /> <hr class="hr" />
@ -12,37 +12,37 @@
class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
> >
<h3 class="title text-primary-400 text-lg text-center col-span-3"> <h3 class="title text-primary-400 text-lg text-center col-span-3">
<%= dgettext("actions", "change email") %> {dgettext("actions", "change email")}
</h3> </h3>
<div <div
:if={@email_changeset.action && not @email_changeset.valid?()} :if={@email_changeset.action && not @email_changeset.valid?}
class="alert alert-danger col-span-3" class="alert alert-danger col-span-3"
> >
<%= dgettext("errors", "oops, something went wrong! please check the errors below") %> {dgettext("errors", "oops, something went wrong! please check the errors below")}
</div> </div>
<%= hidden_input(f, :action, name: "action", value: "update_email") %> {hidden_input(f, :action, name: "action", value: "update_email")}
<%= label(f, :email, gettext("email"), class: "title text-lg text-primary-400") %> {label(f, :email, gettext("email"), class: "title text-lg text-primary-400")}
<%= email_input(f, :email, required: true, class: "mx-2 my-1 input input-primary col-span-2") %> {email_input(f, :email, required: true, class: "mx-2 my-1 input input-primary col-span-2")}
<%= error_tag(f, :email, "col-span-3") %> {error_tag(f, :email, "col-span-3")}
<%= label(f, :current_password, gettext("current password"), {label(f, :current_password, gettext("current password"),
for: "current_password_for_email", for: "current_password_for_email",
class: "mx-2 my-1 title text-lg text-primary-400" class: "mx-2 my-1 title text-lg text-primary-400"
) %> )}
<%= password_input(f, :current_password, {password_input(f, :current_password,
required: true, required: true,
name: "current_password", name: "current_password",
id: "current_password_for_email", id: "current_password_for_email",
class: "mx-2 my-1 input input-primary col-span-2" class: "mx-2 my-1 input input-primary col-span-2"
) %> )}
<%= error_tag(f, :current_password, "col-span-3") %> {error_tag(f, :current_password, "col-span-3")}
<%= submit(dgettext("actions", "change email"), {submit(dgettext("actions", "change email"),
class: "mx-auto btn btn-primary col-span-3" class: "mx-auto btn btn-primary col-span-3"
) %> )}
</.form> </.form>
<hr class="hr" /> <hr class="hr" />
@ -54,49 +54,49 @@
class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center"
> >
<h3 class="title text-primary-400 text-lg text-center col-span-3"> <h3 class="title text-primary-400 text-lg text-center col-span-3">
<%= dgettext("actions", "change password") %> {dgettext("actions", "change password")}
</h3> </h3>
<p <p
:if={@password_changeset.action && not @password_changeset.valid?()} :if={@password_changeset.action && not @password_changeset.valid?}
class="alert alert-danger col-span-3" class="alert alert-danger col-span-3"
> >
<%= dgettext("errors", "oops, something went wrong! please check the errors below.") %> {dgettext("errors", "oops, something went wrong! please check the errors below.")}
</p> </p>
<%= hidden_input(f, :action, name: "action", value: "update_password") %> {hidden_input(f, :action, name: "action", value: "update_password")}
<%= label(f, :password, gettext("new password"), class: "title text-lg text-primary-400") %> {label(f, :password, gettext("new password"), class: "title text-lg text-primary-400")}
<%= password_input(f, :password, {password_input(f, :password,
required: true, required: true,
class: "mx-2 my-1 input input-primary col-span-2" class: "mx-2 my-1 input input-primary col-span-2"
) %> )}
<%= error_tag(f, :password, "col-span-3") %> {error_tag(f, :password, "col-span-3")}
<%= label(f, :password_confirmation, gettext("confirm new password"), {label(f, :password_confirmation, gettext("confirm new password"),
class: "title text-lg text-primary-400" class: "title text-lg text-primary-400"
) %> )}
<%= password_input(f, :password_confirmation, {password_input(f, :password_confirmation,
required: true, required: true,
class: "mx-2 my-1 input input-primary col-span-2" class: "mx-2 my-1 input input-primary col-span-2"
) %> )}
<%= error_tag(f, :password_confirmation, "col-span-3") %> {error_tag(f, :password_confirmation, "col-span-3")}
<%= label(f, :current_password, gettext("current password"), {label(f, :current_password, gettext("current password"),
for: "current_password_for_password", for: "current_password_for_password",
class: "title text-lg text-primary-400" class: "title text-lg text-primary-400"
) %> )}
<%= password_input(f, :current_password, {password_input(f, :current_password,
required: true, required: true,
name: "current_password", name: "current_password",
id: "current_password_for_password", id: "current_password_for_password",
class: "mx-2 my-1 input input-primary col-span-2" class: "mx-2 my-1 input input-primary col-span-2"
) %> )}
<%= error_tag(f, :current_password, "col-span-3") %> {error_tag(f, :current_password, "col-span-3")}
<%= submit(dgettext("actions", "change password"), {submit(dgettext("actions", "change password"),
class: "mx-auto btn btn-primary col-span-3" class: "mx-auto btn btn-primary col-span-3"
) %> )}
</.form> </.form>
<hr class="hr" /> <hr class="hr" />
@ -107,35 +107,35 @@
action={~p"/users/settings"} action={~p"/users/settings"}
class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" 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, :locale, dgettext("actions", "change language"), {label(f, :locale, dgettext("actions", "change language"),
class: "title text-primary-400 text-lg text-center col-span-3" class: "title text-primary-400 text-lg text-center col-span-3"
) %> )}
<div <div
:if={@locale_changeset.action && not @locale_changeset.valid?()} :if={@locale_changeset.action && not @locale_changeset.valid?}
class="alert alert-danger col-span-3" class="alert alert-danger col-span-3"
> >
<%= dgettext("errors", "oops, something went wrong! please check the errors below") %> {dgettext("errors", "oops, something went wrong! please check the errors below")}
</div> </div>
<%= hidden_input(f, :action, name: "action", value: "update_locale") %> {hidden_input(f, :action, name: "action", value: "update_locale")}
<%= select(f, :locale, [{gettext("english"), "en_US"}, {"spanish", "es"}], {select(f, :locale, [{gettext("english"), "en_US"}, {"spanish", "es"}],
class: "mx-2 my-1 min-w-md input input-primary col-start-2" class: "mx-2 my-1 min-w-md input input-primary col-start-2"
) %> )}
<%= error_tag(f, :locale, "col-span-3") %> {error_tag(f, :locale, "col-span-3")}
<%= submit(dgettext("actions", "change language"), {submit(dgettext("actions", "change language"),
class: "whitespace-nowrap mx-auto btn btn-primary col-span-3", class: "whitespace-nowrap mx-auto btn btn-primary col-span-3",
data: [qa: dgettext("prompts", "are you sure you want to change your language?")] data: [qa: dgettext("prompts", "are you sure you want to change your language?")]
) %> )}
</.form> </.form>
<hr class="hr" /> <hr class="hr" />
<div class="flex justify-end items-center"> <div class="flex justify-end items-center">
<.link href={~p"/export/json"} class="mx-4 my-2 btn btn-primary" target="_blank"> <.link href={~p"/export/json"} class="mx-4 my-2 btn btn-primary" target="_blank">
<%= dgettext("actions", "export data as json") %> {dgettext("actions", "export data as json")}
</.link> </.link>
<.link <.link
@ -144,7 +144,7 @@
class="mx-4 my-2 btn btn-alert" class="mx-4 my-2 btn btn-alert"
data-confirm={dgettext("prompts", "are you sure you want to delete your account?")} data-confirm={dgettext("prompts", "are you sure you want to delete your account?")}
> >
<%= dgettext("actions", "delete user") %> {dgettext("actions", "delete user")}
</.link> </.link>
</div> </div>
</div> </div>

View File

@ -4,7 +4,7 @@ defmodule MemexWeb.ErrorHelpers do
""" """
use PhoenixHTMLHelpers use PhoenixHTMLHelpers
import Phoenix.{Component, HTML.Form} import Phoenix.Component
alias Ecto.Changeset alias Ecto.Changeset
alias Phoenix.{HTML.Form, LiveView.Rendered} alias Phoenix.{HTML.Form, LiveView.Rendered}
@ -19,10 +19,10 @@ defmodule MemexWeb.ErrorHelpers do
~H""" ~H"""
<span <span
:for={error <- Keyword.get_values(@form.errors, @field)} :for={error <- Keyword.get_values(@form.errors, @field)}
:if={used_input?(@form[@field])}
class={["invalid-feedback", @extra_class]} class={["invalid-feedback", @extra_class]}
phx-feedback-for={input_name(@form, @field)}
> >
<%= translate_error(error) %> {translate_error(error)}
</span> </span>
""" """
end end

View File

@ -5,7 +5,7 @@ defmodule MemexWeb.Gettext do
By using [Gettext](https://hexdocs.pm/gettext), By using [Gettext](https://hexdocs.pm/gettext),
your module gains a set of macros for translations, for example: your module gains a set of macros for translations, for example:
import MemexWeb.Gettext use Gettext, backend: MemexWeb.Gettext
# Simple translation # Simple translation
gettext("Here is the string to translate") gettext("Here is the string to translate")
@ -20,5 +20,5 @@ defmodule MemexWeb.Gettext do
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
""" """
use Gettext, otp_app: :memex use Gettext.Backend, otp_app: :memex
end end

View File

@ -69,7 +69,7 @@ defmodule MemexWeb.ContextLive.FormComponent do
|> push_navigate(to: return_to) |> push_navigate(to: return_to)
{:error, %Changeset{} = changeset} -> {:error, %Changeset{} = changeset} ->
assign(socket, changeset: changeset) assign(socket, :changeset, changeset)
end end
{:noreply, socket} {:noreply, socket}

View File

@ -1,4 +1,4 @@
<div class="h-full flex flex-col justify-start items-stretch space-y-4"> <div class="flex flex-col justify-start items-stretch space-y-4 h-full">
<.form <.form
:let={f} :let={f}
for={@changeset} for={@changeset}
@ -9,16 +9,17 @@
phx-hook="CtrlEnter" phx-hook="CtrlEnter"
class="flex flex-col justify-start items-stretch space-y-4" class="flex flex-col justify-start items-stretch space-y-4"
> >
<%= text_input(f, :slug, {text_input(f, :slug,
aria_label: gettext("slug"), aria_label: gettext("slug"),
class: "input input-primary", class: "input input-primary",
phx_debounce: 300, phx_debounce: 300,
phx_hook: "SanitizeTitles", phx_hook: "SanitizeTitles",
placeholder: gettext("slug") placeholder: gettext("slug"),
) %> required: true
<%= error_tag(f, :slug) %> )}
{error_tag(f, :slug)}
<%= textarea(f, :content, {textarea(f, :content,
id: "context-form-content", id: "context-form-content",
class: "input input-primary h-64 min-h-64", class: "input input-primary h-64 min-h-64",
phx_update: "ignore", phx_update: "ignore",
@ -27,32 +28,32 @@
aria_label: aria_label:
gettext("use [[note-slug]] to link to a note or [context-slug] to link to a context"), gettext("use [[note-slug]] to link to a note or [context-slug] to link to a context"),
phx_debounce: 300 phx_debounce: 300
) %> )}
<%= error_tag(f, :content) %> {error_tag(f, :content)}
<%= text_input(f, :tags_string, {text_input(f, :tags_string,
aria_label: gettext("tag1,tag2"), aria_label: gettext("tag1,tag2"),
class: "input input-primary", class: "input input-primary",
id: "tags-input", id: "tags-input",
phx_debounce: 300, phx_debounce: 300,
phx_hook: "SanitizeTags", phx_hook: "SanitizeTags",
placeholder: gettext("tag1,tag2") placeholder: gettext("tag1,tag2")
) %> )}
<%= error_tag(f, :tags_string) %> {error_tag(f, :tags_string)}
<div class="flex justify-center items-stretch space-x-4"> <div class="flex justify-center items-stretch space-x-4">
<%= select(f, :visibility, Ecto.Enum.values(Memex.Contexts.Context, :visibility), {select(f, :visibility, Ecto.Enum.values(Memex.Contexts.Context, :visibility),
class: "grow input input-primary", class: "grow input input-primary",
prompt: gettext("select privacy"), prompt: gettext("select privacy"),
aria_label: gettext("select privacy"), aria_label: gettext("select privacy"),
phx_debounce: 300 phx_debounce: 300
) %> )}
<%= submit(dgettext("actions", "save"), {submit(dgettext("actions", "save"),
phx_disable_with: gettext("saving..."), phx_disable_with: gettext("saving..."),
class: "mx-auto btn btn-primary" class: "mx-auto btn btn-primary"
) %> )}
</div> </div>
<%= error_tag(f, :visibility) %> {error_tag(f, :visibility)}
</.form> </.form>
</div> </div>

View File

@ -20,29 +20,37 @@ defmodule MemexWeb.ContextLive.Index do
%{slug: slug} = context = Contexts.get_context_by_slug(slug, current_user) %{slug: slug} = context = Contexts.get_context_by_slug(slug, current_user)
socket socket
|> assign(page_title: gettext("edit %{slug}", slug: slug)) |> assign(
|> assign(context: context) context: context,
page_title: gettext("edit %{slug}", slug: slug)
)
end end
defp apply_action(%{assigns: %{current_user: %{id: current_user_id}}} = socket, :new, _params) do defp apply_action(%{assigns: %{current_user: %{id: current_user_id}}} = socket, :new, _params) do
socket socket
|> assign(page_title: gettext("new context")) |> assign(
|> assign(context: %Context{visibility: :private, user_id: current_user_id}) context: %Context{visibility: :private, user_id: current_user_id},
page_title: gettext("new context")
)
end end
defp apply_action(socket, :index, _params) do defp apply_action(socket, :index, _params) do
socket socket
|> assign(page_title: gettext("contexts")) |> assign(
|> assign(search: nil) context: nil,
|> assign(context: nil) page_title: gettext("contexts"),
search: nil
)
|> display_contexts() |> display_contexts()
end end
defp apply_action(socket, :search, %{"search" => search}) do defp apply_action(socket, :search, %{"search" => search}) do
socket socket
|> assign(page_title: gettext("contexts")) |> assign(
|> assign(search: search) context: nil,
|> assign(context: nil) page_title: gettext("contexts"),
search: search
)
|> display_contexts() |> display_contexts()
end end
@ -68,8 +76,7 @@ defmodule MemexWeb.ContextLive.Index do
{:noreply, socket |> push_patch(to: redirect_to)} {:noreply, socket |> push_patch(to: redirect_to)}
end end
defp display_contexts(%{assigns: %{current_user: current_user, search: search}} = socket) defp display_contexts(%{assigns: %{current_user: %{} = current_user, search: search}} = socket) do
when not (current_user |> is_nil()) do
socket |> assign(contexts: Contexts.list_contexts(search, current_user)) socket |> assign(contexts: Contexts.list_contexts(search, current_user))
end end

View File

@ -1,6 +1,6 @@
<div class="mx-auto flex flex-col justify-center items-start space-y-4 max-w-3xl"> <div class="flex flex-col justify-center items-start mx-auto space-y-4 max-w-3xl">
<h1 class="text-xl"> <h1 class="text-xl">
<%= gettext("contexts") %> {gettext("contexts")}
</h1> </h1>
<.form <.form
@ -9,20 +9,20 @@
as={:search} as={:search}
phx-change="search" phx-change="search"
phx-submit="search" phx-submit="search"
class="self-stretch flex flex-col items-stretch" class="flex flex-col items-stretch self-stretch"
> >
<%= text_input(f, :search_term, {text_input(f, :search_term,
class: "input input-primary", class: "input input-primary",
value: @search, value: @search,
role: "search", role: "search",
phx_debounce: 300, phx_debounce: 300,
placeholder: gettext("search") placeholder: gettext("search")
) %> )}
</.form> </.form>
<%= if @contexts |> Enum.empty?() do %> <%= if @contexts |> Enum.empty?() do %>
<h1 class="self-center text-primary-500"> <h1 class="self-center text-primary-500">
<%= gettext("no contexts found") %> {gettext("no contexts found")}
</h1> </h1>
<% else %> <% else %>
<.live_component <.live_component
@ -33,28 +33,28 @@
> >
<:actions :let={context}> <:actions :let={context}>
<.link <.link
:if={Contexts.owner?(context, @current_user)} :if={@current_user}
patch={~p"/contexts/#{context}/edit"} patch={~p"/contexts/#{context}/edit"}
aria-label={dgettext("actions", "edit %{context_slug}", context_slug: context.slug)} aria-label={dgettext("actions", "edit %{context_slug}", context_slug: context.slug)}
> >
<%= dgettext("actions", "edit") %> {dgettext("actions", "edit")}
</.link> </.link>
<.link <.link
:if={Contexts.owner_or_admin?(context, @current_user)} :if={@current_user}
href="#" href="#"
phx-click="delete" phx-click="delete"
phx-value-id={context.id} phx-value-id={context.id}
data-confirm={dgettext("prompts", "are you sure?")} data-confirm={dgettext("prompts", "are you sure?")}
aria-label={dgettext("actions", "delete %{context_slug}", context_slug: context.slug)} aria-label={dgettext("actions", "delete %{context_slug}", context_slug: context.slug)}
> >
<%= dgettext("actions", "delete") %> {dgettext("actions", "delete")}
</.link> </.link>
</:actions> </:actions>
</.live_component> </.live_component>
<% end %> <% end %>
<.link :if={@current_user} patch={~p"/contexts/new"} class="self-end btn btn-primary"> <.link :if={@current_user} patch={~p"/contexts/new"} class="self-end btn btn-primary">
<%= dgettext("actions", "new context") %> {dgettext("actions", "new context")}
</.link> </.link>
</div> </div>

View File

@ -1,6 +1,6 @@
defmodule MemexWeb.ContextLive.Show do defmodule MemexWeb.ContextLive.Show do
use MemexWeb, :live_view use MemexWeb, :live_view
alias Memex.Contexts alias Memex.{Contexts, Pipelines}
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
@ -21,8 +21,12 @@ defmodule MemexWeb.ContextLive.Show do
socket = socket =
socket socket
|> assign(:page_title, page_title(live_action, context)) |> assign(
|> assign(:context, context) context: context,
page_title: page_title(live_action, context),
context_backlinks: Contexts.backlink("[#{context.slug}]", current_user),
pipeline_backlinks: Pipelines.backlink("[[#{context.slug}]]", current_user)
)
{:noreply, socket} {:noreply, socket}
end end

View File

@ -1,37 +1,54 @@
<div class="mx-auto flex flex-col justify-center items-stretch space-y-4 max-w-3xl"> <div class="flex flex-col justify-center items-stretch mx-auto space-y-4 max-w-3xl">
<h1 class="text-xl"> <h1 class="text-xl">
<%= @context.slug %> {@context.slug}
</h1> </h1>
<div class="flex flex-wrap space-x-1"> <div class="flex flex-wrap space-x-1">
<.link :for={tag <- @context.tags} navigate={~p"/contexts/#{tag}"} class="link"> <.link :for={tag <- @context.tags} navigate={~p"/contexts/#{tag}"} class="link">
<%= tag %> {tag}
</.link> </.link>
</div> </div>
<.context_content context={@context} /> <.context_content context={@context} />
<div
:if={@context_backlinks ++ @pipeline_backlinks != []}
class="flex flex-wrap justify-end items-center self-end"
>
<p>{gettext("Backlinked by:")}</p>
<.link
:for={backlink <- @context_backlinks}
class="m-1 hover:underline"
patch={~p"/context/#{backlink}"}
>
{gettext("[%{slug}]", slug: backlink.slug)}
</.link>
<.link
:for={backlink <- @pipeline_backlinks}
class="m-1 hover:underline"
patch={~p"/pipeline/#{backlink}"}
>
{gettext("[[%{slug}]]", slug: backlink.slug)}
</.link>
</div>
<p class="self-end"> <p class="self-end">
<%= gettext("Visibility: %{visibility}", visibility: @context.visibility) %> {gettext("Visibility: %{visibility}", visibility: @context.visibility)}
</p> </p>
<div class="self-end flex space-x-4"> <div class="flex self-end space-x-4">
<.link <.link :if={@current_user} class="btn btn-primary" patch={~p"/context/#{@context}/edit"}>
:if={Contexts.owner?(@context, @current_user)} {dgettext("actions", "edit")}
class="btn btn-primary"
patch={~p"/context/#{@context}/edit"}
>
<%= dgettext("actions", "edit") %>
</.link> </.link>
<button <button
:if={Contexts.owner_or_admin?(@context, @current_user)} :if={@current_user}
type="button" type="button"
class="btn btn-primary" class="btn btn-primary"
phx-click="delete" phx-click="delete"
data-confirm={dgettext("prompts", "are you sure?")} data-confirm={dgettext("prompts", "are you sure?")}
aria-label={dgettext("actions", "delete %{context_slug}", context_slug: @context.slug)} aria-label={dgettext("actions", "delete %{context_slug}", context_slug: @context.slug)}
> >
<%= dgettext("actions", "delete") %> {dgettext("actions", "delete")}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
<div class="mx-auto flex flex-col justify-center items-stretch space-y-8 text-center max-w-3xl"> <div class="mx-auto flex flex-col justify-center items-stretch space-y-8 text-center max-w-3xl">
<h1 class="title text-primary-400 text-xl text-left"> <h1 class="title text-primary-400 text-xl text-left">
<%= gettext("faq") %> {gettext("faq")}
</h1> </h1>
<hr class="hr" /> <hr class="hr" />
@ -8,13 +8,13 @@
<ul class="flex flex-col justify-center items-stretch space-y-8"> <ul class="flex flex-col justify-center items-stretch space-y-8">
<li class="flex flex-col justify-center items-stretch space-y-2"> <li class="flex flex-col justify-center items-stretch space-y-2">
<b class="whitespace-nowrap text-left"> <b class="whitespace-nowrap text-left">
<%= gettext("what is this?") %> {gettext("what is this?")}
</b> </b>
<p> <p>
<%= gettext( {gettext(
"this is a memex, used to document not just your notes, but also your perspectives and processes." "this is a memex, used to document not just your notes, but also your perspectives and processes."
) %> )}
<%= gettext("some things that this memex is very loosely inspired by:") %> {gettext("some things that this memex is very loosely inspired by:")}
</p> </p>
<ul class="list-disc flex flex-col justify-center items-center space-y-2"> <ul class="list-disc flex flex-col justify-center items-center space-y-2">
@ -25,7 +25,7 @@
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<%= gettext("memex") %> {gettext("memex")}
</.link> </.link>
</li> </li>
<li> <li>
@ -35,7 +35,7 @@
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<%= gettext("zettelkasten") %> {gettext("zettelkasten")}
</.link> </.link>
</li> </li>
<li> <li>
@ -45,7 +45,7 @@
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<%= gettext("org-mode") %> {gettext("org-mode")}
</.link> </.link>
</li> </li>
</ul> </ul>
@ -53,75 +53,75 @@
<li class="flex flex-col justify-center items-stretch space-y-2"> <li class="flex flex-col justify-center items-stretch space-y-2">
<b class="whitespace-nowrap text-left"> <b class="whitespace-nowrap text-left">
<%= gettext("why split up into notes, contexts and pipelines?") %> {gettext("why split up into notes, contexts and pipelines?")}
</b> </b>
<p> <p>
<%= gettext( {gettext(
"i really admired the idea of a zettelkasten, especially with org-mode backlinks, however I felt like my notes would immediately become too messy by just putting everything into a single hierarchy." "i really admired the idea of a zettelkasten, especially with org-mode backlinks, however I felt like my notes would immediately become too messy by just putting everything into a single hierarchy."
) %> )}
<%= gettext( {gettext(
"i wanted to separate between a personal dictionary of concepts and then my thought processes that are built off of my experiences and life lessons. these are notes, and contexts, respectively." "i wanted to separate between a personal dictionary of concepts and then my thought processes that are built off of my experiences and life lessons. these are notes, and contexts, respectively."
) %> )}
<%= gettext( {gettext(
"finally, i wanted to externalize the processes for common situations that use these thought processes at discrete steps. these are pipelines!" "finally, i wanted to externalize the processes for common situations that use these thought processes at discrete steps. these are pipelines!"
) %> )}
</p> </p>
</li> </li>
<li class="flex flex-col justify-center items-stretch space-y-2"> <li class="flex flex-col justify-center items-stretch space-y-2">
<b class="whitespace-nowrap text-left"> <b class="whitespace-nowrap text-left">
<%= gettext("what should my notes be like?") %> {gettext("what should my notes be like?")}
</b> </b>
<p> <p>
<%= gettext( {gettext(
"in my opinion, notes should be written by any of the discrete objects or concepts that are meaningful to you in your life." "in my opinion, notes should be written by any of the discrete objects or concepts that are meaningful to you in your life."
) %> )}
<%= gettext( {gettext(
"spoons? probably not. a particular brand of spoons that you really like? why not :)" "spoons? probably not. a particular brand of spoons that you really like? why not :)"
) %> )}
</p> </p>
</li> </li>
<li class="flex flex-col justify-center items-stretch space-y-2"> <li class="flex flex-col justify-center items-stretch space-y-2">
<b class="whitespace-nowrap text-left"> <b class="whitespace-nowrap text-left">
<%= gettext("what should my contexts be like?") %> {gettext("what should my contexts be like?")}
</b> </b>
<p> <p>
<%= gettext("in my opinion, contexts should be like single-topic blog posts.") %> {gettext("in my opinion, contexts should be like single-topic blog posts.")}
<%= gettext( {gettext(
"for instance, a good context could be what makes some physical designs spark joy for you, and in that context you could backlink to the spoon note as an example of how it fits nicely into your hand." "for instance, a good context could be what makes some physical designs spark joy for you, and in that context you could link to the spoon note as an example of how it fits nicely into your hand."
) %> )}
</p> </p>
</li> </li>
<li class="flex flex-col justify-center items-stretch space-y-2"> <li class="flex flex-col justify-center items-stretch space-y-2">
<b class="whitespace-nowrap text-left"> <b class="whitespace-nowrap text-left">
<%= gettext("what should my pipelines be like?") %> {gettext("what should my pipelines be like?")}
</b> </b>
<p> <p>
<%= gettext( {gettext(
"in my opinion, pipelines should be pretty lightweight, and just backlink to contexts to provide most of the heavy lifting." "in my opinion, pipelines should be pretty lightweight, and just link to contexts to provide most of the heavy lifting."
) %> )}
<%= gettext( {gettext(
"for instance, a pipeline for buying an object could have a step where you consider how much it sparks joy, and it could backlink to the physical designs context, maybe with some notes about how it applies in this case." "for instance, a pipeline for buying an object could have a step where you consider how much it sparks joy, and it could link to the physical designs context, maybe with some notes about how it applies in this case."
) %> )}
</p> </p>
</li> </li>
<li class="flex flex-col justify-center items-stretch space-y-2"> <li class="flex flex-col justify-center items-stretch space-y-2">
<b class="whitespace-nowrap text-left"> <b class="whitespace-nowrap text-left">
<%= gettext("how many people should i invite?") %> {gettext("how many people should i invite?")}
</b> </b>
<p> <p>
<%= gettext( {gettext(
"while memEx fully supports multiple users, each memEx instance should be treated as a single cohesive and collaborative document." "while memEx fully supports multiple users, each memEx instance should be treated as a single cohesive and collaborative document."
) %> )}
<%= gettext( {gettext(
"note, context and pipeline slugs must be unique, and you are free to backlink to notes not written by you." "note, context and pipeline slugs must be unique, and you are free to link to notes not written by you."
) %> )}
<%= gettext( {gettext(
"so, i'd recommend inviting anyone you'd like to work on your collective memEx. however, when in doubt, hopefully setting up a new instance is easy enough. if it isn't, then feel free to let me know :)" "so, i'd recommend inviting anyone you'd like to work on your collective memEx. however, when in doubt, hopefully setting up a new instance is easy enough. if it isn't, then feel free to let me know :)"
) %> )}
</p> </p>
</li> </li>
</ul> </ul>

View File

@ -11,6 +11,6 @@ defmodule MemexWeb.HomeLive do
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
admins = Accounts.list_users_by_role(:admin) admins = Accounts.list_users_by_role(:admin)
{:ok, socket |> assign(page_title: gettext("home"), admins: admins, version: @version)} {:ok, socket |> assign(admins: admins, page_title: gettext("home"), version: @version)}
end end
end end

View File

@ -1,39 +1,39 @@
<div class="mx-auto flex flex-col justify-center items-stretch space-y-4 max-w-lg"> <div class="flex flex-col justify-center items-stretch mx-auto space-y-4 max-w-lg">
<h1 class="title text-primary-400 text-xl"> <h1 class="text-xl title text-primary-400">
<%= gettext("memEx") %> {gettext("memEx")}
</h1> </h1>
<ul class="flex flex-col space-y-4"> <ul class="flex flex-col space-y-4">
<li class="flex flex-col justify-center items-center space-y-2"> <li class="flex flex-col justify-center items-center space-y-2">
<b class="whitespace-nowrap"> <b class="whitespace-nowrap">
<%= gettext("notes:") %> {gettext("notes:")}
</b> </b>
<p> <p>
<%= gettext("document notes about individual items or concepts") %> {gettext("document notes about individual items or concepts")}
</p> </p>
</li> </li>
<li class="flex flex-col justify-center items-center space-y-2"> <li class="flex flex-col justify-center items-center space-y-2">
<b class="whitespace-nowrap"> <b class="whitespace-nowrap">
<%= gettext("contexts:") %> {gettext("contexts:")}
</b> </b>
<p> <p>
<%= gettext("provide context around a single topic and hotlink to your notes") %> {gettext("provide context around a single topic and hotlink to your notes")}
</p> </p>
</li> </li>
<li class="flex flex-col justify-center items-center space-y-2"> <li class="flex flex-col justify-center items-center space-y-2">
<b class="whitespace-nowrap"> <b class="whitespace-nowrap">
<%= gettext("pipelines:") %> {gettext("pipelines:")}
</b> </b>
<p> <p>
<%= gettext("document your processes, attaching contexts to each step") %> {gettext("document your processes, attaching contexts to each step")}
</p> </p>
</li> </li>
<li class="flex flex-col justify-center items-center text-right space-y-2"> <li class="flex flex-col justify-center items-center space-y-2 text-right">
<.link navigate={~p"/faq"} class="btn btn-primary"> <.link navigate={~p"/faq"} class="btn btn-primary">
<%= gettext("read more on how to use memEx") %> {gettext("read more on how to use memEx")}
</.link> </.link>
</li> </li>
</ul> </ul>
@ -41,34 +41,43 @@
<hr class="hr" /> <hr class="hr" />
<ul class="flex flex-col space-y-4"> <ul class="flex flex-col space-y-4">
<h2 class="title text-primary-400 text-lg"> <h2 class="text-lg title text-primary-400">
<%= gettext("features") %> {gettext("features")}
</h2> </h2>
<li class="flex flex-col justify-center items-center space-y-2"> <li class="flex flex-col justify-center items-center space-y-2">
<b class="whitespace-nowrap"> <b class="whitespace-nowrap">
<%= gettext("multi-user:") %> {gettext("multi-user:")}
</b> </b>
<p> <p>
<%= gettext("built with sharing and collaboration in mind") %> {gettext("built with sharing and collaboration in mind")}
</p> </p>
</li> </li>
<li class="flex flex-col justify-center items-center space-y-2"> <li class="flex flex-col justify-center items-center space-y-2">
<b class="whitespace-nowrap"> <b class="whitespace-nowrap">
<%= gettext("privacy:") %> {gettext("privacy:")}
</b> </b>
<p> <p>
<%= gettext("privacy controls on a per-note, context or pipeline basis") %> {gettext("privacy controls on a per-note, context or pipeline basis")}
</p> </p>
</li> </li>
<li class="flex flex-col justify-center items-center space-y-2"> <li class="flex flex-col justify-center items-center space-y-2">
<b class="whitespace-nowrap"> <b class="whitespace-nowrap">
<%= gettext("convenient:") %> {gettext("convenient:")}
</b> </b>
<p> <p>
<%= gettext("accessible from any internet-capable device") %> {gettext("accessible from any internet-capable device")}
</p>
</li>
<li class="flex flex-col justify-center items-center space-y-2">
<b class="whitespace-nowrap">
{gettext("backlinks:")}
</b>
<p>
{gettext("view referencing items from the referenced item")}
</p> </p>
</li> </li>
</ul> </ul>
@ -76,46 +85,46 @@
<hr class="hr" /> <hr class="hr" />
<ul class="flex flex-col justify-center space-y-4"> <ul class="flex flex-col justify-center space-y-4">
<h2 class="title text-primary-400 text-lg"> <h2 class="text-lg title text-primary-400">
<%= gettext("instance information") %> {gettext("instance information")}
</h2> </h2>
<li class="flex flex-col justify-center items-center space-y-2"> <li class="flex flex-col justify-center items-center space-y-2">
<b> <b>
<%= gettext("admins:") %> {gettext("admins:")}
</b> </b>
<p class="flex flex-col justify-center items-center space-y-2"> <p class="flex flex-col justify-center items-center space-y-2">
<%= if @admins |> Enum.empty?() do %> <%= if @admins |> Enum.empty?() do %>
<.link href={~p"/users/register"} class="link"> <.link href={~p"/users/register"} class="link">
<%= dgettext("prompts", "register to setup memEx") %> {dgettext("prompts", "register to setup memEx")}
</.link> </.link>
<% else %> <% else %>
<.link :for={%{email: email} <- @admins} class="link" href={"mailto:#{email}"}> <.link :for={%{email: email} <- @admins} class="link" href={"mailto:#{email}"}>
<%= email %> {email}
</.link> </.link>
<% end %> <% end %>
</p> </p>
</li> </li>
<li class="flex flex-col justify-center items-center space-y-2"> <li class="flex flex-col justify-center items-center space-y-2">
<b><%= gettext("registration:") %></b> <b>{gettext("registration:")}</b>
<p> <p>
<%= case Accounts.registration_mode() do {case Accounts.registration_mode() do
:public -> gettext("public signups") :public -> gettext("public signups")
:invite_only -> gettext("invite only") :invite_only -> gettext("invite only")
end %> end}
</p> </p>
</li> </li>
<li class="flex flex-col justify-center items-center space-y-2"> <li class="flex flex-col justify-center items-center space-y-2">
<b><%= gettext("version:") %></b> <b>{gettext("version:")}</b>
<.link <.link
href="https://gitea.bubbletea.dev/shibao/memEx/src/branch/stable/changelog.md" href="https://gitea.bubbletea.dev/shibao/memEx/src/branch/stable/changelog.md"
class="flex flex-row justify-center items-center space-x-2 link" class="flex flex-row justify-center items-center space-x-2 link"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<p><%= @version %></p> <p>{@version}</p>
<i class="fas fa-md fa-info-circle"></i> <i class="fas fa-md fa-info-circle"></i>
</.link> </.link>
</li> </li>
@ -124,8 +133,8 @@
<hr class="hr" /> <hr class="hr" />
<ul class="flex flex-col space-y-2"> <ul class="flex flex-col space-y-2">
<h2 class="title text-primary-400 text-lg"> <h2 class="text-lg title text-primary-400">
<%= gettext("get involved") %> {gettext("get involved")}
</h2> </h2>
<li class="flex flex-col justify-center items-center space-y-2"> <li class="flex flex-col justify-center items-center space-y-2">
@ -135,7 +144,7 @@
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<p><%= gettext("view the source code") %></p> <p>{gettext("view the source code")}</p>
<i class="fas fa-md fa-code"></i> <i class="fas fa-md fa-code"></i>
</.link> </.link>
</li> </li>
@ -146,7 +155,7 @@
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<p><%= gettext("help translate") %></p> <p>{gettext("help translate")}</p>
<i class="fas fa-md fa-language"></i> <i class="fas fa-md fa-language"></i>
</.link> </.link>
</li> </li>
@ -157,7 +166,7 @@
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<p><%= gettext("report bugs or request features") %></p> <p>{gettext("report bugs or request features")}</p>
<i class="fas fa-md fa-spider"></i> <i class="fas fa-md fa-spider"></i>
</.link> </.link>
</li> </li>

View File

@ -82,7 +82,7 @@ defmodule MemexWeb.InviteLive.FormComponent do
socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) socket |> put_flash(:info, prompt) |> push_navigate(to: return_to)
{:error, %Changeset{} = changeset} -> {:error, %Changeset{} = changeset} ->
socket |> assign(changeset: changeset) socket |> assign(:changeset, changeset)
end end
{:noreply, socket} {:noreply, socket}

View File

@ -1,52 +1,53 @@
<div> <div>
<h2 class="mb-8 text-center title text-xl text-primary-400"> <h2 class="mb-8 text-xl text-center title text-primary-400">
<%= @title %> {@title}
</h2> </h2>
<.form <.form
:let={f} :let={f}
for={@changeset} for={@changeset}
id="invite-form" id="invite-form"
class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" class="flex flex-col justify-center items-center space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4"
phx-target={@myself} phx-target={@myself}
phx-change="validate" phx-change="validate"
phx-submit="save" phx-submit="save"
phx-hook="CtrlEnter" phx-hook="CtrlEnter"
> >
<div <div
:if={@changeset.action && not @changeset.valid?()} :if={@changeset.action && not @changeset.valid?}
class="invalid-feedback col-span-3 text-center" class="col-span-3 text-center invalid-feedback"
> >
<%= changeset_errors(@changeset) %> {changeset_errors(@changeset)}
</div> </div>
<%= label(f, :name, gettext("name"), {label(f, :name, gettext("name"),
class: "title text-lg text-primary-400", class: "title text-lg text-primary-400",
phx_debounce: 300 phx_debounce: 300
) %> )}
<%= text_input(f, :name, {text_input(f, :name,
class: "input input-primary col-span-2", class: "input input-primary col-span-2",
phx_debounce: 300 phx_debounce: 300,
) %> required: true
<%= error_tag(f, :name, "col-span-3") %> )}
{error_tag(f, :name, "col-span-3")}
<%= label(f, :uses_left, gettext("uses left"), {label(f, :uses_left, gettext("uses left"),
class: "title text-lg text-primary-400", class: "title text-lg text-primary-400",
phx_debounce: 300 phx_debounce: 300
) %> )}
<%= number_input(f, :uses_left, {number_input(f, :uses_left,
min: 0, min: 0,
class: "input input-primary col-span-2", class: "input input-primary col-span-2",
phx_debounce: 300 phx_debounce: 300
) %> )}
<%= error_tag(f, :uses_left, "col-span-3") %> {error_tag(f, :uses_left, "col-span-3")}
<span class="col-span-3 text-primary-500 italic text-center"> <span class="col-span-3 italic text-center text-primary-500">
<%= gettext(~s/leave "uses left" blank to make invite unlimited/) %> {gettext(~s/leave "uses left" blank to make invite unlimited/)}
</span> </span>
<%= submit(dgettext("actions", "save"), {submit(dgettext("actions", "save"),
class: "mx-auto btn btn-primary col-span-3", class: "mx-auto btn btn-primary col-span-3",
phx_disable_with: dgettext("prompts", "saving...") phx_disable_with: dgettext("prompts", "saving...")
) %> )}
</.form> </.form>
</div> </div>

View File

@ -20,15 +20,15 @@ defmodule MemexWeb.InviteLive.Index do
defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"id" => id}) do defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"id" => id}) do
socket socket
|> assign(page_title: gettext("edit invite"), invite: Invites.get_invite!(id, current_user)) |> assign(invite: Invites.get_invite!(id, current_user), page_title: gettext("edit invite"))
end end
defp apply_action(socket, :new, _params) do defp apply_action(socket, :new, _params) do
socket |> assign(page_title: gettext("new invite"), invite: %Invite{}) socket |> assign(invite: %Invite{}, page_title: gettext("new invite"))
end end
defp apply_action(socket, :index, _params) do defp apply_action(socket, :index, _params) do
socket |> assign(page_title: gettext("invites"), invite: nil) socket |> assign(invite: nil, page_title: gettext("invites"))
end end
@impl true @impl true
@ -93,7 +93,7 @@ defmodule MemexWeb.InviteLive.Index do
%{"id" => id}, %{"id" => id},
%{assigns: %{current_user: current_user}} = socket %{assigns: %{current_user: current_user}} = socket
) do ) do
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) now = DateTime.utc_now()
socket = socket =
Invites.get_invite!(id, current_user) Invites.get_invite!(id, current_user)
@ -138,6 +138,6 @@ defmodule MemexWeb.InviteLive.Index do
use_counts = invites |> Invites.get_use_counts(current_user) use_counts = invites |> Invites.get_use_counts(current_user)
users = all_users |> Map.get(:user, []) users = all_users |> Map.get(:user, [])
socket |> assign(invites: invites, use_counts: use_counts, admins: admins, users: users) socket |> assign(admins: admins, invites: invites, use_counts: use_counts, users: users)
end end
end end

View File

@ -1,15 +1,15 @@
<div class="mx-auto flex flex-col justify-center items-stretch space-y-4 max-w-3xl"> <div class="flex flex-col justify-center items-stretch mx-auto space-y-4 max-w-3xl">
<h1 class="title text-xl title-primary-500"> <h1 class="text-xl title title-primary-500">
<%= gettext("invites") %> {gettext("invites")}
</h1> </h1>
<%= if @invites |> Enum.empty?() do %> <%= if @invites |> Enum.empty?() do %>
<h1 class="title text-xl text-primary-400"> <h1 class="text-xl text-center title text-primary-400">
<%= gettext("no invites 😔") %> {gettext("no invites 😔")}
</h1> </h1>
<.link patch={~p"/invites"} class="btn btn-primary"> <.link patch={~p"/invites/new"} class="ml-auto btn btn-primary">
<%= dgettext("actions", "invite someone new!") %> {dgettext("actions", "new invite")}
</.link> </.link>
<% end %> <% end %>
@ -30,7 +30,7 @@
dgettext("actions", "copy invite link for %{invite_name}", invite_name: invite.name) dgettext("actions", "copy invite link for %{invite_name}", invite_name: invite.name)
} }
> >
<%= dgettext("actions", "copy") %> {dgettext("actions", "copy")}
</button> </button>
</form> </form>
</:code_actions> </:code_actions>
@ -67,11 +67,11 @@
phx-click={if invite.disabled_at, do: "enable_invite", else: "disable_invite"} phx-click={if invite.disabled_at, do: "enable_invite", else: "disable_invite"}
phx-value-id={invite.id} phx-value-id={invite.id}
> >
<%= if invite.disabled_at, do: gettext("enable"), else: gettext("disable") %> {if invite.disabled_at, do: gettext("enable"), else: gettext("disable")}
</.link> </.link>
<.link <.link
:if={invite.disabled_at |> is_nil() and not (invite.uses_left |> is_nil())} :if={!invite.disabled_at and !!invite.uses_left}
href="#" href="#"
class="btn btn-secondary" class="btn btn-secondary"
phx-click="set_unlimited" phx-click="set_unlimited"
@ -82,20 +82,20 @@
) )
} }
> >
<%= gettext("set unlimited") %> {gettext("set unlimited")}
</.link> </.link>
</.invite_card> </.invite_card>
<.link :if={@invites != []} patch={~p"/invites/new"} class="btn btn-primary ml-auto"> <.link :if={@invites != []} patch={~p"/invites/new"} class="ml-auto btn btn-primary">
<%= dgettext("actions", "create invite") %> {dgettext("actions", "create invite")}
</.link> </.link>
</div> </div>
<%= unless @admins |> Enum.empty?() do %> <%= unless @admins |> Enum.empty?() do %>
<hr class="hr" /> <hr class="hr" />
<h1 class="title text-xl text-primary-400"> <h1 class="text-xl title text-primary-400">
<%= gettext("admins") %> {gettext("admins")}
</h1> </h1>
<div class="flex flex-col justify-center items-stretch space-y-4"> <div class="flex flex-col justify-center items-stretch space-y-4">
@ -122,8 +122,8 @@
<%= unless @users |> Enum.empty?() do %> <%= unless @users |> Enum.empty?() do %>
<hr class="hr" /> <hr class="hr" />
<h1 class="title text-xl text-primary-400"> <h1 class="text-xl title text-primary-400">
<%= gettext("users") %> {gettext("users")}
</h1> </h1>
<div class="flex flex-col justify-center items-stretch space-y-4"> <div class="flex flex-col justify-center items-stretch space-y-4">

View File

@ -68,7 +68,7 @@ defmodule MemexWeb.NoteLive.FormComponent do
|> push_navigate(to: return_to) |> push_navigate(to: return_to)
{:error, %Changeset{} = changeset} -> {:error, %Changeset{} = changeset} ->
assign(socket, changeset: changeset) assign(socket, :changeset, changeset)
end end
{:noreply, socket} {:noreply, socket}

View File

@ -1,4 +1,4 @@
<div class="h-full flex flex-col justify-start items-stretch space-y-4"> <div class="flex flex-col justify-start items-stretch space-y-4 h-full">
<.form <.form
:let={f} :let={f}
for={@changeset} for={@changeset}
@ -9,48 +9,49 @@
phx-hook="CtrlEnter" phx-hook="CtrlEnter"
class="flex flex-col justify-start items-stretch space-y-4" class="flex flex-col justify-start items-stretch space-y-4"
> >
<%= text_input(f, :slug, {text_input(f, :slug,
aria_label: gettext("slug"), aria_label: gettext("slug"),
class: "input input-primary", class: "input input-primary",
phx_debounce: 300, phx_debounce: 300,
phx_hook: "SanitizeTitles", phx_hook: "SanitizeTitles",
placeholder: gettext("slug") placeholder: gettext("slug"),
) %> required: true
<%= error_tag(f, :slug) %> )}
{error_tag(f, :slug)}
<%= textarea(f, :content, {textarea(f, :content,
id: "note-form-content", id: "note-form-content",
class: "input input-primary h-64 min-h-64", class: "input input-primary h-64 min-h-64",
phx_update: "ignore", phx_update: "ignore",
placeholder: gettext("use [note-slug] to link to a note"), placeholder: gettext("use [note-slug] to link to a note"),
aria_label: gettext("use [note-slug] to link to a note"), aria_label: gettext("use [note-slug] to link to a note"),
phx_debounce: 300 phx_debounce: 300
) %> )}
<%= error_tag(f, :content) %> {error_tag(f, :content)}
<%= text_input(f, :tags_string, {text_input(f, :tags_string,
aria_label: gettext("tag1,tag2"), aria_label: gettext("tag1,tag2"),
class: "input input-primary", class: "input input-primary",
id: "tags-input", id: "tags-input",
phx_debounce: 300, phx_debounce: 300,
phx_hook: "SanitizeTags", phx_hook: "SanitizeTags",
placeholder: gettext("tag1,tag2") placeholder: gettext("tag1,tag2")
) %> )}
<%= error_tag(f, :tags_string) %> {error_tag(f, :tags_string)}
<div class="flex justify-center items-stretch space-x-4"> <div class="flex justify-center items-stretch space-x-4">
<%= select(f, :visibility, Ecto.Enum.values(Memex.Notes.Note, :visibility), {select(f, :visibility, Ecto.Enum.values(Memex.Notes.Note, :visibility),
class: "grow input input-primary", class: "grow input input-primary",
prompt: gettext("select privacy"), prompt: gettext("select privacy"),
aria_label: gettext("select privacy"), aria_label: gettext("select privacy"),
phx_debounce: 300 phx_debounce: 300
) %> )}
<%= submit(dgettext("actions", "save"), {submit(dgettext("actions", "save"),
phx_disable_with: gettext("saving..."), phx_disable_with: gettext("saving..."),
class: "mx-auto btn btn-primary" class: "mx-auto btn btn-primary"
) %> )}
</div> </div>
<%= error_tag(f, :visibility) %> {error_tag(f, :visibility)}
</.form> </.form>
</div> </div>

View File

@ -4,11 +4,11 @@ defmodule MemexWeb.NoteLive.Index do
@impl true @impl true
def mount(%{"search" => search}, _session, socket) do def mount(%{"search" => search}, _session, socket) do
{:ok, socket |> assign(search: search) |> display_notes()} {:ok, socket |> assign(:search, search) |> display_notes()}
end end
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
{:ok, socket |> assign(search: nil) |> display_notes()} {:ok, socket |> assign(:search, nil) |> display_notes()}
end end
@impl true @impl true
@ -20,29 +20,37 @@ defmodule MemexWeb.NoteLive.Index do
%{slug: slug} = note = Notes.get_note_by_slug(slug, current_user) %{slug: slug} = note = Notes.get_note_by_slug(slug, current_user)
socket socket
|> assign(page_title: gettext("edit %{slug}", slug: slug)) |> assign(
|> assign(note: note) note: note,
page_title: gettext("edit %{slug}", slug: slug)
)
end end
defp apply_action(%{assigns: %{current_user: %{id: current_user_id}}} = socket, :new, _params) do defp apply_action(%{assigns: %{current_user: %{id: current_user_id}}} = socket, :new, _params) do
socket socket
|> assign(page_title: gettext("new note")) |> assign(
|> assign(note: %Note{visibility: :private, user_id: current_user_id}) note: %Note{visibility: :private, user_id: current_user_id},
page_title: gettext("new note")
)
end end
defp apply_action(socket, :index, _params) do defp apply_action(socket, :index, _params) do
socket socket
|> assign(page_title: gettext("notes")) |> assign(
|> assign(search: nil) note: nil,
|> assign(note: nil) page_title: gettext("notes"),
search: nil
)
|> display_notes() |> display_notes()
end end
defp apply_action(socket, :search, %{"search" => search}) do defp apply_action(socket, :search, %{"search" => search}) do
socket socket
|> assign(page_title: gettext("notes")) |> assign(
|> assign(search: search) note: nil,
|> assign(note: nil) page_title: gettext("notes"),
search: search
)
|> display_notes() |> display_notes()
end end
@ -53,7 +61,7 @@ defmodule MemexWeb.NoteLive.Index do
socket = socket =
socket socket
|> assign(notes: Notes.list_notes(current_user)) |> assign(:notes, Notes.list_notes(current_user))
|> put_flash(:info, gettext("%{slug} deleted", slug: slug)) |> put_flash(:info, gettext("%{slug} deleted", slug: slug))
{:noreply, socket} {:noreply, socket}
@ -67,12 +75,11 @@ defmodule MemexWeb.NoteLive.Index do
{:noreply, socket |> push_patch(to: ~p"/notes/#{search_term}")} {:noreply, socket |> push_patch(to: ~p"/notes/#{search_term}")}
end end
defp display_notes(%{assigns: %{current_user: current_user, search: search}} = socket) defp display_notes(%{assigns: %{current_user: %{} = current_user, search: search}} = socket) do
when not (current_user |> is_nil()) do socket |> assign(:notes, Notes.list_notes(search, current_user))
socket |> assign(notes: Notes.list_notes(search, current_user))
end end
defp display_notes(%{assigns: %{search: search}} = socket) do defp display_notes(%{assigns: %{search: search}} = socket) do
socket |> assign(notes: Notes.list_public_notes(search)) socket |> assign(:notes, Notes.list_public_notes(search))
end end
end end

View File

@ -1,6 +1,6 @@
<div class="mx-auto flex flex-col justify-center items-start space-y-4 max-w-3xl"> <div class="flex flex-col justify-center items-start mx-auto space-y-4 max-w-3xl">
<h1 class="text-xl"> <h1 class="text-xl">
<%= gettext("notes") %> {gettext("notes")}
</h1> </h1>
<.form <.form
@ -9,20 +9,20 @@
as={:search} as={:search}
phx-change="search" phx-change="search"
phx-submit="search" phx-submit="search"
class="self-stretch flex flex-col items-stretch" class="flex flex-col items-stretch self-stretch"
> >
<%= text_input(f, :search_term, {text_input(f, :search_term,
class: "input input-primary", class: "input input-primary",
value: @search, value: @search,
role: "search", role: "search",
phx_debounce: 300, phx_debounce: 300,
placeholder: gettext("search") placeholder: gettext("search")
) %> )}
</.form> </.form>
<%= if @notes |> Enum.empty?() do %> <%= if @notes |> Enum.empty?() do %>
<h1 class="self-center text-primary-500"> <h1 class="self-center text-primary-500">
<%= gettext("no notes found") %> {gettext("no notes found")}
</h1> </h1>
<% else %> <% else %>
<.live_component <.live_component
@ -33,28 +33,28 @@
> >
<:actions :let={note}> <:actions :let={note}>
<.link <.link
:if={Notes.owner?(note, @current_user)} :if={@current_user}
patch={~p"/notes/#{note}/edit"} patch={~p"/notes/#{note}/edit"}
aria-label={dgettext("actions", "edit %{note_slug}", note_slug: note.slug)} aria-label={dgettext("actions", "edit %{note_slug}", note_slug: note.slug)}
> >
<%= dgettext("actions", "edit") %> {dgettext("actions", "edit")}
</.link> </.link>
<.link <.link
:if={Notes.owner_or_admin?(note, @current_user)} :if={@current_user}
href="#" href="#"
phx-click="delete" phx-click="delete"
phx-value-id={note.id} phx-value-id={note.id}
data-confirm={dgettext("prompts", "are you sure?")} data-confirm={dgettext("prompts", "are you sure?")}
aria-label={dgettext("actions", "delete %{note_slug}", note_slug: note.slug)} aria-label={dgettext("actions", "delete %{note_slug}", note_slug: note.slug)}
> >
<%= dgettext("actions", "delete") %> {dgettext("actions", "delete")}
</.link> </.link>
</:actions> </:actions>
</.live_component> </.live_component>
<% end %> <% end %>
<.link :if={@current_user} patch={~p"/notes/new"} class="self-end btn btn-primary"> <.link :if={@current_user} patch={~p"/notes/new"} class="self-end btn btn-primary">
<%= dgettext("actions", "new note") %> {dgettext("actions", "new note")}
</.link> </.link>
</div> </div>

View File

@ -1,6 +1,6 @@
defmodule MemexWeb.NoteLive.Show do defmodule MemexWeb.NoteLive.Show do
use MemexWeb, :live_view use MemexWeb, :live_view
alias Memex.Notes alias Memex.{Contexts, Notes, Pipelines}
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
@ -21,8 +21,13 @@ defmodule MemexWeb.NoteLive.Show do
socket = socket =
socket socket
|> assign(:page_title, page_title(live_action, note)) |> assign(
|> assign(:note, note) context_backlinks: Contexts.backlink("[[#{note.slug}]]", current_user),
note_backlinks: Notes.backlink("[#{note.slug}]", current_user),
note: note,
page_title: page_title(live_action, note),
pipeline_backlinks: Pipelines.backlink("[[[#{note.slug}]]]", current_user)
)
{:noreply, socket} {:noreply, socket}
end end

View File

@ -1,37 +1,61 @@
<div class="mx-auto flex flex-col justify-center items-stretch space-y-4 max-w-3xl"> <div class="flex flex-col justify-center items-stretch mx-auto space-y-4 max-w-3xl">
<h1 class="text-xl"> <h1 class="text-xl">
<%= @note.slug %> {@note.slug}
</h1> </h1>
<div class="flex flex-wrap space-x-1"> <div class="flex flex-wrap space-x-1">
<.link :for={tag <- @note.tags} navigate={~p"/notes/#{tag}"} class="link"> <.link :for={tag <- @note.tags} navigate={~p"/notes/#{tag}"} class="link">
<%= tag %> {tag}
</.link> </.link>
</div> </div>
<.note_content note={@note} /> <.note_content note={@note} />
<div
:if={@note_backlinks ++ @context_backlinks ++ @pipeline_backlinks != []}
class="flex flex-wrap justify-end items-center self-end"
>
<p>{gettext("Backlinked by:")}</p>
<.link
:for={backlink <- @note_backlinks}
class="m-1 hover:underline"
patch={~p"/note/#{backlink}"}
>
{gettext("[%{slug}]", slug: backlink.slug)}
</.link>
<.link
:for={backlink <- @context_backlinks}
class="m-1 hover:underline"
patch={~p"/context/#{backlink}"}
>
{gettext("[[%{slug}]]", slug: backlink.slug)}
</.link>
<.link
:for={backlink <- @pipeline_backlinks}
class="m-1 hover:underline"
patch={~p"/pipeline/#{backlink}"}
>
{gettext("[[[%{slug}]]]", slug: backlink.slug)}
</.link>
</div>
<p class="self-end"> <p class="self-end">
<%= gettext("Visibility: %{visibility}", visibility: @note.visibility) %> {gettext("Visibility: %{visibility}", visibility: @note.visibility)}
</p> </p>
<div class="self-end flex space-x-4"> <div class="flex self-end space-x-4">
<.link <.link :if={@current_user} class="btn btn-primary" patch={~p"/note/#{@note}/edit"}>
:if={Notes.owner?(@note, @current_user)} {dgettext("actions", "edit")}
class="btn btn-primary"
patch={~p"/note/#{@note}/edit"}
>
<%= dgettext("actions", "edit") %>
</.link> </.link>
<button <button
:if={Notes.owner_or_admin?(@note, @current_user)} :if={@current_user}
type="button" type="button"
class="btn btn-primary" class="btn btn-primary"
phx-click="delete" phx-click="delete"
data-confirm={dgettext("prompts", "are you sure?")} data-confirm={dgettext("prompts", "are you sure?")}
aria-label={dgettext("actions", "delete %{note_slug}", note_slug: @note.slug)} aria-label={dgettext("actions", "delete %{note_slug}", note_slug: @note.slug)}
> >
<%= dgettext("actions", "delete") %> {dgettext("actions", "delete")}
</button> </button>
</div> </div>
</div> </div>

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