Compare commits

...

45 commits

Author SHA1 Message Date
Ivan R. d09bcf646e
feat: generate modules for openid apps 2024-09-05 02:27:33 +05:00
Ivan R. 3ebcf2db4d
feat: add styles for table and code tags 2024-08-31 17:16:42 +05:00
Ivan R. 750429fc62
feat: fancy admin panel 2024-08-31 17:06:02 +05:00
Ivan R. a8dbdadb90
fix: reset esbuild config 2024-08-31 17:02:54 +05:00
Ivan R. cdccbb1919
chore: use navigate instead of href for live views 2024-08-31 13:47:03 +05:00
Ivan R. 7235ad0d00
feat: add a warning for unapproved accounts 2024-08-31 13:42:27 +05:00
Ivan R. cc57bddc24
chore: simplify esbuild config, fix hot reload 2024-08-31 13:42:05 +05:00
Ivan R. 80743a50f7
refactor: simplify changeset checks, enable spellcheck 2024-08-29 00:38:29 +05:00
Ivan R. 7aa77b1604
feat: add the ability to approve users 2024-08-28 00:58:20 +05:00
Ivan R. 03256685c0
chore: split css files 2024-08-26 22:10:51 +05:00
Ivan R. 3f042d1d99
feat: show user list in admin panel 2024-08-26 21:05:11 +05:00
Ivan R. 818a7e4a31
refactor: embed all html templates into html modules
For some reason I like it.
2024-08-25 23:58:22 +05:00
Ivan R. 1d52d9c71f
feat: add description for some services 2024-08-25 23:47:58 +05:00
Ivan R. 9e0ed3b640
chore: adjust input/button colors 2024-08-25 23:15:33 +05:00
Ivan R. 40f661ff58
style: run mix format 2024-08-25 23:04:41 +05:00
Ivan R. aba6a76073
refactor(home): create service component, embed html template 2024-08-25 23:04:29 +05:00
Ivan R. f779c5fd82
feat: save user info and approval status 2024-08-16 00:40:09 +05:00
Ivan R. 088baff66a
fix: remove undefined function 2024-08-15 12:35:02 +05:00
Ivan R. e28cdc803c
feat: email delivery 2024-08-15 12:26:43 +05:00
Ivan R. 99ebbe7e84
feat: add support for unix sockets 2024-08-15 01:26:34 +05:00
Ivan R. b2372f4d34
build: add Dockerfile, restore assets.deploy task 2024-08-14 22:28:21 +05:00
Ivan R. 5f73827373
feat: markdown parsing 2024-07-29 20:19:37 +05:00
Ivan R. 107af78925
feat: public note list 2024-07-29 18:07:44 +05:00
Ivan R. 5a00fdf843
feat: admin panel for notes 2024-07-29 16:19:47 +05:00
Ivan R. 45c91eb3bf
feat: admin panel
I just added is_admin field to user schema, /admin scope,
admin page controller and view.

No extra functions were implemented.
2024-07-28 21:52:12 +05:00
Ivan R. 2d81bf20ce
chore: use verified routes in nav bar 2024-07-28 18:03:48 +05:00
Ivan R. 8040c38edf
refactor: store icons as .svg.heex files 2024-07-28 17:52:11 +05:00
Ivan R. a38d13a16a
feat: notes schema 2024-07-27 23:22:09 +05:00
Ivan R. fc799a5c0e
feat: add more information about services 2024-07-23 18:52:18 +05:00
Ivan R. 84681401ec
chore: rename services to blog 2024-07-23 18:51:58 +05:00
Ivan R. e185b154ab
feat: add a list of services 2024-07-23 18:40:52 +05:00
Ivan R. 66bb26380a
chore(users): translate forms, fix colors 2024-07-22 21:12:29 +05:00
Ivan R. eadc784bc3
test: fix failures 2024-07-21 22:22:36 +05:00
Ivan R. 8f34d6a069
test: configure database connection 2024-07-21 22:07:53 +05:00
Ivan R. 3841ee5ed7
style: run mix format 2024-07-21 22:04:18 +05:00
Ivan R. 0a5d70abb6
fix: move account-related links to navbar 2024-07-21 22:03:50 +05:00
Ivan R. 63c28511e3
feat: generate authentication 2024-07-11 01:14:16 +05:00
Ivan R. 63c73d5f08
fix(footer): add more icons, change css class names 2024-07-10 01:20:19 +05:00
Ivan R. 9e8885386a
feat: favicons 2024-07-09 23:19:56 +05:00
Ivan R. 560c2c1c8c
fix(flash): write custom styles, update translations 2024-07-09 23:07:57 +05:00
Ivan R. 1a394277d2
feat: add controllers and views for other pages 2024-07-09 21:21:17 +05:00
Ivan R. c6de8dce34
chore: remove tailwind
I prefer plain css files over tailwind.

I converted some default components,
but it's still a work in progress.
2024-07-06 21:25:42 +05:00
Ivan R. c80f439840
chore: copy layout and main page 2024-06-15 22:04:21 +05:00
Ivan R. 94f33b062b
chore: configure dev database 2024-06-15 15:49:09 +05:00
Ivan R. c8edc05b27
chore: initialize empty phoenix project 2024-05-27 12:55:41 +05:00
245 changed files with 8126 additions and 7990 deletions

View file

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

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

View file

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

View file

@ -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
View 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
View 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;
}

View 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
View 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
View 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
View 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
View 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));

View file

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

View file

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

View file

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

View file

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

View 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

View 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

View 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

View 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
View file

@ -0,0 +1,3 @@
defmodule Comfycamp.Mailer do
use Swoosh.Mailer, otp_app: :comfycamp
end

39
lib/comfycamp/note.ex Normal file
View 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

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

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

View 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

View 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

View 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

View 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

View file

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

View 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="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

View 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

View 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="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

View 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="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

View 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="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

View 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

View 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

View 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

View file

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

View 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

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

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

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

View 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

View 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

View 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

View file

@ -0,0 +1,8 @@
defmodule ComfycampWeb.CinemaController do
use ComfycampWeb, :controller
def index(conn, _params) do
conn
|> render(:index, page_title: "Кинотеатр")
end
end

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

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

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

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

View file

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

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

View 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

View 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

View 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

View 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

View 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

View file

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

View 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

View 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

View 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

View 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

View 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

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

View 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

View 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
View 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
View 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"},
}

View file

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

File diff suppressed because it is too large Load diff

View file

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

View 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 ""

View 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
View 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 ""

View 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 ""

View 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 ""

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