Compare commits
45 commits
Author | SHA1 | Date | |
---|---|---|---|
|
d09bcf646e | ||
|
3ebcf2db4d | ||
|
750429fc62 | ||
|
a8dbdadb90 | ||
|
cdccbb1919 | ||
|
7235ad0d00 | ||
|
cc57bddc24 | ||
|
80743a50f7 | ||
|
7aa77b1604 | ||
|
03256685c0 | ||
|
3f042d1d99 | ||
|
818a7e4a31 | ||
|
1d52d9c71f | ||
|
9e0ed3b640 | ||
|
40f661ff58 | ||
|
aba6a76073 | ||
|
f779c5fd82 | ||
|
088baff66a | ||
|
e28cdc803c | ||
|
99ebbe7e84 | ||
|
b2372f4d34 | ||
|
5f73827373 | ||
|
107af78925 | ||
|
5a00fdf843 | ||
|
45c91eb3bf | ||
|
2d81bf20ce | ||
|
8040c38edf | ||
|
a38d13a16a | ||
|
fc799a5c0e | ||
|
84681401ec | ||
|
e185b154ab | ||
|
66bb26380a | ||
|
eadc784bc3 | ||
|
8f34d6a069 | ||
|
3841ee5ed7 | ||
|
0a5d70abb6 | ||
|
63c28511e3 | ||
|
63c73d5f08 | ||
|
9e8885386a | ||
|
560c2c1c8c | ||
|
1a394277d2 | ||
|
c6de8dce34 | ||
|
c80f439840 | ||
|
94f33b062b | ||
|
c8edc05b27 |
|
@ -1,11 +1,45 @@
|
|||
LICENSE.md
|
||||
README.md
|
||||
node_modules
|
||||
dist
|
||||
.github
|
||||
# This file excludes paths from the Docker build context.
|
||||
#
|
||||
# By default, Docker's build context includes all files (and folders) in the
|
||||
# current directory. Even if a file isn't copied into the container it is still sent to
|
||||
# the Docker daemon.
|
||||
#
|
||||
# There are multiple reasons to exclude files from the build context:
|
||||
#
|
||||
# 1. Prevent nested folders from being copied into the container (ex: exclude
|
||||
# /assets/node_modules when copying /assets)
|
||||
# 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc)
|
||||
# 3. Avoid sending files containing sensitive information
|
||||
#
|
||||
# More information on using .dockerignore is available here:
|
||||
# https://docs.docker.com/engine/reference/builder/#dockerignore-file
|
||||
|
||||
.dockerignore
|
||||
|
||||
# Ignore git, but keep git HEAD and refs to access current commit hash if needed:
|
||||
#
|
||||
# $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat
|
||||
# d0b8727759e1e0e7aa3d41707d12376e373d5ecc
|
||||
.git
|
||||
.editorconfig
|
||||
.gitignore
|
||||
convert-images.py
|
||||
public-src
|
||||
flake.*
|
||||
!.git/HEAD
|
||||
!.git/refs
|
||||
|
||||
# Common development/test artifacts
|
||||
/cover/
|
||||
/doc/
|
||||
/test/
|
||||
/tmp/
|
||||
.elixir_ls
|
||||
|
||||
# Mix artifacts
|
||||
/_build/
|
||||
/deps/
|
||||
*.ez
|
||||
|
||||
# Generated on crash by the VM
|
||||
erl_crash.dump
|
||||
|
||||
# Static artifacts - These should be fetched and built inside the Docker image
|
||||
/assets/node_modules/
|
||||
/priv/static/assets/
|
||||
/priv/static/cache_manifest.json
|
||||
|
|
6
.formatter.exs
Normal file
|
@ -0,0 +1,6 @@
|
|||
[
|
||||
import_deps: [:ecto, :ecto_sql, :phoenix],
|
||||
subdirectories: ["priv/*/migrations"],
|
||||
plugins: [Phoenix.LiveView.HTMLFormatter],
|
||||
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
|
||||
]
|
48
.gitignore
vendored
|
@ -1,21 +1,37 @@
|
|||
# build output
|
||||
dist/
|
||||
# The directory Mix will write compiled artifacts to.
|
||||
/_build/
|
||||
|
||||
# generated types
|
||||
.astro/
|
||||
# If you run "mix test --cover", coverage assets end up here.
|
||||
/cover/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
# The directory Mix downloads your dependencies sources to.
|
||||
/deps/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
# Where 3rd-party dependencies like ExDoc output generated docs.
|
||||
/doc/
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
# Ignore .fetch files in case you like to edit your project deps locally.
|
||||
/.fetch
|
||||
|
||||
# If the VM crashes, it generates a dump, let's ignore it too.
|
||||
erl_crash.dump
|
||||
|
||||
# Also ignore archive artifacts (built via "mix archive.build").
|
||||
*.ez
|
||||
|
||||
# Temporary files, for example, from tests.
|
||||
/tmp/
|
||||
|
||||
# Ignore package tarball (built via "mix hex.build").
|
||||
comfycamp-*.tar
|
||||
|
||||
# Ignore assets that are produced by build tools.
|
||||
/priv/static/assets/
|
||||
|
||||
# Ignore digested assets cache.
|
||||
/priv/static/cache_manifest.json
|
||||
|
||||
# In case you use Node.js/npm, you want to ignore these.
|
||||
npm-debug.log
|
||||
/assets/node_modules/
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
|
99
Dockerfile
|
@ -1,14 +1,97 @@
|
|||
FROM node:20-alpine AS builder
|
||||
# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian
|
||||
# instead of Alpine to avoid DNS resolution issues in production.
|
||||
#
|
||||
# https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu
|
||||
# https://hub.docker.com/_/ubuntu?tab=tags
|
||||
#
|
||||
# This file is based on these images:
|
||||
#
|
||||
# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
|
||||
# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20240701-slim - for the release image
|
||||
# - https://pkgs.org/ - resource for finding needed packages
|
||||
# - Ex: hexpm/elixir:1.17.2-erlang-27.0-debian-bullseye-20240701-slim
|
||||
#
|
||||
ARG ELIXIR_VERSION=1.17.2
|
||||
ARG OTP_VERSION=27.0
|
||||
ARG DEBIAN_VERSION=bullseye-20240701-slim
|
||||
|
||||
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
|
||||
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
|
||||
|
||||
FROM ${BUILDER_IMAGE} as builder
|
||||
|
||||
# install build dependencies
|
||||
RUN apt-get update -y && apt-get install -y build-essential git \
|
||||
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
|
||||
|
||||
# prepare build dir
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm i
|
||||
# install hex + rebar
|
||||
RUN mix local.hex --force && \
|
||||
mix local.rebar --force
|
||||
|
||||
ADD . .
|
||||
RUN npm run build
|
||||
# set build ENV
|
||||
ENV MIX_ENV="prod"
|
||||
|
||||
FROM nginx:alpine
|
||||
# install mix dependencies
|
||||
COPY mix.exs mix.lock ./
|
||||
RUN mix deps.get --only $MIX_ENV
|
||||
RUN mkdir config
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/comfycamp
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
# copy compile-time config files before we compile dependencies
|
||||
# to ensure any relevant config change will trigger the dependencies
|
||||
# to be re-compiled.
|
||||
COPY config/config.exs config/${MIX_ENV}.exs config/
|
||||
RUN mix deps.compile
|
||||
|
||||
COPY priv priv
|
||||
|
||||
COPY lib lib
|
||||
|
||||
COPY assets assets
|
||||
|
||||
# compile assets
|
||||
RUN mix assets.deploy
|
||||
|
||||
# Compile the release
|
||||
RUN mix compile
|
||||
|
||||
# Changes to config/runtime.exs don't require recompiling the code
|
||||
COPY config/runtime.exs config/
|
||||
|
||||
COPY rel rel
|
||||
RUN mix release
|
||||
|
||||
# start a new build stage so that the final image will only contain
|
||||
# the compiled release and other runtime necessities
|
||||
FROM ${RUNNER_IMAGE}
|
||||
|
||||
RUN apt-get update -y && \
|
||||
apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
|
||||
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
|
||||
|
||||
# Set the locale
|
||||
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
||||
|
||||
ENV LANG en_US.UTF-8
|
||||
ENV LANGUAGE en_US:en
|
||||
ENV LC_ALL en_US.UTF-8
|
||||
|
||||
WORKDIR "/app"
|
||||
RUN chown nobody /app
|
||||
|
||||
# set runner ENV
|
||||
ENV MIX_ENV="prod"
|
||||
|
||||
# Only copy the final release from the build stage
|
||||
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/comfycamp ./
|
||||
|
||||
USER nobody
|
||||
|
||||
# If using an environment that doesn't automatically reap zombie processes, it is
|
||||
# advised to add an init process such as tini via `apt-get install`
|
||||
# above and adding an entrypoint. See https://github.com/krallin/tini for details
|
||||
# ENTRYPOINT ["/tini", "--"]
|
||||
|
||||
CMD ["/app/bin/server"]
|
||||
|
|
21
README.md
|
@ -1,17 +1,18 @@
|
|||
# Comfycamp
|
||||
|
||||
My personal website.
|
||||
To start your Phoenix server:
|
||||
|
||||
* Run `mix setup` to install and setup dependencies
|
||||
* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
|
||||
|
||||
## Getting started
|
||||
Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
|
||||
|
||||
```bash
|
||||
npm i
|
||||
npm run dev
|
||||
```
|
||||
Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
|
||||
|
||||
## Learn more
|
||||
|
||||
## Images
|
||||
|
||||
The original pictures are in the `public-src` directory.
|
||||
The `convert-images.py` script converts them into the required formats and resolutions.
|
||||
* Official website: https://www.phoenixframework.org/
|
||||
* Guides: https://hexdocs.pm/phoenix/overview.html
|
||||
* Docs: https://hexdocs.pm/phoenix
|
||||
* Forum: https://elixirforum.com/c/phoenix-forum
|
||||
* Source: https://github.com/phoenixframework/phoenix
|
||||
|
|
59
assets/css/admin.css
Normal file
|
@ -0,0 +1,59 @@
|
|||
.admin-panel {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.admin-panel .menu {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.admin-panel .menu li {
|
||||
padding: 10px;
|
||||
background-color: #20232f;
|
||||
}
|
||||
|
||||
.admin-panel .menu li:first-child {
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.admin-panel .menu li:not(:first-child) {
|
||||
border-top: 1px solid #353544;
|
||||
}
|
||||
|
||||
.admin-panel .menu li:last-child {
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
.admin-panel h1,
|
||||
.admin-panel h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.admin-panel .stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.admin-panel .stat {
|
||||
background-color: #20232f;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: min-content;
|
||||
}
|
||||
|
||||
.admin-panel .stat .value {
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.admin-panel .stat .name {
|
||||
text-align: center;
|
||||
}
|
106
assets/css/core/app.css
Normal file
|
@ -0,0 +1,106 @@
|
|||
@import "./components.css";
|
||||
@import "./flash.css";
|
||||
|
||||
:root {
|
||||
--bg: #13151a;
|
||||
--accent: #b283e5;
|
||||
--input-bg: #28253c;
|
||||
--input-border: #4c4c6d;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: Georgia, serif;
|
||||
background-color: var(--bg);
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
*::selection {
|
||||
background-color: var(--accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 36px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
header,
|
||||
main,
|
||||
footer {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.limiter {
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.link-list {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.link-list a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.navbar .space {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #333;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: #282735;
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
349
assets/css/core/components.css
Normal file
|
@ -0,0 +1,349 @@
|
|||
.label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.5rem;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.list {
|
||||
margin-top: 3.5rem;
|
||||
}
|
||||
|
||||
.list-description {
|
||||
margin-top: -1rem;
|
||||
margin-bottom: -1rem;
|
||||
border-top-width: 1px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.list-item {
|
||||
gap: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.list-item-title {
|
||||
flex: none;
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.list-item-description {
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.modal-bg {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
transition-property: opacity;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 300ms;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
overflow-y: auto;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.modal-body {
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-close-button-container {
|
||||
position: absolute;
|
||||
right: 1.25rem;
|
||||
top: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-close-button {
|
||||
padding: 0.75rem;
|
||||
margin: -0.75rem;
|
||||
flex: none;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.modal-close-button:hover {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.modal-close-button-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
display: none;
|
||||
position: relative;
|
||||
padding: 3.5rem;
|
||||
border-radius: 1rem;
|
||||
box-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||
background-color: #ffffff;
|
||||
transition-property: background-color, border-color, color, fill, stroke, opacity, box-shadow, transform;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 300ms;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.simple-form {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.simple-form-action {
|
||||
display: flex;
|
||||
margin-top: 0.5rem;
|
||||
gap: 1.5rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.5rem;
|
||||
background-color: var(--input-bg);
|
||||
border: 2px solid var(--input-border);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
background-color: var(--input-bg);
|
||||
border: 2px solid var(--input-border);
|
||||
padding: 4px 8px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: 1px solid var(--input-border);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.input {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.textarea {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
background-color: var(--input-bg);
|
||||
border: 2px solid var(--input-border);
|
||||
padding: 4px 8px;
|
||||
color: white;
|
||||
resize: vertical;
|
||||
min-height: 50px;
|
||||
max-height: 330px;
|
||||
}
|
||||
|
||||
.textarea:focus {
|
||||
outline: 1px solid var(--input-border);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.textarea {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.select {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border-width: 1px;
|
||||
border-color: #D1D5DB;
|
||||
width: 100%;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.select {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.checkbox-input {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.show {
|
||||
transition-property: all;
|
||||
transition-duration: 300ms;
|
||||
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
--transform-translate-y: 1rem;
|
||||
opacity: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.show {
|
||||
--transform-scale-x: 1;
|
||||
--transform-scale-y: 1;
|
||||
--transform-translate-y: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.hide {
|
||||
transition-property: all;
|
||||
transition-duration: 300ms;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
|
||||
--transform-translate-y: 1rem;
|
||||
opacity: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.hide {
|
||||
--transform-scale-x: .95;
|
||||
--transform-scale-y: .95;
|
||||
--transform-translate-y: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.show-modal {
|
||||
transition-property: all;
|
||||
transition-duration: 300ms;
|
||||
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.hide-modal {
|
||||
transition-property: all;
|
||||
transition-duration: 200ms;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.back-nav-link {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.back-nav-link svg {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-y: auto;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.table-container {
|
||||
overflow: visible;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.table {
|
||||
margin-top: 2.75rem;
|
||||
width: 40rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.table {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.thead {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
line-height: 1.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.th {
|
||||
padding: 0;
|
||||
padding-bottom: 1rem;
|
||||
padding-right: 1.5rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.th-actions {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tbody {
|
||||
position: relative;
|
||||
border-top-width: 1px;
|
||||
border-top-width: 1px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.td {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
}
|
51
assets/css/core/flash.css
Normal file
|
@ -0,0 +1,51 @@
|
|||
.flash {
|
||||
position: fixed;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 50;
|
||||
padding: 16px;
|
||||
border-radius: 0.3rem;
|
||||
box-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||
width: 20rem;
|
||||
}
|
||||
|
||||
.flash-info {
|
||||
background-color: #15803d;
|
||||
}
|
||||
|
||||
.flash-error {
|
||||
background-color: #b91c1c;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.flash {
|
||||
width: 24rem;
|
||||
}
|
||||
}
|
||||
|
||||
.flash-title {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.flash-body {
|
||||
margin-bottom: 0;
|
||||
text-indent: 28px;
|
||||
}
|
||||
|
||||
.flash-close-button {
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.flash-close-button-icon {
|
||||
opacity: 0.6;
|
||||
}
|
22
assets/css/home.css
Normal file
|
@ -0,0 +1,22 @@
|
|||
.home .service {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.home .service h3 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.home .service .link {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.home .service svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.home .warning {
|
||||
background-color: #aa4526;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
}
|
47
assets/js/app.js
Normal file
|
@ -0,0 +1,47 @@
|
|||
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
|
||||
// to get started and then uncomment the line below.
|
||||
// import "./user_socket.js"
|
||||
|
||||
// You can include dependencies in two ways.
|
||||
//
|
||||
// The simplest option is to put them in assets/vendor and
|
||||
// import them using relative paths:
|
||||
//
|
||||
// import "../vendor/some-package.js"
|
||||
//
|
||||
// Alternatively, you can `npm install some-package --prefix assets` and import
|
||||
// them using a path starting with the package name:
|
||||
//
|
||||
// import "some-package"
|
||||
//
|
||||
|
||||
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
|
||||
import "phoenix_html"
|
||||
// Establish Phoenix Socket and LiveView configuration.
|
||||
import {Socket} from "phoenix"
|
||||
import {LiveSocket} from "phoenix_live_view"
|
||||
import topbar from "../vendor/topbar"
|
||||
import "../css/core/app.css"
|
||||
import "../css/admin.css"
|
||||
import "../css/home.css"
|
||||
|
||||
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||
let liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: {_csrf_token: csrfToken}
|
||||
})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
|
||||
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
|
||||
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
|
||||
|
||||
// connect if there are any LiveViews on the page
|
||||
liveSocket.connect()
|
||||
|
||||
// expose liveSocket on window for web console debug logs and latency simulation:
|
||||
// >> liveSocket.enableDebug()
|
||||
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
|
||||
// >> liveSocket.disableLatencySim()
|
||||
window.liveSocket = liveSocket
|
||||
|
165
assets/vendor/topbar.js
vendored
Normal file
|
@ -0,0 +1,165 @@
|
|||
/**
|
||||
* @license MIT
|
||||
* topbar 2.0.0, 2023-02-04
|
||||
* https://buunguyen.github.io/topbar
|
||||
* Copyright (c) 2021 Buu Nguyen
|
||||
*/
|
||||
(function (window, document) {
|
||||
"use strict";
|
||||
|
||||
// https://gist.github.com/paulirish/1579671
|
||||
(function () {
|
||||
var lastTime = 0;
|
||||
var vendors = ["ms", "moz", "webkit", "o"];
|
||||
for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
|
||||
window.requestAnimationFrame =
|
||||
window[vendors[x] + "RequestAnimationFrame"];
|
||||
window.cancelAnimationFrame =
|
||||
window[vendors[x] + "CancelAnimationFrame"] ||
|
||||
window[vendors[x] + "CancelRequestAnimationFrame"];
|
||||
}
|
||||
if (!window.requestAnimationFrame)
|
||||
window.requestAnimationFrame = function (callback, element) {
|
||||
var currTime = new Date().getTime();
|
||||
var timeToCall = Math.max(0, 16 - (currTime - lastTime));
|
||||
var id = window.setTimeout(function () {
|
||||
callback(currTime + timeToCall);
|
||||
}, timeToCall);
|
||||
lastTime = currTime + timeToCall;
|
||||
return id;
|
||||
};
|
||||
if (!window.cancelAnimationFrame)
|
||||
window.cancelAnimationFrame = function (id) {
|
||||
clearTimeout(id);
|
||||
};
|
||||
})();
|
||||
|
||||
var canvas,
|
||||
currentProgress,
|
||||
showing,
|
||||
progressTimerId = null,
|
||||
fadeTimerId = null,
|
||||
delayTimerId = null,
|
||||
addEvent = function (elem, type, handler) {
|
||||
if (elem.addEventListener) elem.addEventListener(type, handler, false);
|
||||
else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
|
||||
else elem["on" + type] = handler;
|
||||
},
|
||||
options = {
|
||||
autoRun: true,
|
||||
barThickness: 3,
|
||||
barColors: {
|
||||
0: "rgba(26, 188, 156, .9)",
|
||||
".25": "rgba(52, 152, 219, .9)",
|
||||
".50": "rgba(241, 196, 15, .9)",
|
||||
".75": "rgba(230, 126, 34, .9)",
|
||||
"1.0": "rgba(211, 84, 0, .9)",
|
||||
},
|
||||
shadowBlur: 10,
|
||||
shadowColor: "rgba(0, 0, 0, .6)",
|
||||
className: null,
|
||||
},
|
||||
repaint = function () {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = options.barThickness * 5; // need space for shadow
|
||||
|
||||
var ctx = canvas.getContext("2d");
|
||||
ctx.shadowBlur = options.shadowBlur;
|
||||
ctx.shadowColor = options.shadowColor;
|
||||
|
||||
var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
|
||||
for (var stop in options.barColors)
|
||||
lineGradient.addColorStop(stop, options.barColors[stop]);
|
||||
ctx.lineWidth = options.barThickness;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, options.barThickness / 2);
|
||||
ctx.lineTo(
|
||||
Math.ceil(currentProgress * canvas.width),
|
||||
options.barThickness / 2
|
||||
);
|
||||
ctx.strokeStyle = lineGradient;
|
||||
ctx.stroke();
|
||||
},
|
||||
createCanvas = function () {
|
||||
canvas = document.createElement("canvas");
|
||||
var style = canvas.style;
|
||||
style.position = "fixed";
|
||||
style.top = style.left = style.right = style.margin = style.padding = 0;
|
||||
style.zIndex = 100001;
|
||||
style.display = "none";
|
||||
if (options.className) canvas.classList.add(options.className);
|
||||
document.body.appendChild(canvas);
|
||||
addEvent(window, "resize", repaint);
|
||||
},
|
||||
topbar = {
|
||||
config: function (opts) {
|
||||
for (var key in opts)
|
||||
if (options.hasOwnProperty(key)) options[key] = opts[key];
|
||||
},
|
||||
show: function (delay) {
|
||||
if (showing) return;
|
||||
if (delay) {
|
||||
if (delayTimerId) return;
|
||||
delayTimerId = setTimeout(() => topbar.show(), delay);
|
||||
} else {
|
||||
showing = true;
|
||||
if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
|
||||
if (!canvas) createCanvas();
|
||||
canvas.style.opacity = 1;
|
||||
canvas.style.display = "block";
|
||||
topbar.progress(0);
|
||||
if (options.autoRun) {
|
||||
(function loop() {
|
||||
progressTimerId = window.requestAnimationFrame(loop);
|
||||
topbar.progress(
|
||||
"+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
|
||||
);
|
||||
})();
|
||||
}
|
||||
}
|
||||
},
|
||||
progress: function (to) {
|
||||
if (typeof to === "undefined") return currentProgress;
|
||||
if (typeof to === "string") {
|
||||
to =
|
||||
(to.indexOf("+") >= 0 || to.indexOf("-") >= 0
|
||||
? currentProgress
|
||||
: 0) + parseFloat(to);
|
||||
}
|
||||
currentProgress = to > 1 ? 1 : to;
|
||||
repaint();
|
||||
return currentProgress;
|
||||
},
|
||||
hide: function () {
|
||||
clearTimeout(delayTimerId);
|
||||
delayTimerId = null;
|
||||
if (!showing) return;
|
||||
showing = false;
|
||||
if (progressTimerId != null) {
|
||||
window.cancelAnimationFrame(progressTimerId);
|
||||
progressTimerId = null;
|
||||
}
|
||||
(function loop() {
|
||||
if (topbar.progress("+.1") >= 1) {
|
||||
canvas.style.opacity -= 0.05;
|
||||
if (canvas.style.opacity <= 0.05) {
|
||||
canvas.style.display = "none";
|
||||
fadeTimerId = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
fadeTimerId = window.requestAnimationFrame(loop);
|
||||
})();
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module === "object" && typeof module.exports === "object") {
|
||||
module.exports = topbar;
|
||||
} else if (typeof define === "function" && define.amd) {
|
||||
define(function () {
|
||||
return topbar;
|
||||
});
|
||||
} else {
|
||||
this.topbar = topbar;
|
||||
}
|
||||
}.call(this, window, document));
|
|
@ -1,6 +0,0 @@
|
|||
import { defineConfig } from 'astro/config'
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: 'https://comfycamp.space'
|
||||
})
|
56
config/config.exs
Normal file
|
@ -0,0 +1,56 @@
|
|||
# This file is responsible for configuring your application
|
||||
# and its dependencies with the aid of the Config module.
|
||||
#
|
||||
# This configuration file is loaded before any dependency and
|
||||
# is restricted to this project.
|
||||
|
||||
# General application configuration
|
||||
import Config
|
||||
|
||||
config :comfycamp,
|
||||
ecto_repos: [Comfycamp.Repo],
|
||||
generators: [timestamp_type: :utc_datetime]
|
||||
|
||||
config :comfycamp, ComfycampWeb.Gettext, locales: ~w(en ru), default_locale: "ru"
|
||||
|
||||
# Configures the endpoint
|
||||
config :comfycamp, ComfycampWeb.Endpoint,
|
||||
url: [host: "localhost"],
|
||||
adapter: Bandit.PhoenixAdapter,
|
||||
render_errors: [
|
||||
formats: [html: ComfycampWeb.ErrorHTML, json: ComfycampWeb.ErrorJSON],
|
||||
layout: false
|
||||
],
|
||||
pubsub_server: Comfycamp.PubSub,
|
||||
live_view: [signing_salt: "LXPLpJT8"]
|
||||
|
||||
# Configures the mailer
|
||||
#
|
||||
# By default it uses the "Local" adapter which stores the emails
|
||||
# locally. You can see the emails in your browser, at "/dev/mailbox".
|
||||
#
|
||||
# For production it's recommended to configure a different adapter
|
||||
# at the `config/runtime.exs`.
|
||||
config :comfycamp, Comfycamp.Mailer, adapter: Swoosh.Adapters.Local
|
||||
|
||||
# Configure esbuild (the version is required)
|
||||
config :esbuild,
|
||||
version: "0.17.11",
|
||||
comfycamp: [
|
||||
args:
|
||||
~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
|
||||
cd: Path.expand("../assets", __DIR__),
|
||||
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
|
||||
]
|
||||
|
||||
# Configures Elixir's Logger
|
||||
config :logger, :console,
|
||||
format: "$time $metadata[$level] $message\n",
|
||||
metadata: [:request_id]
|
||||
|
||||
# Use Jason for JSON parsing in Phoenix
|
||||
config :phoenix, :json_library, Jason
|
||||
|
||||
# Import environment specific config. This must remain at the bottom
|
||||
# of this file so it overrides the configuration defined above.
|
||||
import_config "#{config_env()}.exs"
|
84
config/dev.exs
Normal file
|
@ -0,0 +1,84 @@
|
|||
import Config
|
||||
|
||||
# Configure your database
|
||||
config :comfycamp, Comfycamp.Repo,
|
||||
username: "phoenix",
|
||||
password: "simple-password",
|
||||
hostname: "localhost",
|
||||
database: "phoenix",
|
||||
stacktrace: true,
|
||||
show_sensitive_data_on_connection_error: true,
|
||||
pool_size: 10
|
||||
|
||||
# For development, we disable any cache and enable
|
||||
# debugging and code reloading.
|
||||
#
|
||||
# The watchers configuration can be used to run external
|
||||
# watchers to your application. For example, we can use it
|
||||
# to bundle .js and .css sources.
|
||||
config :comfycamp, ComfycampWeb.Endpoint,
|
||||
# Binding to loopback ipv4 address prevents access from other machines.
|
||||
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
|
||||
http: [ip: {127, 0, 0, 1}, port: 4000],
|
||||
check_origin: false,
|
||||
code_reloader: true,
|
||||
debug_errors: true,
|
||||
secret_key_base: "6TPjZ6GJcs5FerDbAdr2pHRL5JASsi04nah6WbeQfbPmnuHz0lAUu4e60HNBkKVv",
|
||||
watchers: [
|
||||
esbuild: {Esbuild, :install_and_run, [:comfycamp, ~w(--sourcemap=inline --watch)]}
|
||||
]
|
||||
|
||||
# ## SSL Support
|
||||
#
|
||||
# In order to use HTTPS in development, a self-signed
|
||||
# certificate can be generated by running the following
|
||||
# Mix task:
|
||||
#
|
||||
# mix phx.gen.cert
|
||||
#
|
||||
# Run `mix help phx.gen.cert` for more information.
|
||||
#
|
||||
# The `http:` config above can be replaced with:
|
||||
#
|
||||
# https: [
|
||||
# port: 4001,
|
||||
# cipher_suite: :strong,
|
||||
# keyfile: "priv/cert/selfsigned_key.pem",
|
||||
# certfile: "priv/cert/selfsigned.pem"
|
||||
# ],
|
||||
#
|
||||
# If desired, both `http:` and `https:` keys can be
|
||||
# configured to run both http and https servers on
|
||||
# different ports.
|
||||
|
||||
# Watch static and templates for browser reloading.
|
||||
config :comfycamp, ComfycampWeb.Endpoint,
|
||||
live_reload: [
|
||||
patterns: [
|
||||
~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
|
||||
~r"priv/gettext/.*(po)$",
|
||||
~r"lib/comfycamp_web/(controllers|live|components)/.*(ex|heex)$"
|
||||
]
|
||||
]
|
||||
|
||||
# Enable dev routes for dashboard and mailbox
|
||||
config :comfycamp, dev_routes: true
|
||||
|
||||
# Do not include metadata nor timestamps in development logs
|
||||
config :logger, :console, format: "[$level] $message\n"
|
||||
|
||||
# Set a higher stacktrace during development. Avoid configuring such
|
||||
# in production as building large stacktraces may be expensive.
|
||||
config :phoenix, :stacktrace_depth, 20
|
||||
|
||||
# Initialize plugs at runtime for faster development compilation
|
||||
config :phoenix, :plug_init_mode, :runtime
|
||||
|
||||
config :phoenix_live_view,
|
||||
# Include HEEx debug annotations as HTML comments in rendered markup
|
||||
debug_heex_annotations: true,
|
||||
# Enable helpful, but potentially expensive runtime checks
|
||||
enable_expensive_runtime_checks: true
|
||||
|
||||
# Disable swoosh api client as it is only required for production adapters.
|
||||
config :swoosh, :api_client, false
|
20
config/prod.exs
Normal file
|
@ -0,0 +1,20 @@
|
|||
import Config
|
||||
|
||||
# Note we also include the path to a cache manifest
|
||||
# containing the digested version of static files. This
|
||||
# manifest is generated by the `mix assets.deploy` task,
|
||||
# which you should run after static files are built and
|
||||
# before starting your production server.
|
||||
config :comfycamp, ComfycampWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
|
||||
|
||||
# Configures Swoosh API Client
|
||||
config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: Comfycamp.Finch
|
||||
|
||||
# Disable Swoosh Local Memory Storage
|
||||
config :swoosh, local: false
|
||||
|
||||
# Do not print debug messages in production
|
||||
config :logger, level: :info
|
||||
|
||||
# Runtime production configuration, including reading
|
||||
# of environment variables, is done on config/runtime.exs.
|
119
config/runtime.exs
Normal file
|
@ -0,0 +1,119 @@
|
|||
import Config
|
||||
|
||||
# config/runtime.exs is executed for all environments, including
|
||||
# during releases. It is executed after compilation and before the
|
||||
# system starts, so it is typically used to load production configuration
|
||||
# and secrets from environment variables or elsewhere. Do not define
|
||||
# any compile-time configuration in here, as it won't be applied.
|
||||
# The block below contains prod specific runtime configuration.
|
||||
|
||||
# ## Using releases
|
||||
#
|
||||
# If you use `mix release`, you need to explicitly enable the server
|
||||
# by passing the PHX_SERVER=true when you start it:
|
||||
#
|
||||
# PHX_SERVER=true bin/comfycamp 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 :comfycamp, ComfycampWeb.Endpoint, server: true
|
||||
end
|
||||
|
||||
if config_env() == :prod do
|
||||
database_url =
|
||||
System.get_env("DATABASE_URL") ||
|
||||
raise """
|
||||
environment variable DATABASE_URL is missing.
|
||||
For example: ecto://USER:PASS@HOST/DATABASE
|
||||
"""
|
||||
|
||||
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
|
||||
|
||||
config :comfycamp, Comfycamp.Repo,
|
||||
# ssl: true,
|
||||
url: database_url,
|
||||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
|
||||
socket_dir: System.get_env("DATABASE_SOCKET_DIR"),
|
||||
socket_options: maybe_ipv6
|
||||
|
||||
# The secret key base is used to sign/encrypt cookies and other secrets.
|
||||
# A default value is used in config/dev.exs and config/test.exs but you
|
||||
# want to use a different value for prod and you most likely don't want
|
||||
# to check this value into version control, so we use an environment
|
||||
# variable instead.
|
||||
secret_key_base =
|
||||
System.get_env("SECRET_KEY_BASE") ||
|
||||
raise """
|
||||
environment variable SECRET_KEY_BASE is missing.
|
||||
You can generate one by calling: mix phx.gen.secret
|
||||
"""
|
||||
|
||||
host = System.get_env("PHX_HOST") || "example.com"
|
||||
port = String.to_integer(System.get_env("PORT") || "4000")
|
||||
|
||||
config :comfycamp, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
|
||||
|
||||
config :comfycamp, ComfycampWeb.Endpoint,
|
||||
url: [host: host, port: 443, scheme: "https"],
|
||||
http: [
|
||||
# Enable IPv6 and bind on all interfaces.
|
||||
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
|
||||
# See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
|
||||
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
|
||||
ip: {0, 0, 0, 0, 0, 0, 0, 0},
|
||||
port: port
|
||||
],
|
||||
secret_key_base: secret_key_base
|
||||
|
||||
# ## SSL Support
|
||||
#
|
||||
# To get SSL working, you will need to add the `https` key
|
||||
# to your endpoint configuration:
|
||||
#
|
||||
# config :comfycamp, ComfycampWeb.Endpoint,
|
||||
# https: [
|
||||
# ...,
|
||||
# port: 443,
|
||||
# cipher_suite: :strong,
|
||||
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
|
||||
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
|
||||
# ]
|
||||
#
|
||||
# The `cipher_suite` is set to `:strong` to support only the
|
||||
# latest and more secure SSL ciphers. This means old browsers
|
||||
# and clients may not be supported. You can set it to
|
||||
# `:compatible` for wider support.
|
||||
#
|
||||
# `:keyfile` and `:certfile` expect an absolute path to the key
|
||||
# and cert in disk or a relative path inside priv, for example
|
||||
# "priv/ssl/server.key". For all supported SSL configuration
|
||||
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
|
||||
#
|
||||
# We also recommend setting `force_ssl` in your config/prod.exs,
|
||||
# ensuring no data is ever sent via http, always redirecting to https:
|
||||
#
|
||||
# config :comfycamp, ComfycampWeb.Endpoint,
|
||||
# force_ssl: [hsts: true]
|
||||
#
|
||||
# Check `Plug.SSL` for all available options in `force_ssl`.
|
||||
|
||||
# Mailer
|
||||
|
||||
config :comfycamp, Comfycamp.Mailer,
|
||||
adapter: Swoosh.Adapters.SMTP,
|
||||
relay: System.get_env("SMTP_RELAY"),
|
||||
username: System.get_env("SMTP_USERNAME"),
|
||||
password: System.get_env("SMTP_PASSWORD"),
|
||||
ssl: System.get_env("SMTP_SSL") == "true",
|
||||
tls: :if_available,
|
||||
auth: :always,
|
||||
port: Integer.parse(System.get_env("SMTP_PORT") || "465")
|
||||
|
||||
# For this example you need include a HTTP client required by Swoosh API client.
|
||||
# Swoosh supports Hackney and Finch out of the box:
|
||||
#
|
||||
# config :swoosh, :api_client, Swoosh.ApiClient.Hackney
|
||||
#
|
||||
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
|
||||
end
|
40
config/test.exs
Normal file
|
@ -0,0 +1,40 @@
|
|||
import Config
|
||||
|
||||
# Only in tests, remove the complexity from the password hashing algorithm
|
||||
config :argon2_elixir, t_cost: 1, m_cost: 8
|
||||
|
||||
# Configure your database
|
||||
#
|
||||
# The MIX_TEST_PARTITION environment variable can be used
|
||||
# to provide built-in test partitioning in CI environment.
|
||||
# Run `mix help test` for more information.
|
||||
config :comfycamp, Comfycamp.Repo,
|
||||
username: "phoenix",
|
||||
password: "simple-password",
|
||||
hostname: "localhost",
|
||||
database: "phoenix_test#{System.get_env("MIX_TEST_PARTITION")}",
|
||||
pool: Ecto.Adapters.SQL.Sandbox,
|
||||
pool_size: System.schedulers_online() * 2
|
||||
|
||||
# We don't run a server during test. If one is required,
|
||||
# you can enable the server option below.
|
||||
config :comfycamp, ComfycampWeb.Endpoint,
|
||||
http: [ip: {127, 0, 0, 1}, port: 4002],
|
||||
secret_key_base: "WqKUPBzWMCU4G/llJb5Gz+OGNUDeeKnskl4AFcY6sPs4JrhMDjclYWmZBjSbJ3fI",
|
||||
server: false
|
||||
|
||||
# In test we don't send emails.
|
||||
config :comfycamp, Comfycamp.Mailer, adapter: Swoosh.Adapters.Test
|
||||
|
||||
# Disable swoosh api client as it is only required for production adapters.
|
||||
config :swoosh, :api_client, false
|
||||
|
||||
# Print only warnings and errors during test
|
||||
config :logger, level: :warning
|
||||
|
||||
# Initialize plugs at runtime for faster test compilation
|
||||
config :phoenix, :plug_init_mode, :runtime
|
||||
|
||||
config :phoenix_live_view,
|
||||
# Enable helpful, but potentially expensive runtime checks
|
||||
enable_expensive_runtime_checks: true
|
|
@ -1,77 +0,0 @@
|
|||
import subprocess
|
||||
from os import listdir, path, makedirs
|
||||
from os.path import isfile, join, splitext, exists
|
||||
|
||||
|
||||
def main():
|
||||
convert_favicon()
|
||||
convert_images('public-src', 'public')
|
||||
|
||||
|
||||
def convert_favicon():
|
||||
"""
|
||||
Generate different versions of an icon from a vector image.
|
||||
The function does not overwrite existing files.
|
||||
"""
|
||||
|
||||
root = 'public/favicon'
|
||||
src = f'{root}/vector.svg'
|
||||
|
||||
sizes = [512, 192, 180, 32, 16]
|
||||
for size in sizes:
|
||||
dest = f'{root}/{size}.png'
|
||||
if exists(dest):
|
||||
continue
|
||||
|
||||
cmd = ['convert', src, '-resize', f'{size}x{size}', f'{root}/{size}.png']
|
||||
print(' '.join(cmd))
|
||||
subprocess.run(cmd)
|
||||
|
||||
dest = f'{root}/shortcut.ico'
|
||||
if exists(dest):
|
||||
return
|
||||
|
||||
cmd = [
|
||||
'convert', src,
|
||||
'(', '-clone', '0', '-resize', '16x16', ')',
|
||||
'(', '-clone', '0', '-resize', '32x32', ')',
|
||||
'(', '-clone', '0', '-resize', '48x48', ')',
|
||||
'-delete', '0', '-alpha', 'remove', '-colors', '256',
|
||||
f'{root}/shortcut.ico',
|
||||
]
|
||||
print(' '.join(cmd))
|
||||
subprocess.run(cmd)
|
||||
|
||||
|
||||
def convert_images(src, dest):
|
||||
"""
|
||||
Convert all images from the public-src directory.
|
||||
The function does not overwrite existing files.
|
||||
|
||||
Example:
|
||||
public-src/subdir/1.jpg -> public/subdir/1.avif
|
||||
"""
|
||||
|
||||
for i in listdir(src):
|
||||
entry = join(src, i)
|
||||
|
||||
if isfile(entry):
|
||||
if splitext(i.lower())[1] not in ['.png', '.jpg', '.jpeg']:
|
||||
continue
|
||||
|
||||
dest_file = join(dest, i)
|
||||
dest_file = splitext(dest_file)[0] + '.avif'
|
||||
if exists(dest_file):
|
||||
continue
|
||||
|
||||
cmd = ['convert', entry, '-resize', '1200x', '-quality', '80', dest_file]
|
||||
print(' '.join(cmd))
|
||||
subprocess.run(cmd)
|
||||
else:
|
||||
target_dir = join(dest, i)
|
||||
makedirs(target_dir, exist_ok=True)
|
||||
convert_images(entry, target_dir)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
16
docker-compose.yml
Normal file
|
@ -0,0 +1,16 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: phoenix
|
||||
POSTGRES_USER: phoenix
|
||||
POSTGRES_PASSWORD: simple-password
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- comfycamp-postgres-dev:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
comfycamp-postgres-dev:
|
61
flake.lock
|
@ -1,61 +0,0 @@
|
|||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1705309234,
|
||||
"narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1708566995,
|
||||
"narHash": "sha256-e/THimsoxxMAHSbwMKov5f5Yg+utTj6XVGEo24Lhx+0=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3cb4ae6689d2aa3f363516234572613b31212b78",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-23.11",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
47
flake.nix
|
@ -1,47 +0,0 @@
|
|||
{
|
||||
description = "comfycamp.space";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-23.11";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }: flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
system = "x86_64-linux";
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
packageName = "comfycamp";
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = [
|
||||
pkgs.nodejs_20
|
||||
pkgs.imagemagick
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
packages.${packageName} = pkgs.buildNpmPackage {
|
||||
name = "${packageName}";
|
||||
|
||||
src = ./.;
|
||||
npmDepsHash = "sha256-Nprdi+LX7/fSnrxeJzkbHX13FKM172E8wy6L+nxC/iE=";
|
||||
|
||||
buildInputs = [
|
||||
pkgs.nodejs_20
|
||||
pkgs.vips # required by sharp
|
||||
];
|
||||
nativeBuildInputs = [
|
||||
pkgs.pkg-config # required by sharp
|
||||
];
|
||||
installPhase = ''
|
||||
mkdir $out
|
||||
npm run build
|
||||
cp -r dist/* $out
|
||||
'';
|
||||
};
|
||||
|
||||
defaultPackage = self.packages.${system}.${packageName};
|
||||
}
|
||||
);
|
||||
}
|
9
lib/comfycamp.ex
Normal file
|
@ -0,0 +1,9 @@
|
|||
defmodule Comfycamp do
|
||||
@moduledoc """
|
||||
Comfycamp keeps the contexts that define your domain
|
||||
and business logic.
|
||||
|
||||
Contexts are also responsible for managing your data, regardless
|
||||
if it comes from the database, an external API or others.
|
||||
"""
|
||||
end
|
374
lib/comfycamp/accounts.ex
Normal file
|
@ -0,0 +1,374 @@
|
|||
defmodule Comfycamp.Accounts do
|
||||
@moduledoc """
|
||||
The Accounts context.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Comfycamp.Repo
|
||||
|
||||
alias Comfycamp.Accounts.{User, UserToken, UserNotifier}
|
||||
|
||||
## Database getters
|
||||
|
||||
@doc """
|
||||
Gets a user by email.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user_by_email("foo@example.com")
|
||||
%User{}
|
||||
|
||||
iex> get_user_by_email("unknown@example.com")
|
||||
nil
|
||||
|
||||
"""
|
||||
def get_user_by_email(email) when is_binary(email) do
|
||||
Repo.get_by(User, email: email)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get all users.
|
||||
"""
|
||||
def list_users() do
|
||||
Repo.all(User)
|
||||
end
|
||||
|
||||
def count_users() do
|
||||
Repo.one(from u in "users", select: count(u.id))
|
||||
end
|
||||
|
||||
def count_unapproved_users() do
|
||||
Repo.one(from u in "users", select: count(u.id), where: not u.is_approved)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a user by email and password.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user_by_email_and_password("foo@example.com", "correct_password")
|
||||
%User{}
|
||||
|
||||
iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
|
||||
nil
|
||||
|
||||
"""
|
||||
def get_user_by_email_and_password(email, password)
|
||||
when is_binary(email) and is_binary(password) do
|
||||
user = Repo.get_by(User, email: email)
|
||||
if User.valid_password?(user, password), do: user
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single user.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the User does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user!(123)
|
||||
%User{}
|
||||
|
||||
iex> get_user!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_user!(id), do: Repo.get!(User, id)
|
||||
|
||||
## User registration
|
||||
|
||||
@doc """
|
||||
Registers a user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> register_user(%{field: value})
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> register_user(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def register_user(attrs) do
|
||||
%User{}
|
||||
|> User.registration_changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking user changes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_user_registration(user)
|
||||
%Ecto.Changeset{data: %User{}}
|
||||
|
||||
"""
|
||||
def change_user_registration(%User{} = user, attrs \\ %{}) do
|
||||
User.registration_changeset(user, attrs, hash_password: false, validate_email: false)
|
||||
end
|
||||
|
||||
## Settings
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for changing the user email.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_user_email(user)
|
||||
%Ecto.Changeset{data: %User{}}
|
||||
|
||||
"""
|
||||
def change_user_email(user, attrs \\ %{}) do
|
||||
User.email_changeset(user, attrs, validate_email: false)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Emulates that the email will change without actually changing
|
||||
it in the database.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> apply_user_email(user, "valid password", %{email: ...})
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> apply_user_email(user, "invalid password", %{email: ...})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def apply_user_email(user, password, attrs) do
|
||||
user
|
||||
|> User.email_changeset(attrs)
|
||||
|> User.validate_current_password(password)
|
||||
|> Ecto.Changeset.apply_action(:update)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the user email using the given token.
|
||||
|
||||
If the token matches, the user email is updated and the token is deleted.
|
||||
The confirmed_at date is also updated to the current time.
|
||||
"""
|
||||
def update_user_email(user, token) do
|
||||
context = "change:#{user.email}"
|
||||
|
||||
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
|
||||
%UserToken{sent_to: email} <- Repo.one(query),
|
||||
{:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do
|
||||
:ok
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp user_email_multi(user, email, context) do
|
||||
changeset =
|
||||
user
|
||||
|> User.email_changeset(%{email: email})
|
||||
|> User.confirm_changeset()
|
||||
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.update(:user, changeset)
|
||||
|> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, [context]))
|
||||
end
|
||||
|
||||
@doc ~S"""
|
||||
Delivers the update email instructions to the given user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm_email/#{&1})")
|
||||
{:ok, %{to: ..., body: ...}}
|
||||
|
||||
"""
|
||||
def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
|
||||
when is_function(update_email_url_fun, 1) do
|
||||
{encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")
|
||||
|
||||
Repo.insert!(user_token)
|
||||
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for changing the user password.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_user_password(user)
|
||||
%Ecto.Changeset{data: %User{}}
|
||||
|
||||
"""
|
||||
def change_user_password(user, attrs \\ %{}) do
|
||||
User.password_changeset(user, attrs, hash_password: false)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the user password.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_user_password(user, "valid password", %{password: ...})
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> update_user_password(user, "invalid password", %{password: ...})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_user_password(user, password, attrs) do
|
||||
changeset =
|
||||
user
|
||||
|> User.password_changeset(attrs)
|
||||
|> User.validate_current_password(password)
|
||||
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.update(:user, changeset)
|
||||
|> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all))
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{user: user}} -> {:ok, user}
|
||||
{:error, :user, changeset, _} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
## Session
|
||||
|
||||
@doc """
|
||||
Generates a session token.
|
||||
"""
|
||||
def generate_user_session_token(user) do
|
||||
{token, user_token} = UserToken.build_session_token(user)
|
||||
Repo.insert!(user_token)
|
||||
token
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the user with the given signed token.
|
||||
"""
|
||||
def get_user_by_session_token(token) do
|
||||
{:ok, query} = UserToken.verify_session_token_query(token)
|
||||
Repo.one(query)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes the signed token with the given context.
|
||||
"""
|
||||
def delete_user_session_token(token) do
|
||||
Repo.delete_all(UserToken.by_token_and_context_query(token, "session"))
|
||||
:ok
|
||||
end
|
||||
|
||||
## Confirmation
|
||||
|
||||
@doc ~S"""
|
||||
Delivers the confirmation email instructions to the given user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> deliver_user_confirmation_instructions(user, &url(~p"/users/confirm/#{&1}"))
|
||||
{:ok, %{to: ..., body: ...}}
|
||||
|
||||
iex> deliver_user_confirmation_instructions(confirmed_user, &url(~p"/users/confirm/#{&1}"))
|
||||
{:error, :already_confirmed}
|
||||
|
||||
"""
|
||||
def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun)
|
||||
when is_function(confirmation_url_fun, 1) do
|
||||
if user.confirmed_at do
|
||||
{:error, :already_confirmed}
|
||||
else
|
||||
{encoded_token, user_token} = UserToken.build_email_token(user, "confirm")
|
||||
Repo.insert!(user_token)
|
||||
UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token))
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Confirms a user by the given token.
|
||||
|
||||
If the token matches, the user account is marked as confirmed
|
||||
and the token is deleted.
|
||||
"""
|
||||
def confirm_user(token) do
|
||||
with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"),
|
||||
%User{} = user <- Repo.one(query),
|
||||
{:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do
|
||||
{:ok, user}
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
def update_approval_status(user, is_approved) do
|
||||
user
|
||||
|> User.approval_changeset(%{"is_approved" => is_approved})
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
defp confirm_user_multi(user) do
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.update(:user, User.confirm_changeset(user))
|
||||
|> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, ["confirm"]))
|
||||
end
|
||||
|
||||
## Reset password
|
||||
|
||||
@doc ~S"""
|
||||
Delivers the reset password email to the given user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset_password/#{&1}"))
|
||||
{:ok, %{to: ..., body: ...}}
|
||||
|
||||
"""
|
||||
def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun)
|
||||
when is_function(reset_password_url_fun, 1) do
|
||||
{encoded_token, user_token} = UserToken.build_email_token(user, "reset_password")
|
||||
Repo.insert!(user_token)
|
||||
UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the user by reset password token.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user_by_reset_password_token("validtoken")
|
||||
%User{}
|
||||
|
||||
iex> get_user_by_reset_password_token("invalidtoken")
|
||||
nil
|
||||
|
||||
"""
|
||||
def get_user_by_reset_password_token(token) do
|
||||
with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"),
|
||||
%User{} = user <- Repo.one(query) do
|
||||
user
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Resets the user password.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def reset_user_password(user, attrs) do
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.update(:user, User.password_changeset(user, attrs))
|
||||
|> Ecto.Multi.delete_all(:tokens, UserToken.by_user_and_contexts_query(user, :all))
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{user: user}} -> {:ok, user}
|
||||
{:error, :user, changeset, _} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
end
|
172
lib/comfycamp/accounts/user.ex
Normal file
|
@ -0,0 +1,172 @@
|
|||
defmodule Comfycamp.Accounts.User do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
schema "users" do
|
||||
field :email, :string
|
||||
field :password, :string, virtual: true, redact: true
|
||||
field :hashed_password, :string, redact: true
|
||||
field :confirmed_at, :naive_datetime
|
||||
field :is_admin, :boolean, default: false
|
||||
field :is_approved, :boolean, default: false
|
||||
field :info, :string
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for registration.
|
||||
|
||||
It is important to validate the length of both email and password.
|
||||
Otherwise databases may truncate the email without warnings, which
|
||||
could lead to unpredictable or insecure behaviour. Long passwords may
|
||||
also be very expensive to hash for certain algorithms.
|
||||
|
||||
## Options
|
||||
|
||||
* `:hash_password` - Hashes the password so it can be stored securely
|
||||
in the database and ensures the password field is cleared to prevent
|
||||
leaks in the logs. If password hashing is not needed and clearing the
|
||||
password field is not desired (like when using this changeset for
|
||||
validations on a LiveView form), this option can be set to `false`.
|
||||
Defaults to `true`.
|
||||
|
||||
* `:validate_email` - Validates the uniqueness of the email, in case
|
||||
you don't want to validate the uniqueness of the email (like when
|
||||
using this changeset for validations on a LiveView form before
|
||||
submitting the form), this option can be set to `false`.
|
||||
Defaults to `true`.
|
||||
"""
|
||||
def registration_changeset(user, attrs, opts \\ []) do
|
||||
user
|
||||
|> cast(attrs, [:email, :password, :info])
|
||||
|> validate_email(opts)
|
||||
|> validate_password(opts)
|
||||
|> validate_length(:info, min: 2, max: 4096)
|
||||
end
|
||||
|
||||
defp validate_email(changeset, opts) do
|
||||
changeset
|
||||
|> validate_required([:email])
|
||||
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")
|
||||
|> validate_length(:email, max: 160)
|
||||
|> maybe_validate_unique_email(opts)
|
||||
end
|
||||
|
||||
defp validate_password(changeset, opts) do
|
||||
changeset
|
||||
|> validate_required([:password])
|
||||
|> validate_length(:password, min: 12, max: 72)
|
||||
# Examples of additional password validation:
|
||||
# |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
|
||||
# |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
|
||||
# |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
|
||||
|> maybe_hash_password(opts)
|
||||
end
|
||||
|
||||
defp maybe_hash_password(changeset, opts) do
|
||||
hash_password? = Keyword.get(opts, :hash_password, true)
|
||||
password = get_change(changeset, :password)
|
||||
|
||||
if hash_password? && password && changeset.valid? do
|
||||
changeset
|
||||
# Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
|
||||
# would keep the database transaction open longer and hurt performance.
|
||||
|> put_change(:hashed_password, Argon2.hash_pwd_salt(password))
|
||||
|> delete_change(:password)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_validate_unique_email(changeset, opts) do
|
||||
if Keyword.get(opts, :validate_email, true) do
|
||||
changeset
|
||||
|> unsafe_validate_unique(:email, Comfycamp.Repo)
|
||||
|> unique_constraint(:email)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for changing the email.
|
||||
|
||||
It requires the email to change otherwise an error is added.
|
||||
"""
|
||||
def email_changeset(user, attrs, opts \\ []) do
|
||||
user
|
||||
|> cast(attrs, [:email])
|
||||
|> validate_email(opts)
|
||||
|> case do
|
||||
%{changes: %{email: _}} = changeset -> changeset
|
||||
%{} = changeset -> add_error(changeset, :email, "did not change")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for changing the password.
|
||||
|
||||
## Options
|
||||
|
||||
* `:hash_password` - Hashes the password so it can be stored securely
|
||||
in the database and ensures the password field is cleared to prevent
|
||||
leaks in the logs. If password hashing is not needed and clearing the
|
||||
password field is not desired (like when using this changeset for
|
||||
validations on a LiveView form), this option can be set to `false`.
|
||||
Defaults to `true`.
|
||||
"""
|
||||
def password_changeset(user, attrs, opts \\ []) do
|
||||
user
|
||||
|> cast(attrs, [:password])
|
||||
|> validate_confirmation(:password, message: "does not match password")
|
||||
|> validate_password(opts)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Confirms the account by setting `confirmed_at`.
|
||||
"""
|
||||
def confirm_changeset(user) do
|
||||
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
|
||||
change(user, confirmed_at: now)
|
||||
end
|
||||
|
||||
def approval_changeset(user, attrs) do
|
||||
user
|
||||
|> cast(attrs, [:is_approved])
|
||||
|> validate_required([:is_approved])
|
||||
end
|
||||
|
||||
def admin_status_changeset(user, attrs) do
|
||||
user
|
||||
|> cast(attrs, [:is_admin])
|
||||
|> validate_required([:is_admin])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Verifies the password.
|
||||
|
||||
If there is no user or the user doesn't have a password, we call
|
||||
`Argon2.no_user_verify/0` to avoid timing attacks.
|
||||
"""
|
||||
def valid_password?(%Comfycamp.Accounts.User{hashed_password: hashed_password}, password)
|
||||
when is_binary(hashed_password) and byte_size(password) > 0 do
|
||||
Argon2.verify_pass(password, hashed_password)
|
||||
end
|
||||
|
||||
def valid_password?(_, _) do
|
||||
Argon2.no_user_verify()
|
||||
false
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates the current password otherwise adds an error to the changeset.
|
||||
"""
|
||||
def validate_current_password(changeset, password) do
|
||||
if valid_password?(changeset.data, password) do
|
||||
changeset
|
||||
else
|
||||
add_error(changeset, :current_password, "is not valid")
|
||||
end
|
||||
end
|
||||
end
|
79
lib/comfycamp/accounts/user_notifier.ex
Normal file
|
@ -0,0 +1,79 @@
|
|||
defmodule Comfycamp.Accounts.UserNotifier do
|
||||
import Swoosh.Email
|
||||
|
||||
alias Comfycamp.Mailer
|
||||
|
||||
# Delivers the email using the application mailer.
|
||||
defp deliver(recipient, subject, body) do
|
||||
email =
|
||||
new()
|
||||
|> to(recipient)
|
||||
|> from({"Comfycamp", "contact@example.com"})
|
||||
|> subject(subject)
|
||||
|> text_body(body)
|
||||
|
||||
with {:ok, _metadata} <- Mailer.deliver(email) do
|
||||
{:ok, email}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to confirm account.
|
||||
"""
|
||||
def deliver_confirmation_instructions(user, url) do
|
||||
deliver(user.email, "Confirmation instructions", """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can confirm your account by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't create an account with us, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to reset a user password.
|
||||
"""
|
||||
def deliver_reset_password_instructions(user, url) do
|
||||
deliver(user.email, "Reset password instructions", """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can reset your password by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't request this change, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to update a user email.
|
||||
"""
|
||||
def deliver_update_email_instructions(user, url) do
|
||||
deliver(user.email, "Update email instructions", """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can change your email by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't request this change, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
end
|
||||
end
|
179
lib/comfycamp/accounts/user_token.ex
Normal file
|
@ -0,0 +1,179 @@
|
|||
defmodule Comfycamp.Accounts.UserToken do
|
||||
use Ecto.Schema
|
||||
import Ecto.Query
|
||||
alias Comfycamp.Accounts.UserToken
|
||||
|
||||
@hash_algorithm :sha256
|
||||
@rand_size 32
|
||||
|
||||
# It is very important to keep the reset password token expiry short,
|
||||
# since someone with access to the email may take over the account.
|
||||
@reset_password_validity_in_days 1
|
||||
@confirm_validity_in_days 7
|
||||
@change_email_validity_in_days 7
|
||||
@session_validity_in_days 60
|
||||
|
||||
schema "users_tokens" do
|
||||
field :token, :binary
|
||||
field :context, :string
|
||||
field :sent_to, :string
|
||||
belongs_to :user, Comfycamp.Accounts.User
|
||||
|
||||
timestamps(updated_at: false)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a token that will be stored in a signed place,
|
||||
such as session or cookie. As they are signed, those
|
||||
tokens do not need to be hashed.
|
||||
|
||||
The reason why we store session tokens in the database, even
|
||||
though Phoenix already provides a session cookie, is because
|
||||
Phoenix' default session cookies are not persisted, they are
|
||||
simply signed and potentially encrypted. This means they are
|
||||
valid indefinitely, unless you change the signing/encryption
|
||||
salt.
|
||||
|
||||
Therefore, storing them allows individual user
|
||||
sessions to be expired. The token system can also be extended
|
||||
to store additional data, such as the device used for logging in.
|
||||
You could then use this information to display all valid sessions
|
||||
and devices in the UI and allow users to explicitly expire any
|
||||
session they deem invalid.
|
||||
"""
|
||||
def build_session_token(user) do
|
||||
token = :crypto.strong_rand_bytes(@rand_size)
|
||||
{token, %UserToken{token: token, context: "session", user_id: user.id}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the token is valid and returns its underlying lookup query.
|
||||
|
||||
The query returns the user found by the token, if any.
|
||||
|
||||
The token is valid if it matches the value in the database and it has
|
||||
not expired (after @session_validity_in_days).
|
||||
"""
|
||||
def verify_session_token_query(token) do
|
||||
query =
|
||||
from token in by_token_and_context_query(token, "session"),
|
||||
join: user in assoc(token, :user),
|
||||
where: token.inserted_at > ago(@session_validity_in_days, "day"),
|
||||
select: user
|
||||
|
||||
{:ok, query}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Builds a token and its hash to be delivered to the user's email.
|
||||
|
||||
The non-hashed token is sent to the user email while the
|
||||
hashed part is stored in the database. The original token cannot be reconstructed,
|
||||
which means anyone with read-only access to the database cannot directly use
|
||||
the token in the application to gain access. Furthermore, if the user changes
|
||||
their email in the system, the tokens sent to the previous email are no longer
|
||||
valid.
|
||||
|
||||
Users can easily adapt the existing code to provide other types of delivery methods,
|
||||
for example, by phone numbers.
|
||||
"""
|
||||
def build_email_token(user, context) do
|
||||
build_hashed_token(user, context, user.email)
|
||||
end
|
||||
|
||||
defp build_hashed_token(user, context, sent_to) do
|
||||
token = :crypto.strong_rand_bytes(@rand_size)
|
||||
hashed_token = :crypto.hash(@hash_algorithm, token)
|
||||
|
||||
{Base.url_encode64(token, padding: false),
|
||||
%UserToken{
|
||||
token: hashed_token,
|
||||
context: context,
|
||||
sent_to: sent_to,
|
||||
user_id: user.id
|
||||
}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the token is valid and returns its underlying lookup query.
|
||||
|
||||
The query returns the user found by the token, if any.
|
||||
|
||||
The given token is valid if it matches its hashed counterpart in the
|
||||
database and the user email has not changed. This function also checks
|
||||
if the token is being used within a certain period, depending on the
|
||||
context. The default contexts supported by this function are either
|
||||
"confirm", for account confirmation emails, and "reset_password",
|
||||
for resetting the password. For verifying requests to change the email,
|
||||
see `verify_change_email_token_query/2`.
|
||||
"""
|
||||
def verify_email_token_query(token, context) do
|
||||
case Base.url_decode64(token, padding: false) do
|
||||
{:ok, decoded_token} ->
|
||||
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
|
||||
days = days_for_context(context)
|
||||
|
||||
query =
|
||||
from token in by_token_and_context_query(hashed_token, context),
|
||||
join: user in assoc(token, :user),
|
||||
where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email,
|
||||
select: user
|
||||
|
||||
{:ok, query}
|
||||
|
||||
:error ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
defp days_for_context("confirm"), do: @confirm_validity_in_days
|
||||
defp days_for_context("reset_password"), do: @reset_password_validity_in_days
|
||||
|
||||
@doc """
|
||||
Checks if the token is valid and returns its underlying lookup query.
|
||||
|
||||
The query returns the user found by the token, if any.
|
||||
|
||||
This is used to validate requests to change the user
|
||||
email. It is different from `verify_email_token_query/2` precisely because
|
||||
`verify_email_token_query/2` validates the email has not changed, which is
|
||||
the starting point by this function.
|
||||
|
||||
The given token is valid if it matches its hashed counterpart in the
|
||||
database and if it has not expired (after @change_email_validity_in_days).
|
||||
The context must always start with "change:".
|
||||
"""
|
||||
def verify_change_email_token_query(token, "change:" <> _ = context) do
|
||||
case Base.url_decode64(token, padding: false) do
|
||||
{:ok, decoded_token} ->
|
||||
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
|
||||
|
||||
query =
|
||||
from token in by_token_and_context_query(hashed_token, context),
|
||||
where: token.inserted_at > ago(@change_email_validity_in_days, "day")
|
||||
|
||||
{:ok, query}
|
||||
|
||||
:error ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the token struct for the given token value and context.
|
||||
"""
|
||||
def by_token_and_context_query(token, context) do
|
||||
from UserToken, where: [token: ^token, context: ^context]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all tokens for the given user for the given contexts.
|
||||
"""
|
||||
def by_user_and_contexts_query(user, :all) do
|
||||
from t in UserToken, where: t.user_id == ^user.id
|
||||
end
|
||||
|
||||
def by_user_and_contexts_query(user, [_ | _] = contexts) do
|
||||
from t in UserToken, where: t.user_id == ^user.id and t.context in ^contexts
|
||||
end
|
||||
end
|
36
lib/comfycamp/application.ex
Normal file
|
@ -0,0 +1,36 @@
|
|||
defmodule Comfycamp.Application do
|
||||
# See https://hexdocs.pm/elixir/Application.html
|
||||
# for more information on OTP Applications
|
||||
@moduledoc false
|
||||
|
||||
use Application
|
||||
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
ComfycampWeb.Telemetry,
|
||||
Comfycamp.Repo,
|
||||
{DNSCluster, query: Application.get_env(:comfycamp, :dns_cluster_query) || :ignore},
|
||||
{Phoenix.PubSub, name: Comfycamp.PubSub},
|
||||
# Start the Finch HTTP client for sending emails
|
||||
{Finch, name: Comfycamp.Finch},
|
||||
# Start a worker by calling: Comfycamp.Worker.start_link(arg)
|
||||
# {Comfycamp.Worker, arg},
|
||||
# Start to serve requests, typically the last entry
|
||||
ComfycampWeb.Endpoint
|
||||
]
|
||||
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||
# for other strategies and supported options
|
||||
opts = [strategy: :one_for_one, name: Comfycamp.Supervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
end
|
||||
|
||||
# Tell Phoenix to update the endpoint configuration
|
||||
# whenever the application is updated.
|
||||
@impl true
|
||||
def config_change(changed, _new, removed) do
|
||||
ComfycampWeb.Endpoint.config_change(changed, removed)
|
||||
:ok
|
||||
end
|
||||
end
|
3
lib/comfycamp/mailer.ex
Normal file
|
@ -0,0 +1,3 @@
|
|||
defmodule Comfycamp.Mailer do
|
||||
use Swoosh.Mailer, otp_app: :comfycamp
|
||||
end
|
39
lib/comfycamp/note.ex
Normal file
|
@ -0,0 +1,39 @@
|
|||
defmodule Comfycamp.Notes do
|
||||
@moduledoc """
|
||||
The Notes context.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Comfycamp.Repo
|
||||
alias Comfycamp.Notes.Note
|
||||
|
||||
def change_note(%Note{} = note, attrs \\ %{}) do
|
||||
note
|
||||
|> Note.changeset(attrs)
|
||||
end
|
||||
|
||||
def create_note(attrs \\ %{}) do
|
||||
%Note{}
|
||||
|> Note.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def update_note(%Note{} = note, attrs \\ %{}) do
|
||||
note
|
||||
|> Note.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def delete_note(%Note{} = note) do
|
||||
Repo.delete(note)
|
||||
end
|
||||
|
||||
def get_note!(id) do
|
||||
Note
|
||||
|> Repo.get!(id)
|
||||
end
|
||||
|
||||
def list_notes() do
|
||||
Repo.all(Note)
|
||||
end
|
||||
end
|
18
lib/comfycamp/notes/note.ex
Normal file
|
@ -0,0 +1,18 @@
|
|||
defmodule Comfycamp.Notes.Note do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
schema "notes" do
|
||||
field :title, :string
|
||||
field :markdown, :string
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(note, attrs) do
|
||||
note
|
||||
|> cast(attrs, [:title, :markdown])
|
||||
|> validate_required([:title, :markdown])
|
||||
end
|
||||
end
|
28
lib/comfycamp/release.ex
Normal file
|
@ -0,0 +1,28 @@
|
|||
defmodule Comfycamp.Release do
|
||||
@moduledoc """
|
||||
Used for executing DB release tasks when run in production without Mix
|
||||
installed.
|
||||
"""
|
||||
@app :comfycamp
|
||||
|
||||
def migrate do
|
||||
load_app()
|
||||
|
||||
for repo <- repos() do
|
||||
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
|
||||
end
|
||||
end
|
||||
|
||||
def rollback(repo, version) do
|
||||
load_app()
|
||||
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
|
||||
end
|
||||
|
||||
defp repos do
|
||||
Application.fetch_env!(@app, :ecto_repos)
|
||||
end
|
||||
|
||||
defp load_app do
|
||||
Application.load(@app)
|
||||
end
|
||||
end
|
5
lib/comfycamp/repo.ex
Normal file
|
@ -0,0 +1,5 @@
|
|||
defmodule Comfycamp.Repo do
|
||||
use Ecto.Repo,
|
||||
otp_app: :comfycamp,
|
||||
adapter: Ecto.Adapters.Postgres
|
||||
end
|
104
lib/comfycamp/sso.ex
Normal file
|
@ -0,0 +1,104 @@
|
|||
defmodule Comfycamp.SSO do
|
||||
@moduledoc """
|
||||
The SSO context.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Comfycamp.Repo
|
||||
|
||||
alias Comfycamp.SSO.OIDCApp
|
||||
|
||||
@doc """
|
||||
Returns the list of oidc_apps.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_oidc_apps()
|
||||
[%OIDCApp{}, ...]
|
||||
|
||||
"""
|
||||
def list_oidc_apps do
|
||||
Repo.all(OIDCApp)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single oidc_app.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Oidc app does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_oidc_app!(123)
|
||||
%OIDCApp{}
|
||||
|
||||
iex> get_oidc_app!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_oidc_app!(id), do: Repo.get!(OIDCApp, id)
|
||||
|
||||
@doc """
|
||||
Creates a oidc_app.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_oidc_app(%{field: value})
|
||||
{:ok, %OIDCApp{}}
|
||||
|
||||
iex> create_oidc_app(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def create_oidc_app(attrs \\ %{}) do
|
||||
%OIDCApp{}
|
||||
|> OIDCApp.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a oidc_app.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_oidc_app(oidc_app, %{field: new_value})
|
||||
{:ok, %OIDCApp{}}
|
||||
|
||||
iex> update_oidc_app(oidc_app, %{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_oidc_app(%OIDCApp{} = oidc_app, attrs) do
|
||||
oidc_app
|
||||
|> OIDCApp.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a oidc_app.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_oidc_app(oidc_app)
|
||||
{:ok, %OIDCApp{}}
|
||||
|
||||
iex> delete_oidc_app(oidc_app)
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def delete_oidc_app(%OIDCApp{} = oidc_app) do
|
||||
Repo.delete(oidc_app)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking oidc_app changes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_oidc_app(oidc_app)
|
||||
%Ecto.Changeset{data: %OIDCApp{}}
|
||||
|
||||
"""
|
||||
def change_oidc_app(%OIDCApp{} = oidc_app, attrs \\ %{}) do
|
||||
OIDCApp.changeset(oidc_app, attrs)
|
||||
end
|
||||
end
|
23
lib/comfycamp/sso/oidc_app.ex
Normal file
|
@ -0,0 +1,23 @@
|
|||
defmodule Comfycamp.SSO.OIDCApp do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
schema "oidc_apps" do
|
||||
field :enabled, :boolean, default: false
|
||||
field :name, :string
|
||||
field :client_id, :string
|
||||
field :client_secret, :string
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(oidc_app, attrs) do
|
||||
oidc_app
|
||||
|> cast(attrs, [:name, :client_id, :client_secret, :enabled])
|
||||
|> validate_required([:name, :client_id, :client_secret, :enabled])
|
||||
|> validate_length(:name, min: 2)
|
||||
|> validate_length(:client_id, min: 8)
|
||||
|> validate_length(:client_secret, min: 12)
|
||||
end
|
||||
end
|
116
lib/comfycamp_web.ex
Normal file
|
@ -0,0 +1,116 @@
|
|||
defmodule ComfycampWeb do
|
||||
@moduledoc """
|
||||
The entrypoint for defining your web interface, such
|
||||
as controllers, components, channels, and so on.
|
||||
|
||||
This can be used in your application as:
|
||||
|
||||
use ComfycampWeb, :controller
|
||||
use ComfycampWeb, :html
|
||||
|
||||
The definitions below will be executed for every controller,
|
||||
component, etc, so keep them short and clean, focused
|
||||
on imports, uses and aliases.
|
||||
|
||||
Do NOT define functions inside the quoted expressions
|
||||
below. Instead, define additional modules and import
|
||||
those modules here.
|
||||
"""
|
||||
|
||||
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
|
||||
|
||||
def router do
|
||||
quote do
|
||||
use Phoenix.Router, helpers: false
|
||||
|
||||
# Import common connection and controller functions to use in pipelines
|
||||
import Plug.Conn
|
||||
import Phoenix.Controller
|
||||
import Phoenix.LiveView.Router
|
||||
end
|
||||
end
|
||||
|
||||
def channel do
|
||||
quote do
|
||||
use Phoenix.Channel
|
||||
end
|
||||
end
|
||||
|
||||
def controller do
|
||||
quote do
|
||||
use Phoenix.Controller,
|
||||
formats: [:html, :json],
|
||||
layouts: [html: ComfycampWeb.Layouts]
|
||||
|
||||
import Plug.Conn
|
||||
import ComfycampWeb.Gettext
|
||||
|
||||
unquote(verified_routes())
|
||||
end
|
||||
end
|
||||
|
||||
def live_view do
|
||||
quote do
|
||||
use Phoenix.LiveView,
|
||||
layout: {ComfycampWeb.Layouts, :app}
|
||||
|
||||
unquote(html_helpers())
|
||||
end
|
||||
end
|
||||
|
||||
def live_component do
|
||||
quote do
|
||||
use Phoenix.LiveComponent
|
||||
|
||||
unquote(html_helpers())
|
||||
end
|
||||
end
|
||||
|
||||
def html do
|
||||
quote do
|
||||
use Phoenix.Component
|
||||
|
||||
# Import convenience functions from controllers
|
||||
import Phoenix.Controller,
|
||||
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
|
||||
|
||||
# Include general helpers for rendering HTML
|
||||
unquote(html_helpers())
|
||||
end
|
||||
end
|
||||
|
||||
defp html_helpers do
|
||||
quote do
|
||||
# HTML escaping functionality
|
||||
import Phoenix.HTML
|
||||
# Core UI components and translation
|
||||
import ComfycampWeb.Icons
|
||||
import ComfycampWeb.CoreComponents
|
||||
import ComfycampWeb.Flash
|
||||
import ComfycampWeb.Gettext
|
||||
import ComfycampWeb.NavBar
|
||||
|
||||
# Shortcut for generating JS commands
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
# Routes generation with the ~p sigil
|
||||
unquote(verified_routes())
|
||||
end
|
||||
end
|
||||
|
||||
def verified_routes do
|
||||
quote do
|
||||
use Phoenix.VerifiedRoutes,
|
||||
endpoint: ComfycampWeb.Endpoint,
|
||||
router: ComfycampWeb.Router,
|
||||
statics: ComfycampWeb.static_paths()
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
When used, dispatch to the appropriate controller/live_view/etc.
|
||||
"""
|
||||
defmacro __using__(which) when is_atom(which) do
|
||||
apply(__MODULE__, which, [])
|
||||
end
|
||||
end
|
539
lib/comfycamp_web/components/core_components.ex
Normal file
|
@ -0,0 +1,539 @@
|
|||
defmodule ComfycampWeb.CoreComponents do
|
||||
@moduledoc """
|
||||
Provides core UI components.
|
||||
|
||||
At first glance, this module may seem daunting, but its goal is to provide
|
||||
core building blocks for your application, such as modals, tables, and
|
||||
forms. The components consist mostly of markup and are well-documented
|
||||
with doc strings and declarative assigns. You may customize and style
|
||||
them in any way you want, based on your application growth and needs.
|
||||
|
||||
The default components use Tailwind CSS, a utility-first CSS framework.
|
||||
See the [Tailwind CSS documentation](https://tailwindcss.com) to learn
|
||||
how to customize them or feel free to swap in another framework altogether.
|
||||
|
||||
Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
|
||||
"""
|
||||
use Phoenix.Component
|
||||
|
||||
alias Phoenix.LiveView.JS
|
||||
import ComfycampWeb.Gettext
|
||||
import ComfycampWeb.Icons
|
||||
|
||||
@doc """
|
||||
Renders a modal.
|
||||
|
||||
## Examples
|
||||
|
||||
<.modal id="confirm-modal">
|
||||
This is a modal.
|
||||
</.modal>
|
||||
|
||||
JS commands may be passed to the `:on_cancel` to configure
|
||||
the closing/cancel event, for example:
|
||||
|
||||
<.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}>
|
||||
This is another modal.
|
||||
</.modal>
|
||||
|
||||
"""
|
||||
attr :id, :string, required: true
|
||||
attr :show, :boolean, default: false
|
||||
attr :on_cancel, JS, default: %JS{}
|
||||
slot :inner_block, required: true
|
||||
|
||||
def modal(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
phx-mounted={@show && show_modal(@id)}
|
||||
phx-remove={hide_modal(@id)}
|
||||
data-cancel={JS.exec(@on_cancel, "phx-remove")}
|
||||
class="modal"
|
||||
>
|
||||
<div id={"#{@id}-bg"} class="modal-bg" aria-hidden="true" />
|
||||
<div
|
||||
class="modal-body"
|
||||
aria-labelledby={"#{@id}-title"}
|
||||
aria-describedby={"#{@id}-description"}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="0"
|
||||
>
|
||||
<.focus_wrap
|
||||
id={"#{@id}-container"}
|
||||
phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
|
||||
phx-key="escape"
|
||||
phx-click-away={JS.exec("data-cancel", to: "##{@id}")}
|
||||
class="modal-container"
|
||||
>
|
||||
<div class="modal-close-button-container">
|
||||
<button
|
||||
phx-click={JS.exec("data-cancel", to: "##{@id}")}
|
||||
type="button"
|
||||
class="modal-close-button"
|
||||
aria-label={gettext("close")}
|
||||
>
|
||||
<.icon name="hero-x-mark-solid" class="modal-close-button-icon" />
|
||||
</button>
|
||||
</div>
|
||||
<div id={"#{@id}-content"}>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</div>
|
||||
</.focus_wrap>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a simple form.
|
||||
|
||||
## Examples
|
||||
|
||||
<.simple_form for={@form} phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:email]} label="Email"/>
|
||||
<.input field={@form[:username]} label="Username" />
|
||||
<:actions>
|
||||
<.button>Save</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
"""
|
||||
attr :for, :any, required: true, doc: "the datastructure for the form"
|
||||
attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"
|
||||
|
||||
attr :rest, :global,
|
||||
include: ~w(autocomplete name rel action enctype method novalidate target multipart),
|
||||
doc: "the arbitrary HTML attributes to apply to the form tag"
|
||||
|
||||
slot :inner_block, required: true
|
||||
slot :actions, doc: "the slot for form actions, such as a submit button"
|
||||
|
||||
def simple_form(assigns) do
|
||||
~H"""
|
||||
<.form :let={f} for={@for} as={@as} {@rest}>
|
||||
<div class="simple-form">
|
||||
<%= render_slot(@inner_block, f) %>
|
||||
<div :for={action <- @actions} class="simple-form-action">
|
||||
<%= render_slot(action, f) %>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a button.
|
||||
|
||||
## Examples
|
||||
|
||||
<.button>Send!</.button>
|
||||
<.button phx-click="go" class="ml-2">Send!</.button>
|
||||
"""
|
||||
attr :type, :string, default: nil
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global, include: ~w(disabled form name value)
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
def button(assigns) do
|
||||
~H"""
|
||||
<button
|
||||
type={@type}
|
||||
class={[
|
||||
"button",
|
||||
@class
|
||||
]}
|
||||
{@rest}
|
||||
>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an input with label and error messages.
|
||||
|
||||
A `Phoenix.HTML.FormField` may be passed as argument,
|
||||
which is used to retrieve the input name, id, and values.
|
||||
Otherwise all attributes may be passed explicitly.
|
||||
|
||||
## Types
|
||||
|
||||
This function accepts all HTML input types, considering that:
|
||||
|
||||
* You may also set `type="select"` to render a `<select>` tag
|
||||
|
||||
* `type="checkbox"` is used exclusively to render boolean values
|
||||
|
||||
* For live file uploads, see `Phoenix.Component.live_file_input/1`
|
||||
|
||||
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
|
||||
for more information. Unsupported types, such as hidden and radio,
|
||||
are best written directly in your templates.
|
||||
|
||||
## Examples
|
||||
|
||||
<.input field={@form[:email]} type="email" />
|
||||
<.input name="my-input" errors={["oh no!"]} />
|
||||
"""
|
||||
attr :id, :any, default: nil
|
||||
attr :name, :any
|
||||
attr :label, :string, default: nil
|
||||
attr :value, :any
|
||||
|
||||
attr :type, :string,
|
||||
default: "text",
|
||||
values: ~w(checkbox color date datetime-local email file month number password
|
||||
range search select tel text textarea time url week)
|
||||
|
||||
attr :field, Phoenix.HTML.FormField,
|
||||
doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
||||
|
||||
attr :errors, :list, default: []
|
||||
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
|
||||
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
|
||||
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
|
||||
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
|
||||
|
||||
attr :rest, :global,
|
||||
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
|
||||
multiple pattern placeholder readonly required rows size step)
|
||||
|
||||
slot :inner_block
|
||||
|
||||
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
|
||||
assigns
|
||||
|> assign(field: nil, id: assigns.id || field.id)
|
||||
|> assign(:errors, Enum.map(field.errors, &translate_error(&1)))
|
||||
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|
||||
|> assign_new(:value, fn -> field.value end)
|
||||
|> input()
|
||||
end
|
||||
|
||||
def input(%{type: "checkbox"} = assigns) do
|
||||
assigns =
|
||||
assign_new(assigns, :checked, fn ->
|
||||
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
|
||||
end)
|
||||
|
||||
~H"""
|
||||
<div phx-feedback-for={@name}>
|
||||
<label class="checkbox-label">
|
||||
<input type="hidden" name={@name} value="false" />
|
||||
<input
|
||||
type="checkbox"
|
||||
id={@id}
|
||||
name={@name}
|
||||
value="true"
|
||||
checked={@checked}
|
||||
class="checkbox-input"
|
||||
{@rest}
|
||||
/>
|
||||
<%= @label %>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "select"} = assigns) do
|
||||
~H"""
|
||||
<div phx-feedback-for={@name}>
|
||||
<.label for={@id}><%= @label %></.label>
|
||||
<select id={@id} name={@name} class="select" multiple={@multiple} {@rest}>
|
||||
<option :if={@prompt} value=""><%= @prompt %></option>
|
||||
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
|
||||
</select>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "textarea"} = assigns) do
|
||||
~H"""
|
||||
<div phx-feedback-for={@name}>
|
||||
<.label for={@id}><%= @label %></.label>
|
||||
<textarea
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={[
|
||||
"textarea",
|
||||
@errors == [] && "",
|
||||
@errors != [] && ""
|
||||
]}
|
||||
{@rest}
|
||||
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# All other inputs text, datetime-local, url, password, etc. are handled here...
|
||||
def input(assigns) do
|
||||
~H"""
|
||||
<div phx-feedback-for={@name}>
|
||||
<.label for={@id}><%= @label %></.label>
|
||||
<input
|
||||
type={@type}
|
||||
name={@name}
|
||||
id={@id}
|
||||
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
||||
class={[
|
||||
"input",
|
||||
@errors == [] && "",
|
||||
@errors != [] && ""
|
||||
]}
|
||||
{@rest}
|
||||
/>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a label.
|
||||
"""
|
||||
attr :for, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
|
||||
def label(assigns) do
|
||||
~H"""
|
||||
<label for={@for} class="label">
|
||||
<%= render_slot(@inner_block) %>
|
||||
</label>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a generic error message.
|
||||
"""
|
||||
slot :inner_block, required: true
|
||||
|
||||
def error(assigns) do
|
||||
~H"""
|
||||
<p class="error">
|
||||
<.exclamation_circle_icon /> <%= render_slot(@inner_block) %>
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a header with title.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
|
||||
slot :inner_block, required: true
|
||||
slot :subtitle
|
||||
slot :actions
|
||||
|
||||
def header(assigns) do
|
||||
~H"""
|
||||
<header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}>
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold leading-8 text-zinc-800">
|
||||
<%= render_slot(@inner_block) %>
|
||||
</h1>
|
||||
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
|
||||
<%= render_slot(@subtitle) %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-none"><%= render_slot(@actions) %></div>
|
||||
</header>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc ~S"""
|
||||
Renders a table with generic styling.
|
||||
|
||||
## Examples
|
||||
|
||||
<.table id="users" rows={@users}>
|
||||
<:col :let={user} label="id"><%= user.id %></:col>
|
||||
<:col :let={user} label="username"><%= user.username %></:col>
|
||||
</.table>
|
||||
"""
|
||||
attr :id, :string, required: true
|
||||
attr :rows, :list, required: true
|
||||
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
|
||||
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
|
||||
|
||||
attr :row_item, :any,
|
||||
default: &Function.identity/1,
|
||||
doc: "the function for mapping each row before calling the :col and :action slots"
|
||||
|
||||
slot :col, required: true do
|
||||
attr :label, :string
|
||||
end
|
||||
|
||||
slot :action, doc: "the slot for showing user actions in the last table column"
|
||||
|
||||
def table(assigns) do
|
||||
assigns =
|
||||
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
|
||||
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
|
||||
end
|
||||
|
||||
~H"""
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead class="thead">
|
||||
<tr>
|
||||
<th :for={col <- @col} class="th"><%= col[:label] %></th>
|
||||
<th :if={@action != []} class="th-actions">
|
||||
<span class="sr-only"><%= gettext("Actions") %></span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
id={@id}
|
||||
phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}
|
||||
class="tbody"
|
||||
>
|
||||
<tr :for={row <- @rows} id={@row_id && @row_id.(row)} class="">
|
||||
<td
|
||||
:for={{col, i} <- Enum.with_index(@col)}
|
||||
phx-click={@row_click && @row_click.(row)}
|
||||
class={["td", @row_click && "hover:cursor-pointer"]}
|
||||
>
|
||||
<div class="block py-4 pr-6">
|
||||
<span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl" />
|
||||
<span class={["relative", i == 0 && "font-semibold text-zinc-900"]}>
|
||||
<%= render_slot(col, @row_item.(row)) %>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td :if={@action != []} class="relative w-14 p-0">
|
||||
<div class="relative whitespace-nowrap py-4 text-right text-sm font-medium">
|
||||
<span class="absolute -inset-y-px -right-4 left-0 group-hover:bg-zinc-50 sm:rounded-r-xl" />
|
||||
<span
|
||||
:for={action <- @action}
|
||||
class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
|
||||
>
|
||||
<%= render_slot(action, @row_item.(row)) %>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a data list.
|
||||
|
||||
## Examples
|
||||
|
||||
<.list>
|
||||
<:item title="Title"><%= @post.title %></:item>
|
||||
<:item title="Views"><%= @post.views %></:item>
|
||||
</.list>
|
||||
"""
|
||||
slot :item, required: true do
|
||||
attr :title, :string, required: true
|
||||
end
|
||||
|
||||
def list(assigns) do
|
||||
~H"""
|
||||
<div class="list">
|
||||
<dl class="list-description">
|
||||
<div :for={item <- @item} class="list-item">
|
||||
<dt class="list-item-title"><%= item.title %></dt>
|
||||
<dd class="list-item-description"><%= render_slot(item) %></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a back navigation link.
|
||||
|
||||
## Examples
|
||||
|
||||
<.back navigate={~p"/posts"}>Back to posts</.back>
|
||||
"""
|
||||
attr :navigate, :any, required: true
|
||||
slot :inner_block, required: true
|
||||
|
||||
def back(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<.link navigate={@navigate} class="back-nav-link">
|
||||
<.arrow_left_icon /> <%= render_slot(@inner_block) %>
|
||||
</.link>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
## JS Commands
|
||||
|
||||
def show(js \\ %JS{}, selector) do
|
||||
JS.show(js,
|
||||
to: selector,
|
||||
transition: "show"
|
||||
)
|
||||
end
|
||||
|
||||
def hide(js \\ %JS{}, selector) do
|
||||
JS.hide(js,
|
||||
to: selector,
|
||||
time: 200,
|
||||
transition: "hide"
|
||||
)
|
||||
end
|
||||
|
||||
def show_modal(js \\ %JS{}, id) when is_binary(id) do
|
||||
js
|
||||
|> JS.show(to: "##{id}")
|
||||
|> JS.show(
|
||||
to: "##{id}-bg",
|
||||
transition: "show-modal"
|
||||
)
|
||||
|> show("##{id}-container")
|
||||
|> JS.add_class("overflow-hidden", to: "body")
|
||||
|> JS.focus_first(to: "##{id}-content")
|
||||
end
|
||||
|
||||
def hide_modal(js \\ %JS{}, id) do
|
||||
js
|
||||
|> JS.hide(
|
||||
to: "##{id}-bg",
|
||||
transition: "hide-modal"
|
||||
)
|
||||
|> hide("##{id}-container")
|
||||
|> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"})
|
||||
|> JS.remove_class("overflow-hidden", to: "body")
|
||||
|> JS.pop_focus()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Translates an error message using gettext.
|
||||
"""
|
||||
def translate_error({msg, opts}) do
|
||||
# When using gettext, we typically pass the strings we want
|
||||
# to translate as a static argument:
|
||||
#
|
||||
# # Translate the number of files with plural rules
|
||||
# dngettext("errors", "1 file", "%{count} files", count)
|
||||
#
|
||||
# However the error messages in our forms and APIs are generated
|
||||
# dynamically, so we need to translate them by calling Gettext
|
||||
# with our gettext backend as first argument. Translations are
|
||||
# available in the errors.po file (as we use the "errors" domain).
|
||||
if count = opts[:count] do
|
||||
Gettext.dngettext(ComfycampWeb.Gettext, "errors", msg, msg, count, opts)
|
||||
else
|
||||
Gettext.dgettext(ComfycampWeb.Gettext, "errors", msg, opts)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Translates the errors for a field from a keyword list of errors.
|
||||
"""
|
||||
def translate_errors(errors, field) when is_list(errors) do
|
||||
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
|
||||
end
|
||||
end
|
97
lib/comfycamp_web/components/flash.ex
Normal file
|
@ -0,0 +1,97 @@
|
|||
defmodule ComfycampWeb.Flash do
|
||||
@moduledoc """
|
||||
Default flash component.
|
||||
"""
|
||||
use Phoenix.Component
|
||||
alias Phoenix.LiveView.JS
|
||||
import ComfycampWeb.Gettext
|
||||
import ComfycampWeb.CoreComponents
|
||||
import ComfycampWeb.Icons
|
||||
|
||||
@doc """
|
||||
Renders flash notices.
|
||||
|
||||
## Examples
|
||||
|
||||
<.flash kind={:info} flash={@flash} />
|
||||
<.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash>
|
||||
"""
|
||||
attr :id, :string, doc: "the optional id of flash container"
|
||||
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
|
||||
attr :title, :string, default: nil
|
||||
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
|
||||
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
|
||||
|
||||
slot :inner_block, doc: "the optional inner block that renders the flash message"
|
||||
|
||||
def flash(assigns) do
|
||||
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
|
||||
id={@id}
|
||||
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
||||
role="alert"
|
||||
class={[
|
||||
"flash",
|
||||
@kind == :info && "flash-info",
|
||||
@kind == :error && "flash-error"
|
||||
]}
|
||||
{@rest}
|
||||
>
|
||||
<p :if={@title} class="flash-title">
|
||||
<.exclamation_circle_icon :if={@kind == :error} />
|
||||
<.information_circle_icon :if={@kind == :info} />
|
||||
<%= @title %>
|
||||
</p>
|
||||
<p class="flash-body"><%= msg %></p>
|
||||
<button type="button" class="flash-close-button" aria-label={gettext("close")}>
|
||||
<.x_mark_icon class="flash-close-button-icon" />
|
||||
</button>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Shows the flash group with standard titles and content.
|
||||
|
||||
## Examples
|
||||
|
||||
<.flash_group flash={@flash} />
|
||||
"""
|
||||
attr :flash, :map, required: true, doc: "the map of flash messages"
|
||||
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
|
||||
|
||||
def flash_group(assigns) do
|
||||
~H"""
|
||||
<div id={@id}>
|
||||
<.flash kind={:info} title={gettext("Success!")} flash={@flash} />
|
||||
<.flash kind={:error} title={gettext("Error!")} flash={@flash} />
|
||||
<.flash
|
||||
id="client-error"
|
||||
kind={:error}
|
||||
title={gettext("We can't find the internet")}
|
||||
phx-disconnected={show(".phx-client-error #client-error")}
|
||||
phx-connected={hide("#client-error")}
|
||||
hidden
|
||||
>
|
||||
<%= gettext("Attempting to reconnect") %>
|
||||
<.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
|
||||
</.flash>
|
||||
|
||||
<.flash
|
||||
id="server-error"
|
||||
kind={:error}
|
||||
title={gettext("Something went wrong!")}
|
||||
phx-disconnected={show(".phx-server-error #server-error")}
|
||||
phx-connected={hide("#server-error")}
|
||||
hidden
|
||||
>
|
||||
<%= gettext("Hang in there while we get back on track") %>
|
||||
<.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
|
||||
</.flash>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
17
lib/comfycamp_web/components/icons.ex
Normal file
|
@ -0,0 +1,17 @@
|
|||
defmodule ComfycampWeb.Icons do
|
||||
@moduledoc """
|
||||
Provides reusable svg icons.
|
||||
"""
|
||||
use Phoenix.Component
|
||||
|
||||
embed_templates "icons/*"
|
||||
|
||||
attr :name, :string, default: nil
|
||||
attr :class, :string, default: nil
|
||||
|
||||
def icon(assigns) do
|
||||
~H"""
|
||||
Missing icon
|
||||
"""
|
||||
end
|
||||
end
|
10
lib/comfycamp_web/components/icons/arrow_left_icon.svg.heex
Normal file
|
@ -0,0 +1,10 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="size-6"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
|
||||
</svg>
|
After Width: | Height: | Size: 247 B |
|
@ -0,0 +1,13 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 341 B |
|
@ -0,0 +1,14 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="icon"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 294 B |
14
lib/comfycamp_web/components/icons/envelope_icon.svg.heex
Normal file
|
@ -0,0 +1,14 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="icon"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 478 B |
|
@ -0,0 +1,14 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="icon"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 299 B |
|
@ -0,0 +1,14 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="icon"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 376 B |
|
@ -0,0 +1,9 @@
|
|||
<svg
|
||||
role="img"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon"
|
||||
>
|
||||
<path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z" />
|
||||
</svg>
|
After Width: | Height: | Size: 1.2 KiB |
9
lib/comfycamp_web/components/icons/matrix_icon.svg.heex
Normal file
|
@ -0,0 +1,9 @@
|
|||
<svg
|
||||
role="img"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon"
|
||||
>
|
||||
<path d="M.632.55v22.9H2.28V24H0V0h2.28v.55zm7.043 7.26v1.157h.033c.309-.443.683-.784 1.117-1.024.433-.245.936-.365 1.5-.365.54 0 1.033.107 1.481.314.448.208.785.582 1.02 1.108.254-.374.6-.706 1.034-.992.434-.287.95-.43 1.546-.43.453 0 .872.056 1.26.167.388.11.716.286.993.53.276.245.489.559.646.951.152.392.23.863.23 1.417v5.728h-2.349V11.52c0-.286-.01-.559-.032-.812a1.755 1.755 0 0 0-.18-.66 1.106 1.106 0 0 0-.438-.448c-.194-.11-.457-.166-.785-.166-.332 0-.6.064-.803.189a1.38 1.38 0 0 0-.48.499 1.946 1.946 0 0 0-.231.696 5.56 5.56 0 0 0-.06.785v4.768h-2.35v-4.8c0-.254-.004-.503-.018-.752a2.074 2.074 0 0 0-.143-.688 1.052 1.052 0 0 0-.415-.503c-.194-.125-.476-.19-.854-.19-.111 0-.259.024-.439.074-.18.051-.36.143-.53.282-.171.138-.319.337-.439.595-.12.259-.18.6-.18 1.02v4.966H5.46V7.81zm15.693 15.64V.55H21.72V0H24v24h-2.28v-.55z" />
|
||||
</svg>
|
After Width: | Height: | Size: 968 B |
10
lib/comfycamp_web/components/icons/x_mark_icon.svg.heex
Normal file
|
@ -0,0 +1,10 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="icon"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
After Width: | Height: | Size: 230 B |
9
lib/comfycamp_web/components/icons/xmpp_icon.svg.heex
Normal file
|
@ -0,0 +1,9 @@
|
|||
<svg
|
||||
role="img"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon"
|
||||
>
|
||||
<path d="m3.401 4.594 1.025.366 3.08.912c-.01.18-.016.361-.016.543 0 3.353 1.693 7.444 4.51 10.387 2.817-2.943 4.51-7.034 4.51-10.387 0-.182-.006-.363-.016-.543l3.08-.912 1.025-.366L24 3.276C23.854 8.978 19.146 14.9 13.502 18.17c1.302 1.028 2.778 1.81 4.388 2.215v.114l.004.001v.224a14.55 14.55 0 0 1-4.829-1.281A20.909 20.909 0 0 1 12 18.966c-.353.17-.708.329-1.065.477a14.55 14.55 0 0 1-4.829 1.281V20.5l.004-.001v-.113c1.61-.406 3.086-1.188 4.389-2.216C4.854 14.9.146 8.978 0 3.276l3.401 1.318Z" />
|
||||
</svg>
|
After Width: | Height: | Size: 627 B |
|
@ -0,0 +1,3 @@
|
|||
<svg viewBox="0 0 22.5 22.5" xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="icon">
|
||||
<path d="M2.662 22.31c.065-.264.833-2.14 1.51-3.685.692-1.58.68-1.518.486-2.31-.24-.986-.226-2.594.032-3.447.93-3.081 3.344-5.17 8.587-7.426 1.708-.736 2.646-1.207 3.357-1.689 1.254-.849 2.559-2.321 2.976-3.36.088-.218.187-.395.22-.393.102.007.051 3.11-.069 4.192-.745 6.696-3.016 11.05-6.755 12.955-2.158 1.1-5.56 1.507-7.144.856-.224-.092-.46-.168-.524-.168-.241 0-1.106 2.31-1.355 3.618-.076.405-.194.962-.21 1.047-.387-.003-.026 0-.575 0h-.583z" />
|
||||
</svg>
|
After Width: | Height: | Size: 560 B |
14
lib/comfycamp_web/components/layouts.ex
Normal file
|
@ -0,0 +1,14 @@
|
|||
defmodule ComfycampWeb.Layouts do
|
||||
@moduledoc """
|
||||
This module holds different layouts used by your application.
|
||||
|
||||
See the `layouts` directory for all templates available.
|
||||
The "root" layout is a skeleton rendered as part of the
|
||||
application router. The "app" layout is set as the default
|
||||
layout on both `use ComfycampWeb, :controller` and
|
||||
`use ComfycampWeb, :live_view`.
|
||||
"""
|
||||
use ComfycampWeb, :html
|
||||
|
||||
embed_templates "layouts/*"
|
||||
end
|
33
lib/comfycamp_web/components/layouts/admin.html.heex
Normal file
|
@ -0,0 +1,33 @@
|
|||
<.flash_group flash={@flash} />
|
||||
|
||||
<main>
|
||||
<div class="limiter">
|
||||
<h1>Панель администратора</h1>
|
||||
<.back navigate={~p"/"}>Главная страница</.back>
|
||||
<div class="admin-panel">
|
||||
<ul class="menu">
|
||||
<li>
|
||||
<.link href={~p"/admin"}>
|
||||
Управление
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link href={~p"/admin/notes"}>
|
||||
Заметки
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link href={~p"/admin/users"}>
|
||||
Пользователи
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link href={~p"/admin/oidc_apps"}>
|
||||
OpenID
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
<%= @inner_content %>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
40
lib/comfycamp_web/components/layouts/app.html.heex
Normal file
|
@ -0,0 +1,40 @@
|
|||
<.navbar current_user={@current_user} />
|
||||
|
||||
<.flash_group flash={@flash} />
|
||||
|
||||
<main>
|
||||
<div class="limiter">
|
||||
<%= @inner_content %>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="limiter">
|
||||
<div class="link-list">
|
||||
<a href="http://[201:80ed:6eeb:aea4:cdc0:c836:2831:f2dd]">
|
||||
<.yggdrasil_icon /> Yggdrasil
|
||||
</a>
|
||||
|
||||
<a href="https://git.comfycamp.space/lumin/comfycamp" target="_blank">
|
||||
<.code_bracket_icon /> Исходный код
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="link-list">
|
||||
<a href="https://m.comfycamp.space/@lumin" rel="me" target="_blank">
|
||||
<.mastodon_icon /> Mastodon
|
||||
</a>
|
||||
<a href="mailto:admin@comfycamp.space" target="_blank">
|
||||
<.envelope_icon /> admin@comfycamp.space
|
||||
</a>
|
||||
</div>
|
||||
<div class="link-list">
|
||||
<a href="https://matrix.to/#/@lumin:matrix.comfycamp.space" target="_blank">
|
||||
<.matrix_icon /> @lumin:matrix.comfycamp.space
|
||||
</a>
|
||||
<a href="xmpp://lumin@xmpp.comfycamp.space" target="_blank">
|
||||
<.xmpp_icon /> lumin@xmpp.comfycamp.space
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
36
lib/comfycamp_web/components/layouts/root.html.heex
Normal file
|
@ -0,0 +1,36 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru" class="[scrollbar-gutter:stable]">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="csrf-token" content={get_csrf_token()} />
|
||||
<.live_title suffix=" - Comfycamp">
|
||||
<%= assigns[:page_title] || "Comfycamp" %>
|
||||
</.live_title>
|
||||
<meta
|
||||
name="description"
|
||||
content={
|
||||
assigns[:page_description] ||
|
||||
"Личный сайт человека, который любит рассказывать про интернет."
|
||||
}
|
||||
/>
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
|
||||
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
|
||||
</script>
|
||||
<link
|
||||
rel="alternate"
|
||||
type="text/html"
|
||||
href={"http://[201:80ed:6eeb:aea4:cdc0:c836:2831:f2dd]" <> @conn.request_path}
|
||||
/>
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href={~p"/images/favicons/vector.svg"} />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href={~p"/images/favicons/16.png"} />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href={~p"/images/favicons/32.png"} />
|
||||
<link rel="apple-touch-icon" type="image/png" href={~p"/images/favicons/180.png"} />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href={~p"/images/favicons/192.png"} />
|
||||
<link rel="shortcut icon" type="image/x-icon" href={~p"/images/favicons/shortcut.ico"} />
|
||||
</head>
|
||||
<body>
|
||||
<%= @inner_content %>
|
||||
</body>
|
||||
</html>
|
48
lib/comfycamp_web/components/navbar.ex
Normal file
|
@ -0,0 +1,48 @@
|
|||
defmodule ComfycampWeb.NavBar do
|
||||
use Phoenix.Component
|
||||
use ComfycampWeb, :verified_routes
|
||||
|
||||
alias Comfycamp.Accounts.User
|
||||
|
||||
@doc """
|
||||
Navigation bar.
|
||||
|
||||
## Examples
|
||||
|
||||
<.navbar current_user={@current_user} />
|
||||
"""
|
||||
attr :current_user, User, required: false
|
||||
|
||||
def navbar(assigns) do
|
||||
~H"""
|
||||
<nav class="limiter navbar">
|
||||
<.link href={~p"/"}>Главная</.link>
|
||||
<.link href={~p"/notes"}>Заметки</.link>
|
||||
<.link href={~p"/cinema"}>Кинотеатр</.link>
|
||||
|
||||
<div class="space" />
|
||||
|
||||
<%= if @current_user do %>
|
||||
<.link :if={@current_user.is_admin} href={~p"/admin"}>
|
||||
Админка
|
||||
</.link>
|
||||
<.link navigate={~p"/users/settings"}>
|
||||
Настройки
|
||||
</.link>
|
||||
|
||||
<.link href={~p"/users/log_out"} method="delete">
|
||||
Выйти
|
||||
</.link>
|
||||
<% else %>
|
||||
<.link navigate={~p"/users/register"}>
|
||||
Зарегистрироваться
|
||||
</.link>
|
||||
|
||||
<.link navigate={~p"/users/log_in"}>
|
||||
Войти
|
||||
</.link>
|
||||
<% end %>
|
||||
</nav>
|
||||
"""
|
||||
end
|
||||
end
|
23
lib/comfycamp_web/controllers/admin_page_controller.ex
Normal file
|
@ -0,0 +1,23 @@
|
|||
defmodule ComfycampWeb.AdminPageController do
|
||||
use ComfycampWeb, :controller
|
||||
alias Comfycamp.Accounts
|
||||
|
||||
def home(conn, _params) do
|
||||
user_count = Accounts.count_users()
|
||||
unapproved_user_count = Accounts.count_unapproved_users()
|
||||
|
||||
conn
|
||||
|> put_layout(html: :admin)
|
||||
|> render(:home,
|
||||
page_title: "Админка",
|
||||
user_count: user_count,
|
||||
unapproved_user_count: unapproved_user_count
|
||||
)
|
||||
end
|
||||
|
||||
def services(conn, _params) do
|
||||
conn
|
||||
|> put_layout(html: :admin)
|
||||
|> render(:home, page_title: "Админка")
|
||||
end
|
||||
end
|
33
lib/comfycamp_web/controllers/admin_page_html.ex
Normal file
|
@ -0,0 +1,33 @@
|
|||
defmodule ComfycampWeb.AdminPageHTML do
|
||||
use ComfycampWeb, :html
|
||||
|
||||
def home(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<h3>Управление сайтом</h3>
|
||||
<div class="stats">
|
||||
<.stat name="Всего пользователей" value={@user_count} />
|
||||
<.stat
|
||||
name="Неодобренных пользователей"
|
||||
value={@unapproved_user_count}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Card for statistical data.
|
||||
"""
|
||||
attr :name, :string, required: true
|
||||
attr :value, :any, required: true
|
||||
|
||||
def stat(assigns) do
|
||||
~H"""
|
||||
<div class="stat">
|
||||
<div class="value"><%= @value %></div>
|
||||
<div class="name"><%= @name %></div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
8
lib/comfycamp_web/controllers/cinema_controller.ex
Normal file
|
@ -0,0 +1,8 @@
|
|||
defmodule ComfycampWeb.CinemaController do
|
||||
use ComfycampWeb, :controller
|
||||
|
||||
def index(conn, _params) do
|
||||
conn
|
||||
|> render(:index, page_title: "Кинотеатр")
|
||||
end
|
||||
end
|
12
lib/comfycamp_web/controllers/cinema_html.ex
Normal file
|
@ -0,0 +1,12 @@
|
|||
defmodule ComfycampWeb.CinemaHTML do
|
||||
@moduledoc """
|
||||
This module contains pages rendered by CinemaController.
|
||||
"""
|
||||
use ComfycampWeb, :html
|
||||
|
||||
def index(assigns) do
|
||||
~H"""
|
||||
<p>Кинотеатр</p>
|
||||
"""
|
||||
end
|
||||
end
|
24
lib/comfycamp_web/controllers/error_html.ex
Normal file
|
@ -0,0 +1,24 @@
|
|||
defmodule ComfycampWeb.ErrorHTML do
|
||||
@moduledoc """
|
||||
This module is invoked by your endpoint in case of errors on HTML requests.
|
||||
|
||||
See config/config.exs.
|
||||
"""
|
||||
use ComfycampWeb, :html
|
||||
|
||||
# If you want to customize your error pages,
|
||||
# uncomment the embed_templates/1 call below
|
||||
# and add pages to the error directory:
|
||||
#
|
||||
# * lib/comfycamp_web/controllers/error_html/404.html.heex
|
||||
# * lib/comfycamp_web/controllers/error_html/500.html.heex
|
||||
#
|
||||
# embed_templates "error_html/*"
|
||||
|
||||
# The default is to render a plain text page based on
|
||||
# the template name. For example, "404.html" becomes
|
||||
# "Not Found".
|
||||
def render(template, _assigns) do
|
||||
Phoenix.Controller.status_message_from_template(template)
|
||||
end
|
||||
end
|
21
lib/comfycamp_web/controllers/error_json.ex
Normal file
|
@ -0,0 +1,21 @@
|
|||
defmodule ComfycampWeb.ErrorJSON do
|
||||
@moduledoc """
|
||||
This module is invoked by your endpoint in case of errors on JSON requests.
|
||||
|
||||
See config/config.exs.
|
||||
"""
|
||||
|
||||
# If you want to customize a particular status code,
|
||||
# you may add your own clauses, such as:
|
||||
#
|
||||
# def render("500.json", _assigns) do
|
||||
# %{errors: %{detail: "Internal Server Error"}}
|
||||
# end
|
||||
|
||||
# By default, Phoenix returns the status message from
|
||||
# the template name. For example, "404.json" becomes
|
||||
# "Not Found".
|
||||
def render(template, _assigns) do
|
||||
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
|
||||
end
|
||||
end
|
15
lib/comfycamp_web/controllers/home_controller.ex
Normal file
|
@ -0,0 +1,15 @@
|
|||
defmodule ComfycampWeb.HomeController do
|
||||
use ComfycampWeb, :controller
|
||||
|
||||
def index(conn, _params) do
|
||||
render(conn, :index, page_title: "Главная")
|
||||
end
|
||||
|
||||
def mastodon(conn, _params) do
|
||||
render(conn, :mastodon, page_title: "Mastodon")
|
||||
end
|
||||
|
||||
def nextcloud(conn, _params) do
|
||||
render(conn, :nextcloud, page_title: "Nextcloud")
|
||||
end
|
||||
end
|
140
lib/comfycamp_web/controllers/home_html.ex
Normal file
|
@ -0,0 +1,140 @@
|
|||
defmodule ComfycampWeb.HomeHTML do
|
||||
@moduledoc """
|
||||
This module contains pages rendered by HomeController.
|
||||
"""
|
||||
use ComfycampWeb, :html
|
||||
import ComfycampWeb.Icons
|
||||
|
||||
def index(assigns) do
|
||||
~H"""
|
||||
<div class="home">
|
||||
<h1>Уютный домик</h1>
|
||||
|
||||
<%= if assigns[:current_user] != nil and @current_user.is_approved == false do %>
|
||||
<p class="warning">
|
||||
Ваш аккаунт ещё не был одобрен, вы не сможете использовать другие сервисы.
|
||||
Подождите немного.
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<p>
|
||||
У меня есть несколько проектов, запущенных на домашнем сервере.
|
||||
Я буду рад, если они будут полезны другим людям.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Создайте аккаунт на этом сайте, чтобы получить доступ к остальным сервисам.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Не стоит ожидать большой надёжности, однако я прикладываю все усилия,
|
||||
чтобы сервисы были доступны 24/7.
|
||||
</p>
|
||||
|
||||
<p><i>- Иван, администратор comfycamp.space</i></p>
|
||||
|
||||
<h2>Доступные сервисы</h2>
|
||||
|
||||
<.service
|
||||
name="Mastodon"
|
||||
domain="m.comfycamp.space"
|
||||
description="Микроблоги с поддержкой fediverse."
|
||||
learn_more_url="/services/mastodon"
|
||||
/>
|
||||
|
||||
<.service
|
||||
name="Nextcloud"
|
||||
domain="nc.comfycamp.space"
|
||||
description="Облако, календарь, задачи."
|
||||
learn_more_url="/services/nextcloud"
|
||||
/>
|
||||
|
||||
<.service
|
||||
name="Forgejo"
|
||||
domain="git.comfycamp.space"
|
||||
description="Хостинг для git-проектов."
|
||||
/>
|
||||
|
||||
<.service
|
||||
name="XMPP"
|
||||
domain="xmpp.comfycamp.space"
|
||||
description="Проверенный временем протокол для обмена сообщениями."
|
||||
enable_link={false}
|
||||
/>
|
||||
|
||||
<.service
|
||||
name="Matrix"
|
||||
domain="matrix.comfycamp.space"
|
||||
description="Современный протокол для общения."
|
||||
enable_link={false}
|
||||
/>
|
||||
|
||||
<.service
|
||||
name="Fresh RSS"
|
||||
domain="freshrss.comfycamp.space"
|
||||
description="Сервис для чтения RSS лент."
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
A component representing one service, like mastodon or nextcloud.
|
||||
"""
|
||||
attr :name, :string, required: true
|
||||
attr :domain, :string, required: true
|
||||
attr :description, :string, required: true
|
||||
attr :enable_link, :boolean, required: false, default: true
|
||||
attr :learn_more_url, :string, required: false
|
||||
|
||||
def service(assigns) do
|
||||
~H"""
|
||||
<div class="service">
|
||||
<h3><%= @name %></h3>
|
||||
<%= if @enable_link do %>
|
||||
<a class="link" href={"https://" <> @domain} target="_blank">
|
||||
<%= @domain %> <.arrow_top_right_on_square_icon />
|
||||
</a>
|
||||
<% else %>
|
||||
<span class="link"><%= @domain %></span>
|
||||
<% end %>
|
||||
<p>
|
||||
<%= @description %>
|
||||
<%= if assigns[:learn_more_url] do %>
|
||||
<.link href={@learn_more_url}>Узнать больше</.link>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def mastodon(assigns) do
|
||||
~H"""
|
||||
<div class="home">
|
||||
<h1>Mastodon</h1>
|
||||
<a href="https://m.comfycamp.space" target="_blank">m.comfycamp.space</a>
|
||||
<p>Удобный сервис для ведения микроблогов. Простыми словами, это альтернатива Twitter.</p>
|
||||
<p>
|
||||
Mastodon умеет подключаться к другим сервисам внутри сети Fediverse.
|
||||
Так, например, вы можете общаться с пользователями Pleroma, Misskey, Friendica, Pixelfed,
|
||||
даже если они находятся на других серверах.
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def nextcloud(assigns) do
|
||||
~H"""
|
||||
<div class="home">
|
||||
<h1>Nextcloud</h1>
|
||||
<a href="https://nc.comfycamp.space" target="_blank">nc.comfycamp.space</a>
|
||||
<p>
|
||||
Облако, способное заменить десяток сервисов.
|
||||
При помощи Nextcloud вы можете хранить файлы, вести календарь и список задач,
|
||||
синхронизировать контакты, читать почту и новости...
|
||||
А если этого будет мало, вам доступны десятки приложений на любой вкус и цвет.
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
25
lib/comfycamp_web/controllers/notes_controller.ex
Normal file
|
@ -0,0 +1,25 @@
|
|||
defmodule ComfycampWeb.NotesController do
|
||||
use ComfycampWeb, :controller
|
||||
|
||||
alias Comfycamp.Notes
|
||||
|
||||
def index(conn, _params) do
|
||||
notes = Notes.list_notes()
|
||||
|
||||
render(conn, :index, page_title: "Заметки", notes: notes)
|
||||
end
|
||||
|
||||
def show(conn, %{"id" => id}) do
|
||||
note = Notes.get_note!(id)
|
||||
|
||||
case Earmark.as_html(note.markdown) do
|
||||
{:ok, note_body, _deprecation_messages} ->
|
||||
render(conn, :show, page_title: note.title, note: note, note_body: note_body)
|
||||
|
||||
{:error, note_body, error_messages} ->
|
||||
conn
|
||||
|> put_flash(:error, error_messages)
|
||||
|> render(:show, page_title: note.title, note: note, note_body: note_body)
|
||||
end
|
||||
end
|
||||
end
|
109
lib/comfycamp_web/controllers/notes_editor_controller.ex
Normal file
|
@ -0,0 +1,109 @@
|
|||
defmodule ComfycampWeb.NotesEditorController do
|
||||
use ComfycampWeb, :controller
|
||||
|
||||
alias Comfycamp.Notes
|
||||
alias Comfycamp.Notes.Note
|
||||
|
||||
def index(conn, _params) do
|
||||
notes = Notes.list_notes()
|
||||
|
||||
conn
|
||||
|> put_layout(html: :admin)
|
||||
|> render(:index, page_title: "Заметки", notes: notes)
|
||||
end
|
||||
|
||||
def new(conn, _params) do
|
||||
changeset = Notes.change_note(%Note{})
|
||||
|
||||
conn
|
||||
|> put_layout(html: :admin)
|
||||
|> render(:new,
|
||||
page_title: "Новая заметка",
|
||||
changeset: changeset,
|
||||
stylesheets: ["/assets/admin.css"]
|
||||
)
|
||||
end
|
||||
|
||||
def edit(conn, %{"id" => id}) do
|
||||
note = Notes.get_note!(id)
|
||||
|
||||
changeset = Notes.change_note(note)
|
||||
|
||||
conn
|
||||
|> put_layout(html: :admin)
|
||||
|> render(:edit,
|
||||
page_title: "Редактировать заметку",
|
||||
changeset: changeset,
|
||||
stylesheets: ["/assets/admin.css"]
|
||||
)
|
||||
end
|
||||
|
||||
def create(conn, %{"note" => note_params}) do
|
||||
case Notes.create_note(note_params) do
|
||||
{:ok, note} ->
|
||||
conn
|
||||
|> put_flash(:info, "Заметка сохранена.")
|
||||
|> redirect(to: ~p"/admin/notes/#{note}")
|
||||
|
||||
{:error, changeset} ->
|
||||
conn
|
||||
|> put_flash(:error, "Ошибка при обновлении заметки.")
|
||||
|> put_layout(html: :admin)
|
||||
|> render(:new,
|
||||
page_title: "Создать заметку",
|
||||
changeset: changeset,
|
||||
stylesheets: ["/assets/admin.css"]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def update(conn, %{"id" => id, "note" => note_params}) do
|
||||
note = Notes.get_note!(id)
|
||||
|
||||
case Notes.update_note(note, note_params) do
|
||||
{:ok, note} ->
|
||||
conn
|
||||
|> put_flash(:info, "Заметка обновлена.")
|
||||
|> redirect(to: ~p"/admin/notes/#{note}")
|
||||
|
||||
{:error, changeset} ->
|
||||
conn
|
||||
|> put_flash(:error, "Ошибка при обновлении заметки.")
|
||||
|> put_layout(html: :admin)
|
||||
|> render(:edit,
|
||||
page_title: "Редактировать заметку",
|
||||
changeset: changeset,
|
||||
stylesheets: ["/assets/admin.css"]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def show(conn, %{"id" => id}) do
|
||||
note = Notes.get_note!(id)
|
||||
|
||||
conn
|
||||
|> put_layout(html: :admin)
|
||||
|> render(:show, page_title: "Заметка", note: note)
|
||||
end
|
||||
|
||||
def delete(conn, %{"id" => id}) do
|
||||
note = Notes.get_note!(id)
|
||||
|
||||
case Notes.delete_note(note) do
|
||||
{:ok, _note} ->
|
||||
conn
|
||||
|> put_flash(:info, "Заметка удалена.")
|
||||
|> redirect(to: ~p"/admin/notes")
|
||||
|
||||
{:error, changeset} ->
|
||||
conn
|
||||
|> put_flash(:error, "Ошибка при удалении заметки.")
|
||||
|> put_layout(html: :admin)
|
||||
|> render(:edit,
|
||||
page_title: "Редактировать заметку",
|
||||
changeset: changeset,
|
||||
stylesheets: ["/assets/admin.css"]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
85
lib/comfycamp_web/controllers/notes_editor_html.ex
Normal file
|
@ -0,0 +1,85 @@
|
|||
defmodule ComfycampWeb.NotesEditorHTML do
|
||||
use ComfycampWeb, :html
|
||||
|
||||
def index(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<h3>Заметки</h3>
|
||||
<.link href={~p"/admin/notes/new"}>
|
||||
Создать заметку
|
||||
</.link>
|
||||
|
||||
<ul>
|
||||
<%= for note <- @notes do %>
|
||||
<li>
|
||||
<.link href={~p"/admin/notes/#{note}"}>
|
||||
<%= note.title %>
|
||||
</.link>
|
||||
|
||||
<%= note.updated_at %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def show(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<h3><%= @note.title %></h3>
|
||||
<.back navigate={~p"/admin/notes"}>Назад</.back>
|
||||
|
||||
<.link href={~p"/admin/notes/#{@note}/edit"}>
|
||||
Редактировать
|
||||
</.link>
|
||||
|
||||
<p>Создана: <%= @note.inserted_at %></p>
|
||||
<p>Обновлена: <%= @note.updated_at %></p>
|
||||
|
||||
<pre><%= @note.markdown %></pre>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def new(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<h3>Новая заметка</h3>
|
||||
<.note_form changeset={@changeset} action={~p"/admin/notes"} />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def edit(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<h3>Редактировать заметку</h3>
|
||||
<.note_form changeset={@changeset} action={~p"/admin/notes/#{@changeset.data.id}"} />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def note_form(assigns) do
|
||||
~H"""
|
||||
<.simple_form :let={f} for={@changeset} action={@action}>
|
||||
<.input field={f[:title]} type="text" label="Заголовок" />
|
||||
<.input field={f[:markdown]} type="textarea" label="Содержание (markdown)" />
|
||||
|
||||
<:actions>
|
||||
<.button>Сохранить</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
|
||||
<%= if @changeset.data.id do %>
|
||||
<.link
|
||||
href={~p"/admin/notes/#{@changeset.data}"}
|
||||
method="DELETE"
|
||||
data-confirm="Вы уверены?"
|
||||
>
|
||||
Удалить
|
||||
</.link>
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
end
|
38
lib/comfycamp_web/controllers/notes_html.ex
Normal file
|
@ -0,0 +1,38 @@
|
|||
defmodule ComfycampWeb.NotesHTML do
|
||||
@moduledoc """
|
||||
This module contains pages rendered by NotesController.
|
||||
"""
|
||||
use ComfycampWeb, :html
|
||||
|
||||
def index(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<h1>Заметки</h1>
|
||||
|
||||
<%= for note <- @notes do %>
|
||||
<div>
|
||||
<.link href={~p"/notes/#{note}"}>
|
||||
<h2><%= note.title %></h2>
|
||||
</.link>
|
||||
<%= note.inserted_at %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def show(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<h1><%= @note.title %></h1>
|
||||
|
||||
<.back navigate={~p"/notes"}>К списку заметок</.back>
|
||||
|
||||
<p>Создана: <%= @note.inserted_at %></p>
|
||||
<p>Обновлена: <%= @note.updated_at %></p>
|
||||
|
||||
<%= raw(@note_body) %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
76
lib/comfycamp_web/controllers/oidc_app_controller.ex
Normal file
|
@ -0,0 +1,76 @@
|
|||
defmodule ComfycampWeb.OIDCAppController do
|
||||
use ComfycampWeb, :controller
|
||||
|
||||
alias Comfycamp.SSO
|
||||
alias Comfycamp.SSO.OIDCApp
|
||||
|
||||
def index(conn, _params) do
|
||||
oidc_apps = SSO.list_oidc_apps()
|
||||
|
||||
conn
|
||||
|> put_layout(html: :admin)
|
||||
|> render(:index, oidc_apps: oidc_apps)
|
||||
end
|
||||
|
||||
def new(conn, _params) do
|
||||
changeset = SSO.change_oidc_app(%OIDCApp{})
|
||||
|
||||
conn
|
||||
|> put_layout(html: :admin)
|
||||
|> render(:new, changeset: changeset)
|
||||
end
|
||||
|
||||
def create(conn, %{"oidc_app" => oidc_app_params}) do
|
||||
case SSO.create_oidc_app(oidc_app_params) do
|
||||
{:ok, oidc_app} ->
|
||||
conn
|
||||
|> put_flash(:info, "Oidc app created successfully.")
|
||||
|> redirect(to: ~p"/admin/oidc_apps/#{oidc_app}")
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
conn
|
||||
|> put_layout(html: :admin)
|
||||
|> render(:new, changeset: changeset)
|
||||
end
|
||||
end
|
||||
|
||||
def show(conn, %{"id" => id}) do
|
||||
oidc_app = SSO.get_oidc_app!(id)
|
||||
|
||||
conn
|
||||
|> put_layout(html: :admin)
|
||||
|> render(:show, oidc_app: oidc_app)
|
||||
end
|
||||
|
||||
def edit(conn, %{"id" => id}) do
|
||||
oidc_app = SSO.get_oidc_app!(id)
|
||||
changeset = SSO.change_oidc_app(oidc_app)
|
||||
|
||||
conn
|
||||
|> put_layout(html: :admin)
|
||||
|> render(:edit, oidc_app: oidc_app, changeset: changeset)
|
||||
end
|
||||
|
||||
def update(conn, %{"id" => id, "oidc_app" => oidc_app_params}) do
|
||||
oidc_app = SSO.get_oidc_app!(id)
|
||||
|
||||
case SSO.update_oidc_app(oidc_app, oidc_app_params) do
|
||||
{:ok, oidc_app} ->
|
||||
conn
|
||||
|> put_flash(:info, "Oidc app updated successfully.")
|
||||
|> redirect(to: ~p"/admin/oidc_apps/#{oidc_app}")
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
render(conn, :edit, oidc_app: oidc_app, changeset: changeset)
|
||||
end
|
||||
end
|
||||
|
||||
def delete(conn, %{"id" => id}) do
|
||||
oidc_app = SSO.get_oidc_app!(id)
|
||||
{:ok, _oidc_app} = SSO.delete_oidc_app(oidc_app)
|
||||
|
||||
conn
|
||||
|> put_flash(:info, "Oidc app deleted successfully.")
|
||||
|> redirect(to: ~p"/admin/oidc_apps")
|
||||
end
|
||||
end
|
13
lib/comfycamp_web/controllers/oidc_app_html.ex
Normal file
|
@ -0,0 +1,13 @@
|
|||
defmodule ComfycampWeb.OIDCAppHTML do
|
||||
use ComfycampWeb, :html
|
||||
|
||||
embed_templates "oidc_app_html/*"
|
||||
|
||||
@doc """
|
||||
Renders a oidc_app form.
|
||||
"""
|
||||
attr :changeset, Ecto.Changeset, required: true
|
||||
attr :action, :string, required: true
|
||||
|
||||
def oidc_app_form(assigns)
|
||||
end
|
10
lib/comfycamp_web/controllers/oidc_app_html/edit.html.heex
Normal file
|
@ -0,0 +1,10 @@
|
|||
<div>
|
||||
<.header>
|
||||
Edit Oidc app <%= @oidc_app.id %>
|
||||
<:subtitle>Use this form to manage oidc_app records in your database.</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.oidc_app_form changeset={@changeset} action={~p"/admin/oidc_apps/#{@oidc_app}"} />
|
||||
|
||||
<.back navigate={~p"/admin/oidc_apps"}>Back to oidc_apps</.back>
|
||||
</div>
|
28
lib/comfycamp_web/controllers/oidc_app_html/index.html.heex
Normal file
|
@ -0,0 +1,28 @@
|
|||
<div>
|
||||
<.header>
|
||||
Listing Oidc apps
|
||||
<:actions>
|
||||
<.link href={~p"/admin/oidc_apps/new"}>
|
||||
<.button>New Oidc app</.button>
|
||||
</.link>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.table id="oidc_apps" rows={@oidc_apps} row_click={&JS.navigate(~p"/admin/oidc_apps/#{&1}")}>
|
||||
<:col :let={oidc_app} label="Name"><%= oidc_app.name %></:col>
|
||||
<:col :let={oidc_app} label="Client"><%= oidc_app.client_id %></:col>
|
||||
<:col :let={oidc_app} label="Client secret"><%= oidc_app.client_secret %></:col>
|
||||
<:col :let={oidc_app} label="Enabled"><%= oidc_app.enabled %></:col>
|
||||
<:action :let={oidc_app}>
|
||||
<div class="sr-only">
|
||||
<.link navigate={~p"/admin/oidc_apps/#{oidc_app}"}>Show</.link>
|
||||
</div>
|
||||
<.link navigate={~p"/admin/oidc_apps/#{oidc_app}/edit"}>Edit</.link>
|
||||
</:action>
|
||||
<:action :let={oidc_app}>
|
||||
<.link href={~p"/admin/oidc_apps/#{oidc_app}"} method="delete" data-confirm="Are you sure?">
|
||||
Delete
|
||||
</.link>
|
||||
</:action>
|
||||
</.table>
|
||||
</div>
|
10
lib/comfycamp_web/controllers/oidc_app_html/new.html.heex
Normal file
|
@ -0,0 +1,10 @@
|
|||
<div>
|
||||
<.header>
|
||||
New Oidc app
|
||||
<:subtitle>Use this form to manage oidc_app records in your database.</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.oidc_app_form changeset={@changeset} action={~p"/admin/oidc_apps"} />
|
||||
|
||||
<.back navigate={~p"/admin/oidc_apps"}>Back to oidc_apps</.back>
|
||||
</div>
|
|
@ -0,0 +1,14 @@
|
|||
<div>
|
||||
<.simple_form :let={f} for={@changeset} action={@action}>
|
||||
<.error :if={@changeset.action}>
|
||||
Oops, something went wrong! Please check the errors below.
|
||||
</.error>
|
||||
<.input field={f[:name]} type="text" label="Name" />
|
||||
<.input field={f[:client_id]} type="text" label="Client" />
|
||||
<.input field={f[:client_secret]} type="text" label="Client secret" />
|
||||
<.input field={f[:enabled]} type="checkbox" label="Enabled" />
|
||||
<:actions>
|
||||
<.button>Save Oidc app</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
</div>
|
20
lib/comfycamp_web/controllers/oidc_app_html/show.html.heex
Normal file
|
@ -0,0 +1,20 @@
|
|||
<div>
|
||||
<.header>
|
||||
Oidc app <%= @oidc_app.id %>
|
||||
<:subtitle>This is a oidc_app record from your database.</:subtitle>
|
||||
<:actions>
|
||||
<.link href={~p"/admin/oidc_apps/#{@oidc_app}/edit"}>
|
||||
<.button>Edit oidc_app</.button>
|
||||
</.link>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.list>
|
||||
<:item title="Name"><%= @oidc_app.name %></:item>
|
||||
<:item title="Client"><%= @oidc_app.client_id %></:item>
|
||||
<:item title="Client secret"><%= @oidc_app.client_secret %></:item>
|
||||
<:item title="Enabled"><%= @oidc_app.enabled %></:item>
|
||||
</.list>
|
||||
|
||||
<.back navigate={~p"/admin/oidc_apps"}>Back to oidc_apps</.back>
|
||||
</div>
|
56
lib/comfycamp_web/controllers/user_editor_controller.ex
Normal file
|
@ -0,0 +1,56 @@
|
|||
defmodule ComfycampWeb.UserEditorController do
|
||||
use ComfycampWeb, :controller
|
||||
alias Comfycamp.Accounts
|
||||
|
||||
def index(conn, _params) do
|
||||
users = Accounts.list_users()
|
||||
|
||||
conn
|
||||
|> put_layout(html: :admin)
|
||||
|> render(:index,
|
||||
page_title: "Пользователи",
|
||||
users: users,
|
||||
stylesheets: ["/assets/admin.css"]
|
||||
)
|
||||
end
|
||||
|
||||
def show(conn, %{"id" => id}) do
|
||||
user = Accounts.get_user!(id)
|
||||
|
||||
conn
|
||||
|> put_layout(html: :admin)
|
||||
|> render(:show, page_title: user.email, user: user)
|
||||
end
|
||||
|
||||
def approve(conn, %{"id" => id}) do
|
||||
user = Accounts.get_user!(id)
|
||||
|
||||
case Accounts.update_approval_status(user, true) do
|
||||
{:ok, user} ->
|
||||
conn
|
||||
|> put_flash(:info, "Пользователь одобрен")
|
||||
|> redirect(to: ~p"/admin/users/#{user}")
|
||||
|
||||
{:error, _changeset} ->
|
||||
conn
|
||||
|> put_flash(:error, "Ошибка при одобрении")
|
||||
|> redirect(to: ~p"/admin/users/#{user}")
|
||||
end
|
||||
end
|
||||
|
||||
def disapprove(conn, %{"id" => id}) do
|
||||
user = Accounts.get_user!(id)
|
||||
|
||||
case Accounts.update_approval_status(user, false) do
|
||||
{:ok, user} ->
|
||||
conn
|
||||
|> put_flash(:info, "Одобрение отменено")
|
||||
|> redirect(to: ~p"/admin/users/#{user}")
|
||||
|
||||
{:error, _changeset} ->
|
||||
conn
|
||||
|> put_flash(:error, "Ошибка при отмене одобрения")
|
||||
|> redirect(to: ~p"/admin/users/#{user}")
|
||||
end
|
||||
end
|
||||
end
|
63
lib/comfycamp_web/controllers/user_editor_html.ex
Normal file
|
@ -0,0 +1,63 @@
|
|||
defmodule ComfycampWeb.UserEditorHTML do
|
||||
use ComfycampWeb, :html
|
||||
|
||||
def index(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<h3>Пользователи</h3>
|
||||
<table>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Email</th>
|
||||
<th>Одобрен?</th>
|
||||
<th>Админ?</th>
|
||||
</tr>
|
||||
<%= for user <- @users do %>
|
||||
<tr>
|
||||
<td><%= user.id %></td>
|
||||
<td>
|
||||
<.link href={~p"/admin/users/#{user}"}><%= user.email %></.link>
|
||||
</td>
|
||||
<td><%= user.is_approved %></td>
|
||||
<td><%= user.is_admin %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</table>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def show(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<h3><%= @user.email %></h3>
|
||||
<.back navigate={~p"/admin/users"}>Назад</.back>
|
||||
<p>
|
||||
<%= if @user.info do %>
|
||||
<%= @user.info %>
|
||||
<% else %>
|
||||
Описания нет.
|
||||
<% end %>
|
||||
</p>
|
||||
|
||||
<%= if @user.is_approved == false do %>
|
||||
<.link
|
||||
method="PUT"
|
||||
href={~p"/admin/users/#{@user}/approve"}
|
||||
data-confirm="Точно хотите одобрить пользователя?"
|
||||
>
|
||||
Одобрить
|
||||
</.link>
|
||||
<% else %>
|
||||
<.link
|
||||
method="PUT"
|
||||
href={~p"/admin/users/#{@user}/disapprove"}
|
||||
data-confirm="Точно хотите отменить одобрение?"
|
||||
>
|
||||
Отменить одобрение
|
||||
</.link>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
42
lib/comfycamp_web/controllers/user_session_controller.ex
Normal file
|
@ -0,0 +1,42 @@
|
|||
defmodule ComfycampWeb.UserSessionController do
|
||||
use ComfycampWeb, :controller
|
||||
|
||||
alias Comfycamp.Accounts
|
||||
alias ComfycampWeb.UserAuth
|
||||
|
||||
def create(conn, %{"_action" => "registered"} = params) do
|
||||
create(conn, params, "Account created successfully!")
|
||||
end
|
||||
|
||||
def create(conn, %{"_action" => "password_updated"} = params) do
|
||||
conn
|
||||
|> put_session(:user_return_to, ~p"/users/settings")
|
||||
|> create(params, "Password updated successfully!")
|
||||
end
|
||||
|
||||
def create(conn, params) do
|
||||
create(conn, params, "Welcome back!")
|
||||
end
|
||||
|
||||
defp create(conn, %{"user" => user_params}, info) do
|
||||
%{"email" => email, "password" => password} = user_params
|
||||
|
||||
if user = Accounts.get_user_by_email_and_password(email, password) do
|
||||
conn
|
||||
|> put_flash(:info, info)
|
||||
|> UserAuth.log_in_user(user, user_params)
|
||||
else
|
||||
# In order to prevent user enumeration attacks, don't disclose whether the email is registered.
|
||||
conn
|
||||
|> put_flash(:error, "Invalid email or password")
|
||||
|> put_flash(:email, String.slice(email, 0, 160))
|
||||
|> redirect(to: ~p"/users/log_in")
|
||||
end
|
||||
end
|
||||
|
||||
def delete(conn, _params) do
|
||||
conn
|
||||
|> put_flash(:info, "Logged out successfully.")
|
||||
|> UserAuth.log_out_user()
|
||||
end
|
||||
end
|
53
lib/comfycamp_web/endpoint.ex
Normal file
|
@ -0,0 +1,53 @@
|
|||
defmodule ComfycampWeb.Endpoint do
|
||||
use Phoenix.Endpoint, otp_app: :comfycamp
|
||||
|
||||
# The session will be stored in the cookie and signed,
|
||||
# this means its contents can be read but not tampered with.
|
||||
# Set :encryption_salt if you would also like to encrypt it.
|
||||
@session_options [
|
||||
store: :cookie,
|
||||
key: "_comfycamp_key",
|
||||
signing_salt: "VfHsLCyK",
|
||||
same_site: "Lax"
|
||||
]
|
||||
|
||||
socket "/live", Phoenix.LiveView.Socket,
|
||||
websocket: [connect_info: [session: @session_options]],
|
||||
longpoll: [connect_info: [session: @session_options]]
|
||||
|
||||
# Serve at "/" the static files from "priv/static" directory.
|
||||
#
|
||||
# You should set gzip to true if you are running phx.digest
|
||||
# when deploying your static files in production.
|
||||
plug Plug.Static,
|
||||
at: "/",
|
||||
from: :comfycamp,
|
||||
gzip: false,
|
||||
only: ComfycampWeb.static_paths()
|
||||
|
||||
# Code reloading can be explicitly enabled under the
|
||||
# :code_reloader configuration of your endpoint.
|
||||
if code_reloading? do
|
||||
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
|
||||
plug Phoenix.LiveReloader
|
||||
plug Phoenix.CodeReloader
|
||||
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :comfycamp
|
||||
end
|
||||
|
||||
plug Phoenix.LiveDashboard.RequestLogger,
|
||||
param_key: "request_logger",
|
||||
cookie_key: "request_logger"
|
||||
|
||||
plug Plug.RequestId
|
||||
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
|
||||
|
||||
plug Plug.Parsers,
|
||||
parsers: [:urlencoded, :multipart, :json],
|
||||
pass: ["*/*"],
|
||||
json_decoder: Phoenix.json_library()
|
||||
|
||||
plug Plug.MethodOverride
|
||||
plug Plug.Head
|
||||
plug Plug.Session, @session_options
|
||||
plug ComfycampWeb.Router
|
||||
end
|
24
lib/comfycamp_web/gettext.ex
Normal file
|
@ -0,0 +1,24 @@
|
|||
defmodule ComfycampWeb.Gettext do
|
||||
@moduledoc """
|
||||
A module providing Internationalization with a gettext-based API.
|
||||
|
||||
By using [Gettext](https://hexdocs.pm/gettext),
|
||||
your module gains a set of macros for translations, for example:
|
||||
|
||||
import ComfycampWeb.Gettext
|
||||
|
||||
# Simple translation
|
||||
gettext("Here is the string to translate")
|
||||
|
||||
# Plural translation
|
||||
ngettext("Here is the string to translate",
|
||||
"Here are the strings to translate",
|
||||
3)
|
||||
|
||||
# Domain-based translation
|
||||
dgettext("errors", "Here is the error message to translate")
|
||||
|
||||
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
|
||||
"""
|
||||
use Gettext, otp_app: :comfycamp
|
||||
end
|
|
@ -0,0 +1,51 @@
|
|||
defmodule ComfycampWeb.UserConfirmationInstructionsLive do
|
||||
use ComfycampWeb, :live_view
|
||||
|
||||
alias Comfycamp.Accounts
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="mx-auto max-w-sm">
|
||||
<.header class="text-center">
|
||||
Не получили инструкцию для подтверждения?
|
||||
<:subtitle>Мы отправим новую ссылку на вашу почту.</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.simple_form for={@form} id="resend_confirmation_form" phx-submit="send_instructions">
|
||||
<.input field={@form[:email]} type="email" placeholder="Email" required />
|
||||
<:actions>
|
||||
<.button phx-disable-with="Отправляю..." class="w-full">
|
||||
Отправить инструкции повторно
|
||||
</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
|
||||
<p class="text-center mt-4">
|
||||
<.link href={~p"/users/register"}>Зарегистрироваться</.link>
|
||||
| <.link href={~p"/users/log_in"}>Войти</.link>
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, assign(socket, form: to_form(%{}, as: "user"))}
|
||||
end
|
||||
|
||||
def handle_event("send_instructions", %{"user" => %{"email" => email}}, socket) do
|
||||
if user = Accounts.get_user_by_email(email) do
|
||||
Accounts.deliver_user_confirmation_instructions(
|
||||
user,
|
||||
&url(~p"/users/confirm/#{&1}")
|
||||
)
|
||||
end
|
||||
|
||||
info =
|
||||
"If your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly."
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, info)
|
||||
|> redirect(to: ~p"/")}
|
||||
end
|
||||
end
|
58
lib/comfycamp_web/live/user_confirmation_live.ex
Normal file
|
@ -0,0 +1,58 @@
|
|||
defmodule ComfycampWeb.UserConfirmationLive do
|
||||
use ComfycampWeb, :live_view
|
||||
|
||||
alias Comfycamp.Accounts
|
||||
|
||||
def render(%{live_action: :edit} = assigns) do
|
||||
~H"""
|
||||
<div class="mx-auto max-w-sm">
|
||||
<.header class="text-center">Подтвердить аккаунт</.header>
|
||||
|
||||
<.simple_form for={@form} id="confirmation_form" phx-submit="confirm_account">
|
||||
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
|
||||
<:actions>
|
||||
<.button phx-disable-with="Confirming..." class="w-full">Подтвердить мой аккаунт</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
|
||||
<p class="text-center mt-4">
|
||||
<.link href={~p"/users/register"}>Зарегистрироваться</.link>
|
||||
| <.link href={~p"/users/log_in"}>Войти</.link>
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def mount(%{"token" => token}, _session, socket) do
|
||||
form = to_form(%{"token" => token}, as: "user")
|
||||
{:ok, assign(socket, form: form), temporary_assigns: [form: nil]}
|
||||
end
|
||||
|
||||
# Do not log in the user after confirmation to avoid a
|
||||
# leaked token giving the user access to the account.
|
||||
def handle_event("confirm_account", %{"user" => %{"token" => token}}, socket) do
|
||||
case Accounts.confirm_user(token) do
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "User confirmed successfully.")
|
||||
|> redirect(to: ~p"/")}
|
||||
|
||||
:error ->
|
||||
# If there is a current user and the account was already confirmed,
|
||||
# then odds are that the confirmation link was already visited, either
|
||||
# by some automation or by the user themselves, so we redirect without
|
||||
# a warning message.
|
||||
case socket.assigns do
|
||||
%{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
|
||||
{:noreply, redirect(socket, to: ~p"/")}
|
||||
|
||||
%{} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "User confirmation link is invalid or it has expired.")
|
||||
|> redirect(to: ~p"/")}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
50
lib/comfycamp_web/live/user_forgot_password_live.ex
Normal file
|
@ -0,0 +1,50 @@
|
|||
defmodule ComfycampWeb.UserForgotPasswordLive do
|
||||
use ComfycampWeb, :live_view
|
||||
|
||||
alias Comfycamp.Accounts
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="mx-auto max-w-sm">
|
||||
<.header class="text-center">
|
||||
Забыли пароль?
|
||||
<:subtitle>Мы отправим ссылку для сброса пароля.</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.simple_form for={@form} id="reset_password_form" phx-submit="send_email">
|
||||
<.input field={@form[:email]} type="email" placeholder="Email" required />
|
||||
<:actions>
|
||||
<.button phx-disable-with="Отправляю..." class="w-full">
|
||||
Отправить инструкции
|
||||
</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
<p class="text-center text-sm mt-4">
|
||||
<.link href={~p"/users/register"}>Зарегистрироваться</.link>
|
||||
| <.link href={~p"/users/log_in"}>Войти</.link>
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, assign(socket, form: to_form(%{}, as: "user"))}
|
||||
end
|
||||
|
||||
def handle_event("send_email", %{"user" => %{"email" => email}}, socket) do
|
||||
if user = Accounts.get_user_by_email(email) do
|
||||
Accounts.deliver_user_reset_password_instructions(
|
||||
user,
|
||||
&url(~p"/users/reset_password/#{&1}")
|
||||
)
|
||||
end
|
||||
|
||||
info =
|
||||
"If your email is in our system, you will receive instructions to reset your password shortly."
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, info)
|
||||
|> redirect(to: ~p"/")}
|
||||
end
|
||||
end
|
43
lib/comfycamp_web/live/user_login_live.ex
Normal file
|
@ -0,0 +1,43 @@
|
|||
defmodule ComfycampWeb.UserLoginLive do
|
||||
use ComfycampWeb, :live_view
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="mx-auto max-w-sm">
|
||||
<.header class="text-center">
|
||||
Войти в аккаунт
|
||||
<:subtitle>
|
||||
Нет аккаунта?
|
||||
<.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline">
|
||||
Создайте
|
||||
</.link>
|
||||
новый.
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.simple_form for={@form} id="login_form" action={~p"/users/log_in"} phx-update="ignore">
|
||||
<.input field={@form[:email]} type="email" label="Email" required />
|
||||
<.input field={@form[:password]} type="password" label="Пароль" required />
|
||||
|
||||
<:actions>
|
||||
<.input field={@form[:remember_me]} type="checkbox" label="Запомнить" />
|
||||
<.link href={~p"/users/reset_password"} class="text-sm font-semibold">
|
||||
Забыли пароль?
|
||||
</.link>
|
||||
</:actions>
|
||||
<:actions>
|
||||
<.button phx-disable-with="Вхожу..." class="w-full">
|
||||
Войти <span aria-hidden="true">→</span>
|
||||
</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
email = Phoenix.Flash.get(socket.assigns.flash, :email)
|
||||
form = to_form(%{"email" => email}, as: "user")
|
||||
{:ok, assign(socket, form: form), temporary_assigns: [form: form]}
|
||||
end
|
||||
end
|
100
lib/comfycamp_web/live/user_registration_live.ex
Normal file
|
@ -0,0 +1,100 @@
|
|||
defmodule ComfycampWeb.UserRegistrationLive do
|
||||
use ComfycampWeb, :live_view
|
||||
|
||||
alias Comfycamp.Accounts
|
||||
alias Comfycamp.Accounts.User
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="">
|
||||
<.header class="text-center">
|
||||
Создайте аккаунт
|
||||
<:subtitle>
|
||||
Уже зарегистрированы?
|
||||
<.link navigate={~p"/users/log_in"} class="font-semibold text-brand hover:underline">
|
||||
Войти
|
||||
</.link>
|
||||
в аккаунт.
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.simple_form
|
||||
for={@form}
|
||||
id="registration_form"
|
||||
phx-submit="save"
|
||||
phx-change="validate"
|
||||
phx-trigger-action={@trigger_submit}
|
||||
action={~p"/users/log_in?_action=registered"}
|
||||
method="post"
|
||||
>
|
||||
<.error :if={@check_errors}>
|
||||
Что-то пошло не так. Проверьте ошибки ниже.
|
||||
</.error>
|
||||
|
||||
<.input field={@form[:email]} type="email" label="Email" required />
|
||||
<.input field={@form[:password]} type="password" label="Пароль" required />
|
||||
<.input
|
||||
field={@form[:info]}
|
||||
type="textarea"
|
||||
label="Почему вы хотите получить доступ?"
|
||||
spellcheck="true"
|
||||
required
|
||||
/>
|
||||
<p>
|
||||
Ваш небольшой рассказ помогает защитить сервисы.
|
||||
Можете указать ссылки на соцсети.
|
||||
</p>
|
||||
|
||||
<:actions>
|
||||
<.button phx-disable-with="Создаю аккаунт..." class="w-full">
|
||||
Создать аккаунт
|
||||
</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
changeset = Accounts.change_user_registration(%User{})
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(trigger_submit: false, check_errors: false)
|
||||
|> assign_form(changeset)
|
||||
|
||||
{:ok, socket, temporary_assigns: [form: nil]}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"user" => user_params}, socket) do
|
||||
case Accounts.register_user(user_params) do
|
||||
{:ok, user} ->
|
||||
{:ok, _} =
|
||||
Accounts.deliver_user_confirmation_instructions(
|
||||
user,
|
||||
&url(~p"/users/confirm/#{&1}")
|
||||
)
|
||||
|
||||
changeset = Accounts.change_user_registration(user)
|
||||
{:noreply, socket |> assign(trigger_submit: true) |> assign_form(changeset)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, socket |> assign(check_errors: true) |> assign_form(changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("validate", %{"user" => user_params}, socket) do
|
||||
changeset = Accounts.change_user_registration(%User{}, user_params)
|
||||
{:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
|
||||
end
|
||||
|
||||
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
|
||||
form = to_form(changeset, as: "user")
|
||||
|
||||
if changeset.valid? do
|
||||
assign(socket, form: form, check_errors: false)
|
||||
else
|
||||
assign(socket, form: form)
|
||||
end
|
||||
end
|
||||
end
|
89
lib/comfycamp_web/live/user_reset_password_live.ex
Normal file
|
@ -0,0 +1,89 @@
|
|||
defmodule ComfycampWeb.UserResetPasswordLive do
|
||||
use ComfycampWeb, :live_view
|
||||
|
||||
alias Comfycamp.Accounts
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="mx-auto max-w-sm">
|
||||
<.header class="text-center">Сброс пароля</.header>
|
||||
|
||||
<.simple_form
|
||||
for={@form}
|
||||
id="reset_password_form"
|
||||
phx-submit="reset_password"
|
||||
phx-change="validate"
|
||||
>
|
||||
<.error :if={@form.errors != []}>
|
||||
Упс, что-то пошло не так. Проверьте ошибки ниже.
|
||||
</.error>
|
||||
|
||||
<.input field={@form[:password]} type="password" label="Новый пароль" required />
|
||||
<.input
|
||||
field={@form[:password_confirmation]}
|
||||
type="password"
|
||||
label="Подтвердите новый пароль"
|
||||
required
|
||||
/>
|
||||
<:actions>
|
||||
<.button phx-disable-with="Сбрасываю..." class="w-full">Сбросить пароль</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
|
||||
<p class="text-center text-sm mt-4">
|
||||
<.link href={~p"/users/register"}>Зарегистрироваться</.link>
|
||||
| <.link href={~p"/users/log_in"}>Войти</.link>
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def mount(params, _session, socket) do
|
||||
socket = assign_user_and_token(socket, params)
|
||||
|
||||
form_source =
|
||||
case socket.assigns do
|
||||
%{user: user} ->
|
||||
Accounts.change_user_password(user)
|
||||
|
||||
_ ->
|
||||
%{}
|
||||
end
|
||||
|
||||
{:ok, assign_form(socket, form_source), temporary_assigns: [form: nil]}
|
||||
end
|
||||
|
||||
# Do not log in the user after reset password to avoid a
|
||||
# leaked token giving the user access to the account.
|
||||
def handle_event("reset_password", %{"user" => user_params}, socket) do
|
||||
case Accounts.reset_user_password(socket.assigns.user, user_params) do
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Password reset successfully.")
|
||||
|> redirect(to: ~p"/users/log_in")}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign_form(socket, Map.put(changeset, :action, :insert))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("validate", %{"user" => user_params}, socket) do
|
||||
changeset = Accounts.change_user_password(socket.assigns.user, user_params)
|
||||
{:noreply, assign_form(socket, Map.put(changeset, :action, :validate))}
|
||||
end
|
||||
|
||||
defp assign_user_and_token(socket, %{"token" => token}) do
|
||||
if user = Accounts.get_user_by_reset_password_token(token) do
|
||||
assign(socket, user: user, token: token)
|
||||
else
|
||||
socket
|
||||
|> put_flash(:error, "Reset password link is invalid or it has expired.")
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
end
|
||||
|
||||
defp assign_form(socket, %{} = source) do
|
||||
assign(socket, :form, to_form(source, as: "user"))
|
||||
end
|
||||
end
|
172
lib/comfycamp_web/live/user_settings_live.ex
Normal file
|
@ -0,0 +1,172 @@
|
|||
defmodule ComfycampWeb.UserSettingsLive do
|
||||
use ComfycampWeb, :live_view
|
||||
|
||||
alias Comfycamp.Accounts
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header class="text-center">
|
||||
Настройки аккаунта
|
||||
<:subtitle>Настройки email адреса и пароля</:subtitle>
|
||||
</.header>
|
||||
|
||||
<div class="space-y-12 divide-y">
|
||||
<div>
|
||||
<.simple_form
|
||||
for={@email_form}
|
||||
id="email_form"
|
||||
phx-submit="update_email"
|
||||
phx-change="validate_email"
|
||||
>
|
||||
<.input field={@email_form[:email]} type="email" label="Email" required />
|
||||
<.input
|
||||
field={@email_form[:current_password]}
|
||||
name="current_password"
|
||||
id="current_password_for_email"
|
||||
type="password"
|
||||
label="Текущий пароль"
|
||||
value={@email_form_current_password}
|
||||
required
|
||||
/>
|
||||
<:actions>
|
||||
<.button phx-disable-with="Изменяю...">Изменить email</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
</div>
|
||||
<div>
|
||||
<.simple_form
|
||||
for={@password_form}
|
||||
id="password_form"
|
||||
action={~p"/users/log_in?_action=password_updated"}
|
||||
method="post"
|
||||
phx-change="validate_password"
|
||||
phx-submit="update_password"
|
||||
phx-trigger-action={@trigger_submit}
|
||||
>
|
||||
<input
|
||||
name={@password_form[:email].name}
|
||||
type="hidden"
|
||||
id="hidden_user_email"
|
||||
value={@current_email}
|
||||
/>
|
||||
<.input
|
||||
field={@password_form[:password]}
|
||||
type="password"
|
||||
label="Новый пароль"
|
||||
required
|
||||
/>
|
||||
<.input
|
||||
field={@password_form[:password_confirmation]}
|
||||
type="password"
|
||||
label="Подтвердите новый пароль"
|
||||
/>
|
||||
<.input
|
||||
field={@password_form[:current_password]}
|
||||
name="current_password"
|
||||
type="password"
|
||||
label="Текущий пароль"
|
||||
id="current_password_for_password"
|
||||
value={@current_password}
|
||||
required
|
||||
/>
|
||||
<:actions>
|
||||
<.button phx-disable-with="Изменяю...">Изменить пароль</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def mount(%{"token" => token}, _session, socket) do
|
||||
socket =
|
||||
case Accounts.update_user_email(socket.assigns.current_user, token) do
|
||||
:ok ->
|
||||
put_flash(socket, :info, "Email changed successfully.")
|
||||
|
||||
:error ->
|
||||
put_flash(socket, :error, "Email change link is invalid or it has expired.")
|
||||
end
|
||||
|
||||
{:ok, push_navigate(socket, to: ~p"/users/settings")}
|
||||
end
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
user = socket.assigns.current_user
|
||||
email_changeset = Accounts.change_user_email(user)
|
||||
password_changeset = Accounts.change_user_password(user)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:current_password, nil)
|
||||
|> assign(:email_form_current_password, nil)
|
||||
|> assign(:current_email, user.email)
|
||||
|> assign(:email_form, to_form(email_changeset))
|
||||
|> assign(:password_form, to_form(password_changeset))
|
||||
|> assign(:trigger_submit, false)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
def handle_event("validate_email", params, socket) do
|
||||
%{"current_password" => password, "user" => user_params} = params
|
||||
|
||||
email_form =
|
||||
socket.assigns.current_user
|
||||
|> Accounts.change_user_email(user_params)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form()
|
||||
|
||||
{:noreply, assign(socket, email_form: email_form, email_form_current_password: password)}
|
||||
end
|
||||
|
||||
def handle_event("update_email", params, socket) do
|
||||
%{"current_password" => password, "user" => user_params} = params
|
||||
user = socket.assigns.current_user
|
||||
|
||||
case Accounts.apply_user_email(user, password, user_params) do
|
||||
{:ok, applied_user} ->
|
||||
Accounts.deliver_user_update_email_instructions(
|
||||
applied_user,
|
||||
user.email,
|
||||
&url(~p"/users/settings/confirm_email/#{&1}")
|
||||
)
|
||||
|
||||
info = "A link to confirm your email change has been sent to the new address."
|
||||
{:noreply, socket |> put_flash(:info, info) |> assign(email_form_current_password: nil)}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :email_form, to_form(Map.put(changeset, :action, :insert)))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("validate_password", params, socket) do
|
||||
%{"current_password" => password, "user" => user_params} = params
|
||||
|
||||
password_form =
|
||||
socket.assigns.current_user
|
||||
|> Accounts.change_user_password(user_params)
|
||||
|> Map.put(:action, :validate)
|
||||
|> to_form()
|
||||
|
||||
{:noreply, assign(socket, password_form: password_form, current_password: password)}
|
||||
end
|
||||
|
||||
def handle_event("update_password", params, socket) do
|
||||
%{"current_password" => password, "user" => user_params} = params
|
||||
user = socket.assigns.current_user
|
||||
|
||||
case Accounts.update_user_password(user, password, user_params) do
|
||||
{:ok, user} ->
|
||||
password_form =
|
||||
user
|
||||
|> Accounts.change_user_password(user_params)
|
||||
|> to_form()
|
||||
|
||||
{:noreply, assign(socket, trigger_submit: true, password_form: password_form)}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, password_form: to_form(changeset))}
|
||||
end
|
||||
end
|
||||
end
|
102
lib/comfycamp_web/router.ex
Normal file
|
@ -0,0 +1,102 @@
|
|||
defmodule ComfycampWeb.Router do
|
||||
use ComfycampWeb, :router
|
||||
|
||||
import ComfycampWeb.UserAuth
|
||||
|
||||
pipeline :browser do
|
||||
plug :accepts, ["html"]
|
||||
plug :fetch_session
|
||||
plug :fetch_live_flash
|
||||
plug :put_root_layout, html: {ComfycampWeb.Layouts, :root}
|
||||
plug :protect_from_forgery
|
||||
plug :put_secure_browser_headers
|
||||
plug :fetch_current_user
|
||||
end
|
||||
|
||||
pipeline :api do
|
||||
plug :accepts, ["json"]
|
||||
end
|
||||
|
||||
scope "/", ComfycampWeb do
|
||||
pipe_through :browser
|
||||
|
||||
get "/", HomeController, :index
|
||||
get "/services/mastodon", HomeController, :mastodon
|
||||
get "/services/nextcloud", HomeController, :nextcloud
|
||||
|
||||
resources "/notes", NotesController, only: [:index, :show]
|
||||
get "/cinema", CinemaController, :index
|
||||
end
|
||||
|
||||
# Other scopes may use custom stacks.
|
||||
# scope "/api", ComfycampWeb do
|
||||
# pipe_through :api
|
||||
# end
|
||||
|
||||
# Enable LiveDashboard and Swoosh mailbox preview in development
|
||||
if Application.compile_env(:comfycamp, :dev_routes) do
|
||||
# If you want to use the LiveDashboard in production, you should put
|
||||
# it behind authentication and allow only admins to access it.
|
||||
# If your application does not have an admins-only section yet,
|
||||
# you can use Plug.BasicAuth to set up some basic authentication
|
||||
# as long as you are also using SSL (which you should anyway).
|
||||
import Phoenix.LiveDashboard.Router
|
||||
|
||||
scope "/dev" do
|
||||
pipe_through :browser
|
||||
|
||||
live_dashboard "/dashboard", metrics: ComfycampWeb.Telemetry
|
||||
forward "/mailbox", Plug.Swoosh.MailboxPreview
|
||||
end
|
||||
end
|
||||
|
||||
## Authentication routes
|
||||
|
||||
scope "/", ComfycampWeb do
|
||||
pipe_through [:browser, :redirect_if_user_is_authenticated]
|
||||
|
||||
live_session :redirect_if_user_is_authenticated,
|
||||
on_mount: [{ComfycampWeb.UserAuth, :redirect_if_user_is_authenticated}] do
|
||||
live "/users/register", UserRegistrationLive, :new
|
||||
live "/users/log_in", UserLoginLive, :new
|
||||
live "/users/reset_password", UserForgotPasswordLive, :new
|
||||
live "/users/reset_password/:token", UserResetPasswordLive, :edit
|
||||
end
|
||||
|
||||
post "/users/log_in", UserSessionController, :create
|
||||
end
|
||||
|
||||
scope "/", ComfycampWeb do
|
||||
pipe_through [:browser, :require_authenticated_user]
|
||||
|
||||
live_session :require_authenticated_user,
|
||||
on_mount: [{ComfycampWeb.UserAuth, :ensure_authenticated}] do
|
||||
live "/users/settings", UserSettingsLive, :edit
|
||||
live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
|
||||
end
|
||||
end
|
||||
|
||||
scope "/", ComfycampWeb do
|
||||
pipe_through [:browser]
|
||||
|
||||
delete "/users/log_out", UserSessionController, :delete
|
||||
|
||||
live_session :current_user,
|
||||
on_mount: [{ComfycampWeb.UserAuth, :mount_current_user}] do
|
||||
live "/users/confirm/:token", UserConfirmationLive, :edit
|
||||
live "/users/confirm", UserConfirmationInstructionsLive, :new
|
||||
end
|
||||
end
|
||||
|
||||
scope "/admin", ComfycampWeb do
|
||||
pipe_through [:browser, :require_authenticated_user, :ensure_admin]
|
||||
|
||||
get "/", AdminPageController, :home
|
||||
get "/services", AdminPageController, :services
|
||||
resources "/notes", NotesEditorController
|
||||
resources "/users", UserEditorController, only: [:index, :show]
|
||||
resources "/oidc_apps", OIDCAppController
|
||||
put "/users/:id/approve", UserEditorController, :approve
|
||||
put "/users/:id/disapprove", UserEditorController, :disapprove
|
||||
end
|
||||
end
|
92
lib/comfycamp_web/telemetry.ex
Normal file
|
@ -0,0 +1,92 @@
|
|||
defmodule ComfycampWeb.Telemetry do
|
||||
use Supervisor
|
||||
import Telemetry.Metrics
|
||||
|
||||
def start_link(arg) do
|
||||
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_arg) do
|
||||
children = [
|
||||
# Telemetry poller will execute the given period measurements
|
||||
# every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
|
||||
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
|
||||
# Add reporters as children of your supervision tree.
|
||||
# {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
|
||||
]
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
|
||||
def metrics do
|
||||
[
|
||||
# Phoenix Metrics
|
||||
summary("phoenix.endpoint.start.system_time",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.endpoint.stop.duration",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.router_dispatch.start.system_time",
|
||||
tags: [:route],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.router_dispatch.exception.duration",
|
||||
tags: [:route],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.router_dispatch.stop.duration",
|
||||
tags: [:route],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.socket_connected.duration",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.channel_joined.duration",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.channel_handled_in.duration",
|
||||
tags: [:event],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
|
||||
# Database Metrics
|
||||
summary("comfycamp.repo.query.total_time",
|
||||
unit: {:native, :millisecond},
|
||||
description: "The sum of the other measurements"
|
||||
),
|
||||
summary("comfycamp.repo.query.decode_time",
|
||||
unit: {:native, :millisecond},
|
||||
description: "The time spent decoding the data received from the database"
|
||||
),
|
||||
summary("comfycamp.repo.query.query_time",
|
||||
unit: {:native, :millisecond},
|
||||
description: "The time spent executing the query"
|
||||
),
|
||||
summary("comfycamp.repo.query.queue_time",
|
||||
unit: {:native, :millisecond},
|
||||
description: "The time spent waiting for a database connection"
|
||||
),
|
||||
summary("comfycamp.repo.query.idle_time",
|
||||
unit: {:native, :millisecond},
|
||||
description:
|
||||
"The time the connection spent waiting before being checked out for the query"
|
||||
),
|
||||
|
||||
# VM Metrics
|
||||
summary("vm.memory.total", unit: {:byte, :kilobyte}),
|
||||
summary("vm.total_run_queue_lengths.total"),
|
||||
summary("vm.total_run_queue_lengths.cpu"),
|
||||
summary("vm.total_run_queue_lengths.io")
|
||||
]
|
||||
end
|
||||
|
||||
defp periodic_measurements do
|
||||
[
|
||||
# A module, function and arguments to be invoked periodically.
|
||||
# This function must call :telemetry.execute/3 and a metric must be added above.
|
||||
# {ComfycampWeb, :count_users, []}
|
||||
]
|
||||
end
|
||||
end
|
240
lib/comfycamp_web/user_auth.ex
Normal file
|
@ -0,0 +1,240 @@
|
|||
defmodule ComfycampWeb.UserAuth do
|
||||
use ComfycampWeb, :verified_routes
|
||||
|
||||
import Plug.Conn
|
||||
import Phoenix.Controller
|
||||
|
||||
alias Comfycamp.Accounts
|
||||
|
||||
# Make the remember me cookie valid for 60 days.
|
||||
# If you want bump or reduce this value, also change
|
||||
# the token expiry itself in UserToken.
|
||||
@max_age 60 * 60 * 24 * 60
|
||||
@remember_me_cookie "_comfycamp_web_user_remember_me"
|
||||
@remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"]
|
||||
|
||||
@doc """
|
||||
Logs the user in.
|
||||
|
||||
It renews the session ID and clears the whole session
|
||||
to avoid fixation attacks. See the renew_session
|
||||
function to customize this behaviour.
|
||||
|
||||
It also sets a `:live_socket_id` key in the session,
|
||||
so LiveView sessions are identified and automatically
|
||||
disconnected on log out. The line can be safely removed
|
||||
if you are not using LiveView.
|
||||
"""
|
||||
def log_in_user(conn, user, params \\ %{}) do
|
||||
token = Accounts.generate_user_session_token(user)
|
||||
user_return_to = get_session(conn, :user_return_to)
|
||||
|
||||
conn
|
||||
|> renew_session()
|
||||
|> put_token_in_session(token)
|
||||
|> maybe_write_remember_me_cookie(token, params)
|
||||
|> redirect(to: user_return_to || signed_in_path(conn))
|
||||
end
|
||||
|
||||
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do
|
||||
put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options)
|
||||
end
|
||||
|
||||
defp maybe_write_remember_me_cookie(conn, _token, _params) do
|
||||
conn
|
||||
end
|
||||
|
||||
# This function renews the session ID and erases the whole
|
||||
# session to avoid fixation attacks. If there is any data
|
||||
# in the session you may want to preserve after log in/log out,
|
||||
# you must explicitly fetch the session data before clearing
|
||||
# and then immediately set it after clearing, for example:
|
||||
#
|
||||
# defp renew_session(conn) do
|
||||
# preferred_locale = get_session(conn, :preferred_locale)
|
||||
#
|
||||
# conn
|
||||
# |> configure_session(renew: true)
|
||||
# |> clear_session()
|
||||
# |> put_session(:preferred_locale, preferred_locale)
|
||||
# end
|
||||
#
|
||||
defp renew_session(conn) do
|
||||
delete_csrf_token()
|
||||
|
||||
conn
|
||||
|> configure_session(renew: true)
|
||||
|> clear_session()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Logs the user out.
|
||||
|
||||
It clears all session data for safety. See renew_session.
|
||||
"""
|
||||
def log_out_user(conn) do
|
||||
user_token = get_session(conn, :user_token)
|
||||
user_token && Accounts.delete_user_session_token(user_token)
|
||||
|
||||
if live_socket_id = get_session(conn, :live_socket_id) do
|
||||
ComfycampWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
|
||||
end
|
||||
|
||||
conn
|
||||
|> renew_session()
|
||||
|> delete_resp_cookie(@remember_me_cookie)
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Authenticates the user by looking into the session
|
||||
and remember me token.
|
||||
"""
|
||||
def fetch_current_user(conn, _opts) do
|
||||
{user_token, conn} = ensure_user_token(conn)
|
||||
user = user_token && Accounts.get_user_by_session_token(user_token)
|
||||
assign(conn, :current_user, user)
|
||||
end
|
||||
|
||||
defp ensure_user_token(conn) do
|
||||
if token = get_session(conn, :user_token) do
|
||||
{token, conn}
|
||||
else
|
||||
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
|
||||
|
||||
if token = conn.cookies[@remember_me_cookie] do
|
||||
{token, put_token_in_session(conn, token)}
|
||||
else
|
||||
{nil, conn}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles mounting and authenticating the current_user in LiveViews.
|
||||
|
||||
## `on_mount` arguments
|
||||
|
||||
* `:mount_current_user` - Assigns current_user
|
||||
to socket assigns based on user_token, or nil if
|
||||
there's no user_token or no matching user.
|
||||
|
||||
* `:ensure_authenticated` - Authenticates the user from the session,
|
||||
and assigns the current_user to socket assigns based
|
||||
on user_token.
|
||||
Redirects to login page if there's no logged user.
|
||||
|
||||
* `:redirect_if_user_is_authenticated` - Authenticates the user from the session.
|
||||
Redirects to signed_in_path if there's a logged user.
|
||||
|
||||
## Examples
|
||||
|
||||
Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate
|
||||
the current_user:
|
||||
|
||||
defmodule ComfycampWeb.PageLive do
|
||||
use ComfycampWeb, :live_view
|
||||
|
||||
on_mount {ComfycampWeb.UserAuth, :mount_current_user}
|
||||
...
|
||||
end
|
||||
|
||||
Or use the `live_session` of your router to invoke the on_mount callback:
|
||||
|
||||
live_session :authenticated, on_mount: [{ComfycampWeb.UserAuth, :ensure_authenticated}] do
|
||||
live "/profile", ProfileLive, :index
|
||||
end
|
||||
"""
|
||||
def on_mount(:mount_current_user, _params, session, socket) do
|
||||
{:cont, mount_current_user(socket, session)}
|
||||
end
|
||||
|
||||
def on_mount(:ensure_authenticated, _params, session, socket) do
|
||||
socket = mount_current_user(socket, session)
|
||||
|
||||
if socket.assigns.current_user do
|
||||
{:cont, socket}
|
||||
else
|
||||
socket =
|
||||
socket
|
||||
|> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
|
||||
|> Phoenix.LiveView.redirect(to: ~p"/users/log_in")
|
||||
|
||||
{:halt, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do
|
||||
socket = mount_current_user(socket, session)
|
||||
|
||||
if socket.assigns.current_user do
|
||||
{:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))}
|
||||
else
|
||||
{:cont, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp mount_current_user(socket, session) do
|
||||
Phoenix.Component.assign_new(socket, :current_user, fn ->
|
||||
if user_token = session["user_token"] do
|
||||
Accounts.get_user_by_session_token(user_token)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Used for routes that require the user to not be authenticated.
|
||||
"""
|
||||
def redirect_if_user_is_authenticated(conn, _opts) do
|
||||
if conn.assigns[:current_user] do
|
||||
conn
|
||||
|> redirect(to: signed_in_path(conn))
|
||||
|> halt()
|
||||
else
|
||||
conn
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Used for routes that require the user to be authenticated.
|
||||
|
||||
If you want to enforce the user email is confirmed before
|
||||
they use the application at all, here would be a good place.
|
||||
"""
|
||||
def require_authenticated_user(conn, _opts) do
|
||||
if conn.assigns[:current_user] do
|
||||
conn
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "You must log in to access this page.")
|
||||
|> maybe_store_return_to()
|
||||
|> redirect(to: ~p"/users/log_in")
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
|
||||
def ensure_admin(conn, _opts) do
|
||||
if conn.assigns[:current_user].is_admin do
|
||||
conn
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "Вы должны быть администратором для просмотра.")
|
||||
|> redirect(to: ~p"/")
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
|
||||
defp put_token_in_session(conn, token) do
|
||||
conn
|
||||
|> put_session(:user_token, token)
|
||||
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
|
||||
end
|
||||
|
||||
defp maybe_store_return_to(%{method: "GET"} = conn) do
|
||||
put_session(conn, :user_return_to, current_path(conn))
|
||||
end
|
||||
|
||||
defp maybe_store_return_to(conn), do: conn
|
||||
|
||||
defp signed_in_path(_conn), do: ~p"/"
|
||||
end
|
80
mix.exs
Normal file
|
@ -0,0 +1,80 @@
|
|||
defmodule Comfycamp.MixProject do
|
||||
use Mix.Project
|
||||
|
||||
def project do
|
||||
[
|
||||
app: :comfycamp,
|
||||
version: "0.1.0",
|
||||
elixir: "~> 1.14",
|
||||
elixirc_paths: elixirc_paths(Mix.env()),
|
||||
start_permanent: Mix.env() == :prod,
|
||||
aliases: aliases(),
|
||||
deps: deps()
|
||||
]
|
||||
end
|
||||
|
||||
# Configuration for the OTP application.
|
||||
#
|
||||
# Type `mix help compile.app` for more information.
|
||||
def application do
|
||||
[
|
||||
mod: {Comfycamp.Application, []},
|
||||
extra_applications: [:logger, :runtime_tools]
|
||||
]
|
||||
end
|
||||
|
||||
# Specifies which paths to compile per environment.
|
||||
defp elixirc_paths(:test), do: ["lib", "test/support"]
|
||||
defp elixirc_paths(_), do: ["lib"]
|
||||
|
||||
# Specifies your project dependencies.
|
||||
#
|
||||
# Type `mix help deps` for examples and options.
|
||||
defp deps do
|
||||
[
|
||||
{:argon2_elixir, "~> 3.0"},
|
||||
{:phoenix, "~> 1.7.12"},
|
||||
{:phoenix_ecto, "~> 4.4"},
|
||||
{:ecto_sql, "~> 3.10"},
|
||||
{:postgrex, ">= 0.0.0"},
|
||||
{:phoenix_html, "~> 4.0"},
|
||||
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
||||
{:phoenix_live_view, "~> 0.20.2"},
|
||||
{:floki, ">= 0.30.0", only: :test},
|
||||
{:phoenix_live_dashboard, "~> 0.8.3"},
|
||||
{:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
|
||||
{:swoosh, "~> 1.5"},
|
||||
{:finch, "~> 0.13"},
|
||||
{:telemetry_metrics, "~> 1.0"},
|
||||
{:telemetry_poller, "~> 1.0"},
|
||||
{:gettext, "~> 0.20"},
|
||||
{:jason, "~> 1.2"},
|
||||
{:dns_cluster, "~> 0.1.1"},
|
||||
{:bandit, "~> 1.2"},
|
||||
# Markdown rendering
|
||||
{:earmark, "~> 1.4"},
|
||||
{:gen_smtp, "~> 1.2"}
|
||||
]
|
||||
end
|
||||
|
||||
# Aliases are shortcuts or tasks specific to the current project.
|
||||
# For example, to install project dependencies and perform other setup tasks, run:
|
||||
#
|
||||
# $ mix setup
|
||||
#
|
||||
# See the documentation for `Mix` for more info on aliases.
|
||||
defp aliases do
|
||||
[
|
||||
setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
|
||||
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
|
||||
"ecto.reset": ["ecto.drop", "ecto.setup"],
|
||||
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
|
||||
"assets.setup": ["esbuild.install --if-missing"],
|
||||
"assets.build": ["esbuild comfycamp"],
|
||||
"assets.deploy": [
|
||||
"esbuild comfycamp --minify",
|
||||
"phx.digest"
|
||||
]
|
||||
]
|
||||
end
|
||||
end
|
45
mix.lock
Normal file
|
@ -0,0 +1,45 @@
|
|||
%{
|
||||
"argon2_elixir": {:hex, :argon2_elixir, "3.2.1", "f47740bf9f2a39ffef79ba48eb25dea2ee37bcc7eadf91d49615591d1a6fce1a", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "a813b78217394530b5fcf4c8070feee43df03ffef938d044019169c766315690"},
|
||||
"bandit": {:hex, :bandit, "1.5.2", "ed0a41c43a9e529c670d0fd48371db4027e7b80d43b1942893e17deb8bed0540", [:mix], [{:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "35ddbdce7e8a2a3c6b5093f7299d70832a43ed2f4a1852885a61d334cab1b4ad"},
|
||||
"castore": {:hex, :castore, "1.0.7", "b651241514e5f6956028147fe6637f7ac13802537e895a724f90bf3e36ddd1dd", [:mix], [], "hexpm", "da7785a4b0d2a021cd1292a60875a784b6caef71e76bf4917bdee1f390455cf5"},
|
||||
"comeonin": {:hex, :comeonin, "5.4.0", "246a56ca3f41d404380fc6465650ddaa532c7f98be4bda1b4656b3a37cc13abe", [:mix], [], "hexpm", "796393a9e50d01999d56b7b8420ab0481a7538d0caf80919da493b4a6e51faf1"},
|
||||
"db_connection": {:hex, :db_connection, "2.6.0", "77d835c472b5b67fc4f29556dee74bf511bbafecdcaf98c27d27fa5918152086", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c2f992d15725e721ec7fbc1189d4ecdb8afef76648c746a8e1cad35e3b8a35f3"},
|
||||
"decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"},
|
||||
"dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"},
|
||||
"earmark": {:hex, :earmark, "1.4.47", "7e7596b84fe4ebeb8751e14cbaeaf4d7a0237708f2ce43630cfd9065551f94ca", [:mix], [], "hexpm", "3e96bebea2c2d95f3b346a7ff22285bc68a99fbabdad9b655aa9c6be06c698f8"},
|
||||
"ecto": {:hex, :ecto, "3.11.2", "e1d26be989db350a633667c5cda9c3d115ae779b66da567c68c80cfb26a8c9ee", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c38bca2c6f8d8023f2145326cc8a80100c3ffe4dcbd9842ff867f7fc6156c65"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.11.2", "c7cc7f812af571e50b80294dc2e535821b3b795ce8008d07aa5f336591a185a8", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "73c07f995ac17dbf89d3cfaaf688fcefabcd18b7b004ac63b0dc4ef39499ed6b"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.8.4", "4960a03ce79081dee8fe119d80ad372c4e7badb84c493cc75983f9d3bc8bde0f", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "6e7f1d619b5f61dfabd0a20aa268e575572b542ac31723293a4c1a567d5ef040"},
|
||||
"esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"},
|
||||
"expo": {:hex, :expo, "0.5.2", "beba786aab8e3c5431813d7a44b828e7b922bfa431d6bfbada0904535342efe2", [:mix], [], "hexpm", "8c9bfa06ca017c9cb4020fabe980bc7fdb1aaec059fd004c2ab3bff03b1c599c"},
|
||||
"file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"},
|
||||
"finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"},
|
||||
"floki": {:hex, :floki, "0.36.2", "a7da0193538c93f937714a6704369711998a51a6164a222d710ebd54020aa7a3", [:mix], [], "hexpm", "a8766c0bc92f074e5cb36c4f9961982eda84c5d2b8e979ca67f5c268ec8ed580"},
|
||||
"gen_smtp": {:hex, :gen_smtp, "1.2.0", "9cfc75c72a8821588b9b9fe947ae5ab2aed95a052b81237e0928633a13276fd3", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "5ee0375680bca8f20c4d85f58c2894441443a743355430ff33a783fe03296779"},
|
||||
"gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"},
|
||||
"hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"},
|
||||
"jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"},
|
||||
"mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"},
|
||||
"mint": {:hex, :mint, "1.6.0", "88a4f91cd690508a04ff1c3e28952f322528934be541844d54e0ceb765f01d5e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "3c5ae85d90a5aca0a49c0d8b67360bbe407f3b54f1030a111047ff988e8fefaa"},
|
||||
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||
"phoenix": {:hex, :phoenix, "1.7.12", "1cc589e0eab99f593a8aa38ec45f15d25297dd6187ee801c8de8947090b5a9d3", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "d646192fbade9f485b01bc9920c139bfdd19d0f8df3d73fd8eaf2dfbe0d2837c"},
|
||||
"phoenix_ecto": {:hex, :phoenix_ecto, "4.6.1", "96798325fab2fed5a824ca204e877b81f9afd2e480f581e81f7b4b64a5a477f2", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.17", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "0ae544ff99f3c482b0807c5cec2c8289e810ecacabc04959d82c3337f4703391"},
|
||||
"phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"},
|
||||
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.3", "7ff51c9b6609470f681fbea20578dede0e548302b0c8bdf338b5a753a4f045bf", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f9470a0a8bae4f56430a23d42f977b5a6205fdba6559d76f932b876bfaec652d"},
|
||||
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "0.20.14", "70fa101aa0539e81bed4238777498f6215e9dda3461bdaa067cad6908110c364", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "82f6d006c5264f979ed5eb75593d808bbe39020f20df2e78426f4f2d570e2402"},
|
||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
|
||||
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||
"plug": {:hex, :plug, "1.16.0", "1d07d50cb9bb05097fdf187b31cf087c7297aafc3fed8299aac79c128a707e47", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cbf53aa1f5c4d758a7559c0bd6d59e286c2be0c6a1fac8cc3eee2f638243b93e"},
|
||||
"plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
|
||||
"postgrex": {:hex, :postgrex, "0.18.0", "f34664101eaca11ff24481ed4c378492fed2ff416cd9b06c399e90f321867d7e", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a042989ba1bc1cca7383ebb9e461398e3f89f868c92ce6671feb7ef132a252d1"},
|
||||
"ranch": {:hex, :ranch, "2.1.0", "2261f9ed9574dcfcc444106b9f6da155e6e540b2f82ba3d42b339b93673b72a3", [:make, :rebar3], [], "hexpm", "244ee3fa2a6175270d8e1fc59024fd9dbc76294a321057de8f803b1479e76916"},
|
||||
"swoosh": {:hex, :swoosh, "1.16.7", "9dd0c172b4519a023f58e94d3ea79480b469dd4c0cd5369fabfbfd2e39bf5545", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.1.0", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.4 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "21073982816cff3410e90c0d80ebfd5a0bf4839c7b39db20bc69a6df123bbf35"},
|
||||
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
|
||||
"telemetry_metrics": {:hex, :telemetry_metrics, "1.0.0", "29f5f84991ca98b8eb02fc208b2e6de7c95f8bb2294ef244a176675adc7775df", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"},
|
||||
"telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.3.5", "6022b6338f1635b3d32406ff98d68b843ba73b3aa95cfc27154223244f3a6ca5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2be6954916fdfe4756af3239fb6b6d75d0b8063b5df03ba76fd8a4c87849e180"},
|
||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||
"websock_adapter": {:hex, :websock_adapter, "0.5.6", "0437fe56e093fd4ac422de33bf8fc89f7bc1416a3f2d732d8b2c8fd54792fe60", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "e04378d26b0af627817ae84c92083b7e97aca3121196679b73c73b99d0d133ea"},
|
||||
}
|
12
nginx.conf
|
@ -1,12 +0,0 @@
|
|||
server {
|
||||
root /usr/share/comfycamp;
|
||||
error_page 404 /404.html;
|
||||
|
||||
location ~ \.(png|ico|svg|webp)$ {
|
||||
add_header Cache-Control "public, max-age=86400";
|
||||
}
|
||||
|
||||
location / {
|
||||
index index.html index.htm;
|
||||
}
|
||||
}
|
6399
package-lock.json
generated
19
package.json
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"name": "comfycamp",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"check": "astro check && tsc --noEmit",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.5.6",
|
||||
"@astrojs/rss": "^4.0.5",
|
||||
"astro": "^4.4.9",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
53
priv/gettext/default.pot
Normal file
|
@ -0,0 +1,53 @@
|
|||
## This file is a PO Template file.
|
||||
##
|
||||
## "msgid"s here are often extracted from source code.
|
||||
## Add new messages manually only if they're dynamic
|
||||
## messages that can't be statically extracted.
|
||||
##
|
||||
## Run "mix gettext.extract" to bring this file up to
|
||||
## date. Leave "msgstr"s empty as changing them here has no
|
||||
## effect: edit them in PO (.po) files instead.
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
|
||||
#: lib/comfycamp_web/components/core_components.ex:391
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
#: lib/comfycamp_web/components/flash.ex:79
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Attempting to reconnect"
|
||||
msgstr ""
|
||||
|
||||
#: lib/comfycamp_web/components/flash.ex:70
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Error!"
|
||||
msgstr ""
|
||||
|
||||
#: lib/comfycamp_web/components/flash.ex:91
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Hang in there while we get back on track"
|
||||
msgstr ""
|
||||
|
||||
#: lib/comfycamp_web/components/flash.ex:86
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Something went wrong!"
|
||||
msgstr ""
|
||||
|
||||
#: lib/comfycamp_web/components/flash.ex:69
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Success!"
|
||||
msgstr ""
|
||||
|
||||
#: lib/comfycamp_web/components/flash.ex:74
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "We can't find the internet"
|
||||
msgstr ""
|
||||
|
||||
#: lib/comfycamp_web/components/core_components.ex:75
|
||||
#: lib/comfycamp_web/components/flash.ex:49
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "close"
|
||||
msgstr ""
|
53
priv/gettext/en/LC_MESSAGES/default.po
Normal file
|
@ -0,0 +1,53 @@
|
|||
## "msgid"s in this file come from POT (.pot) files.
|
||||
###
|
||||
### Do not add, change, or remove "msgid"s manually here as
|
||||
### they're tied to the ones in the corresponding POT file
|
||||
### (with the same domain).
|
||||
###
|
||||
### Use "mix gettext.extract --merge" or "mix gettext.merge"
|
||||
### to merge POT files into PO files.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Language: en\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#: lib/comfycamp_web/components/core_components.ex:391
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
#: lib/comfycamp_web/components/flash.ex:79
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Attempting to reconnect"
|
||||
msgstr ""
|
||||
|
||||
#: lib/comfycamp_web/components/flash.ex:70
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Error!"
|
||||
msgstr ""
|
||||
|
||||
#: lib/comfycamp_web/components/flash.ex:91
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Hang in there while we get back on track"
|
||||
msgstr ""
|
||||
|
||||
#: lib/comfycamp_web/components/flash.ex:86
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Something went wrong!"
|
||||
msgstr ""
|
||||
|
||||
#: lib/comfycamp_web/components/flash.ex:69
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Success!"
|
||||
msgstr ""
|
||||
|
||||
#: lib/comfycamp_web/components/flash.ex:74
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "We can't find the internet"
|
||||
msgstr ""
|
||||
|
||||
#: lib/comfycamp_web/components/core_components.ex:75
|
||||
#: lib/comfycamp_web/components/flash.ex:49
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "close"
|
||||
msgstr ""
|
112
priv/gettext/en/LC_MESSAGES/errors.po
Normal file
|
@ -0,0 +1,112 @@
|
|||
## `msgid`s in this file come from POT (.pot) files.
|
||||
##
|
||||
## Do not add, change, or remove `msgid`s manually here as
|
||||
## they're tied to the ones in the corresponding POT file
|
||||
## (with the same domain).
|
||||
##
|
||||
## Use `mix gettext.extract --merge` or `mix gettext.merge`
|
||||
## to merge POT files into PO files.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Language: en\n"
|
||||
|
||||
## From Ecto.Changeset.cast/4
|
||||
msgid "can't be blank"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.unique_constraint/3
|
||||
msgid "has already been taken"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.put_change/3
|
||||
msgid "is invalid"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.validate_acceptance/3
|
||||
msgid "must be accepted"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.validate_format/3
|
||||
msgid "has invalid format"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.validate_subset/3
|
||||
msgid "has an invalid entry"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.validate_exclusion/3
|
||||
msgid "is reserved"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.validate_confirmation/3
|
||||
msgid "does not match confirmation"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.no_assoc_constraint/3
|
||||
msgid "is still associated with this entry"
|
||||
msgstr ""
|
||||
|
||||
msgid "are still associated with this entry"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.validate_length/3
|
||||
msgid "should have %{count} item(s)"
|
||||
msgid_plural "should have %{count} item(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should be %{count} character(s)"
|
||||
msgid_plural "should be %{count} character(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should be %{count} byte(s)"
|
||||
msgid_plural "should be %{count} byte(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should have at least %{count} item(s)"
|
||||
msgid_plural "should have at least %{count} item(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should be at least %{count} character(s)"
|
||||
msgid_plural "should be at least %{count} character(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should be at least %{count} byte(s)"
|
||||
msgid_plural "should be at least %{count} byte(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should have at most %{count} item(s)"
|
||||
msgid_plural "should have at most %{count} item(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should be at most %{count} character(s)"
|
||||
msgid_plural "should be at most %{count} character(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should be at most %{count} byte(s)"
|
||||
msgid_plural "should be at most %{count} byte(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
## From Ecto.Changeset.validate_number/3
|
||||
msgid "must be less than %{number}"
|
||||
msgstr ""
|
||||
|
||||
msgid "must be greater than %{number}"
|
||||
msgstr ""
|
||||
|
||||
msgid "must be less than or equal to %{number}"
|
||||
msgstr ""
|
||||
|
||||
msgid "must be greater than or equal to %{number}"
|
||||
msgstr ""
|
||||
|
||||
msgid "must be equal to %{number}"
|
||||
msgstr ""
|
109
priv/gettext/errors.pot
Normal file
|
@ -0,0 +1,109 @@
|
|||
## This is a PO Template file.
|
||||
##
|
||||
## `msgid`s here are often extracted from source code.
|
||||
## Add new translations manually only if they're dynamic
|
||||
## translations that can't be statically extracted.
|
||||
##
|
||||
## Run `mix gettext.extract` to bring this file up to
|
||||
## date. Leave `msgstr`s empty as changing them here has no
|
||||
## effect: edit them in PO (`.po`) files instead.
|
||||
## From Ecto.Changeset.cast/4
|
||||
msgid "can't be blank"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.unique_constraint/3
|
||||
msgid "has already been taken"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.put_change/3
|
||||
msgid "is invalid"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.validate_acceptance/3
|
||||
msgid "must be accepted"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.validate_format/3
|
||||
msgid "has invalid format"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.validate_subset/3
|
||||
msgid "has an invalid entry"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.validate_exclusion/3
|
||||
msgid "is reserved"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.validate_confirmation/3
|
||||
msgid "does not match confirmation"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.no_assoc_constraint/3
|
||||
msgid "is still associated with this entry"
|
||||
msgstr ""
|
||||
|
||||
msgid "are still associated with this entry"
|
||||
msgstr ""
|
||||
|
||||
## From Ecto.Changeset.validate_length/3
|
||||
msgid "should have %{count} item(s)"
|
||||
msgid_plural "should have %{count} item(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should be %{count} character(s)"
|
||||
msgid_plural "should be %{count} character(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should be %{count} byte(s)"
|
||||
msgid_plural "should be %{count} byte(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should have at least %{count} item(s)"
|
||||
msgid_plural "should have at least %{count} item(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should be at least %{count} character(s)"
|
||||
msgid_plural "should be at least %{count} character(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should be at least %{count} byte(s)"
|
||||
msgid_plural "should be at least %{count} byte(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should have at most %{count} item(s)"
|
||||
msgid_plural "should have at most %{count} item(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should be at most %{count} character(s)"
|
||||
msgid_plural "should be at most %{count} character(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "should be at most %{count} byte(s)"
|
||||
msgid_plural "should be at most %{count} byte(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
## From Ecto.Changeset.validate_number/3
|
||||
msgid "must be less than %{number}"
|
||||
msgstr ""
|
||||
|
||||
msgid "must be greater than %{number}"
|
||||
msgstr ""
|
||||
|
||||
msgid "must be less than or equal to %{number}"
|
||||
msgstr ""
|
||||
|
||||
msgid "must be greater than or equal to %{number}"
|
||||
msgstr ""
|
||||
|
||||
msgid "must be equal to %{number}"
|
||||
msgstr ""
|
53
priv/gettext/ru/LC_MESSAGES/default.po
Normal file
|
@ -0,0 +1,53 @@
|
|||
## "msgid"s in this file come from POT (.pot) files.
|
||||
###
|
||||
### Do not add, change, or remove "msgid"s manually here as
|
||||
### they're tied to the ones in the corresponding POT file
|
||||
### (with the same domain).
|
||||
###
|
||||
### Use "mix gettext.extract --merge" or "mix gettext.merge"
|
||||
### to merge POT files into PO files.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Language: ru\n"
|
||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100 != 11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10||n%100>=20) ? 1 : 2);\n"
|
||||
|
||||
#: lib/comfycamp_web/components/core_components.ex:391
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
#: lib/comfycamp_web/components/flash.ex:79
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Attempting to reconnect"
|
||||
msgstr ""
|
||||
|
||||
#: lib/comfycamp_web/components/flash.ex:70
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Error!"
|
||||
msgstr "Ошибка!"
|
||||
|
||||
#: lib/comfycamp_web/components/flash.ex:91
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Hang in there while we get back on track"
|
||||
msgstr ""
|
||||
|
||||
#: lib/comfycamp_web/components/flash.ex:86
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Something went wrong!"
|
||||
msgstr ""
|
||||
|
||||
#: lib/comfycamp_web/components/flash.ex:69
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Success!"
|
||||
msgstr "Успех!"
|
||||
|
||||
#: lib/comfycamp_web/components/flash.ex:74
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "We can't find the internet"
|
||||
msgstr ""
|
||||
|
||||
#: lib/comfycamp_web/components/core_components.ex:75
|
||||
#: lib/comfycamp_web/components/flash.ex:49
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "close"
|
||||
msgstr ""
|
111
priv/gettext/ru/LC_MESSAGES/errors.po
Normal file
|
@ -0,0 +1,111 @@
|
|||
## "msgid"s in this file come from POT (.pot) files.
|
||||
###
|
||||
### Do not add, change, or remove "msgid"s manually here as
|
||||
### they're tied to the ones in the corresponding POT file
|
||||
### (with the same domain).
|
||||
###
|
||||
### Use "mix gettext.extract --merge" or "mix gettext.merge"
|
||||
### to merge POT files into PO files.
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Language: ru\n"
|
||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100 != 11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10||n%100>=20) ? 1 : 2);\n"
|
||||
|
||||
msgid "can't be blank"
|
||||
msgstr ""
|
||||
|
||||
msgid "has already been taken"
|
||||
msgstr ""
|
||||
|
||||
msgid "is invalid"
|
||||
msgstr ""
|
||||
|
||||
msgid "must be accepted"
|
||||
msgstr ""
|
||||
|
||||
msgid "has invalid format"
|
||||
msgstr ""
|
||||
|
||||
msgid "has an invalid entry"
|
||||
msgstr ""
|
||||
|
||||
msgid "is reserved"
|
||||
msgstr ""
|
||||
|
||||
msgid "does not match confirmation"
|
||||
msgstr ""
|
||||
|
||||
msgid "is still associated with this entry"
|
||||
msgstr ""
|
||||
|
||||
msgid "are still associated with this entry"
|
||||
msgstr ""
|
||||
|
||||
msgid "should have %{count} item(s)"
|
||||
msgid_plural "should have %{count} item(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
|
||||
msgid "should be %{count} character(s)"
|
||||
msgid_plural "should be %{count} character(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
|
||||
msgid "should be %{count} byte(s)"
|
||||
msgid_plural "should be %{count} byte(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
|
||||
msgid "should have at least %{count} item(s)"
|
||||
msgid_plural "should have at least %{count} item(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
|
||||
msgid "should be at least %{count} character(s)"
|
||||
msgid_plural "should be at least %{count} character(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
|
||||
msgid "should be at least %{count} byte(s)"
|
||||
msgid_plural "should be at least %{count} byte(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
|
||||
msgid "should have at most %{count} item(s)"
|
||||
msgid_plural "should have at most %{count} item(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
|
||||
msgid "should be at most %{count} character(s)"
|
||||
msgid_plural "should be at most %{count} character(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
|
||||
msgid "should be at most %{count} byte(s)"
|
||||
msgid_plural "should be at most %{count} byte(s)"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
|
||||
msgid "must be less than %{number}"
|
||||
msgstr ""
|
||||
|
||||
msgid "must be greater than %{number}"
|
||||
msgstr ""
|
||||
|
||||
msgid "must be less than or equal to %{number}"
|
||||
msgstr ""
|
||||
|
||||
msgid "must be greater than or equal to %{number}"
|
||||
msgstr ""
|
||||
|
||||
msgid "must be equal to %{number}"
|
||||
msgstr ""
|