Compare commits
49 commits
Author | SHA1 | Date | |
---|---|---|---|
Ivan R. | 489c39e16c | ||
Ivan R. | 1eb567cd6d | ||
Ivan R. | c1a4b839bd | ||
Ivan R. | ba4e90ef51 | ||
Ivan R. | d09bcf646e | ||
Ivan R. | 3ebcf2db4d | ||
Ivan R. | 750429fc62 | ||
Ivan R. | a8dbdadb90 | ||
Ivan R. | cdccbb1919 | ||
Ivan R. | 7235ad0d00 | ||
Ivan R. | cc57bddc24 | ||
Ivan R. | 80743a50f7 | ||
Ivan R. | 7aa77b1604 | ||
Ivan R. | 03256685c0 | ||
Ivan R. | 3f042d1d99 | ||
Ivan R. | 818a7e4a31 | ||
Ivan R. | 1d52d9c71f | ||
Ivan R. | 9e0ed3b640 | ||
Ivan R. | 40f661ff58 | ||
Ivan R. | aba6a76073 | ||
Ivan R. | f779c5fd82 | ||
Ivan R. | 088baff66a | ||
Ivan R. | e28cdc803c | ||
Ivan R. | 99ebbe7e84 | ||
Ivan R. | b2372f4d34 | ||
Ivan R. | 5f73827373 | ||
Ivan R. | 107af78925 | ||
Ivan R. | 5a00fdf843 | ||
Ivan R. | 45c91eb3bf | ||
Ivan R. | 2d81bf20ce | ||
Ivan R. | 8040c38edf | ||
Ivan R. | a38d13a16a | ||
Ivan R. | fc799a5c0e | ||
Ivan R. | 84681401ec | ||
Ivan R. | e185b154ab | ||
Ivan R. | 66bb26380a | ||
Ivan R. | eadc784bc3 | ||
Ivan R. | 8f34d6a069 | ||
Ivan R. | 3841ee5ed7 | ||
Ivan R. | 0a5d70abb6 | ||
Ivan R. | 63c28511e3 | ||
Ivan R. | 63c73d5f08 | ||
Ivan R. | 9e8885386a | ||
Ivan R. | 560c2c1c8c | ||
Ivan R. | 1a394277d2 | ||
Ivan R. | c6de8dce34 | ||
Ivan R. | c80f439840 | ||
Ivan R. | 94f33b062b | ||
Ivan R. | c8edc05b27 |
|
@ -1,11 +1,45 @@
|
|||
LICENSE.md
|
||||
README.md
|
||||
node_modules
|
||||
dist
|
||||
.github
|
||||
# This file excludes paths from the Docker build context.
|
||||
#
|
||||
# By default, Docker's build context includes all files (and folders) in the
|
||||
# current directory. Even if a file isn't copied into the container it is still sent to
|
||||
# the Docker daemon.
|
||||
#
|
||||
# There are multiple reasons to exclude files from the build context:
|
||||
#
|
||||
# 1. Prevent nested folders from being copied into the container (ex: exclude
|
||||
# /assets/node_modules when copying /assets)
|
||||
# 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc)
|
||||
# 3. Avoid sending files containing sensitive information
|
||||
#
|
||||
# More information on using .dockerignore is available here:
|
||||
# https://docs.docker.com/engine/reference/builder/#dockerignore-file
|
||||
|
||||
.dockerignore
|
||||
|
||||
# Ignore git, but keep git HEAD and refs to access current commit hash if needed:
|
||||
#
|
||||
# $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat
|
||||
# d0b8727759e1e0e7aa3d41707d12376e373d5ecc
|
||||
.git
|
||||
.editorconfig
|
||||
.gitignore
|
||||
convert-images.py
|
||||
public-src
|
||||
flake.*
|
||||
!.git/HEAD
|
||||
!.git/refs
|
||||
|
||||
# Common development/test artifacts
|
||||
/cover/
|
||||
/doc/
|
||||
/test/
|
||||
/tmp/
|
||||
.elixir_ls
|
||||
|
||||
# Mix artifacts
|
||||
/_build/
|
||||
/deps/
|
||||
*.ez
|
||||
|
||||
# Generated on crash by the VM
|
||||
erl_crash.dump
|
||||
|
||||
# Static artifacts - These should be fetched and built inside the Docker image
|
||||
/assets/node_modules/
|
||||
/priv/static/assets/
|
||||
/priv/static/cache_manifest.json
|
||||
|
|
6
.formatter.exs
Normal file
6
.formatter.exs
Normal file
|
@ -0,0 +1,6 @@
|
|||
[
|
||||
import_deps: [:ecto, :ecto_sql, :phoenix],
|
||||
subdirectories: ["priv/*/migrations"],
|
||||
plugins: [Phoenix.LiveView.HTMLFormatter],
|
||||
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
|
||||
]
|
48
.gitignore
vendored
48
.gitignore
vendored
|
@ -1,21 +1,37 @@
|
|||
# build output
|
||||
dist/
|
||||
# The directory Mix will write compiled artifacts to.
|
||||
/_build/
|
||||
|
||||
# generated types
|
||||
.astro/
|
||||
# If you run "mix test --cover", coverage assets end up here.
|
||||
/cover/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
# The directory Mix downloads your dependencies sources to.
|
||||
/deps/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
# Where 3rd-party dependencies like ExDoc output generated docs.
|
||||
/doc/
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
# Ignore .fetch files in case you like to edit your project deps locally.
|
||||
/.fetch
|
||||
|
||||
# If the VM crashes, it generates a dump, let's ignore it too.
|
||||
erl_crash.dump
|
||||
|
||||
# Also ignore archive artifacts (built via "mix archive.build").
|
||||
*.ez
|
||||
|
||||
# Temporary files, for example, from tests.
|
||||
/tmp/
|
||||
|
||||
# Ignore package tarball (built via "mix hex.build").
|
||||
comfycamp-*.tar
|
||||
|
||||
# Ignore assets that are produced by build tools.
|
||||
/priv/static/assets/
|
||||
|
||||
# Ignore digested assets cache.
|
||||
/priv/static/cache_manifest.json
|
||||
|
||||
# In case you use Node.js/npm, you want to ignore these.
|
||||
npm-debug.log
|
||||
/assets/node_modules/
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
|
99
Dockerfile
99
Dockerfile
|
@ -1,14 +1,97 @@
|
|||
FROM node:20-alpine AS builder
|
||||
# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian
|
||||
# instead of Alpine to avoid DNS resolution issues in production.
|
||||
#
|
||||
# https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu
|
||||
# https://hub.docker.com/_/ubuntu?tab=tags
|
||||
#
|
||||
# This file is based on these images:
|
||||
#
|
||||
# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
|
||||
# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20240701-slim - for the release image
|
||||
# - https://pkgs.org/ - resource for finding needed packages
|
||||
# - Ex: hexpm/elixir:1.17.2-erlang-27.0-debian-bullseye-20240701-slim
|
||||
#
|
||||
ARG ELIXIR_VERSION=1.17.2
|
||||
ARG OTP_VERSION=27.0
|
||||
ARG DEBIAN_VERSION=bullseye-20240701-slim
|
||||
|
||||
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
|
||||
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
|
||||
|
||||
FROM ${BUILDER_IMAGE} as builder
|
||||
|
||||
# install build dependencies
|
||||
RUN apt-get update -y && apt-get install -y build-essential git \
|
||||
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
|
||||
|
||||
# prepare build dir
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm i
|
||||
# install hex + rebar
|
||||
RUN mix local.hex --force && \
|
||||
mix local.rebar --force
|
||||
|
||||
ADD . .
|
||||
RUN npm run build
|
||||
# set build ENV
|
||||
ENV MIX_ENV="prod"
|
||||
|
||||
FROM nginx:alpine
|
||||
# install mix dependencies
|
||||
COPY mix.exs mix.lock ./
|
||||
RUN mix deps.get --only $MIX_ENV
|
||||
RUN mkdir config
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/comfycamp
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
# copy compile-time config files before we compile dependencies
|
||||
# to ensure any relevant config change will trigger the dependencies
|
||||
# to be re-compiled.
|
||||
COPY config/config.exs config/${MIX_ENV}.exs config/
|
||||
RUN mix deps.compile
|
||||
|
||||
COPY priv priv
|
||||
|
||||
COPY lib lib
|
||||
|
||||
COPY assets assets
|
||||
|
||||
# compile assets
|
||||
RUN mix assets.deploy
|
||||
|
||||
# Compile the release
|
||||
RUN mix compile
|
||||
|
||||
# Changes to config/runtime.exs don't require recompiling the code
|
||||
COPY config/runtime.exs config/
|
||||
|
||||
COPY rel rel
|
||||
RUN mix release
|
||||
|
||||
# start a new build stage so that the final image will only contain
|
||||
# the compiled release and other runtime necessities
|
||||
FROM ${RUNNER_IMAGE}
|
||||
|
||||
RUN apt-get update -y && \
|
||||
apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
|
||||
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
|
||||
|
||||
# Set the locale
|
||||
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
||||
|
||||
ENV LANG en_US.UTF-8
|
||||
ENV LANGUAGE en_US:en
|
||||
ENV LC_ALL en_US.UTF-8
|
||||
|
||||
WORKDIR "/app"
|
||||
RUN chown nobody /app
|
||||
|
||||
# set runner ENV
|
||||
ENV MIX_ENV="prod"
|
||||
|
||||
# Only copy the final release from the build stage
|
||||
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/comfycamp ./
|
||||
|
||||
USER nobody
|
||||
|
||||
# If using an environment that doesn't automatically reap zombie processes, it is
|
||||
# advised to add an init process such as tini via `apt-get install`
|
||||
# above and adding an entrypoint. See https://github.com/krallin/tini for details
|
||||
# ENTRYPOINT ["/tini", "--"]
|
||||
|
||||
CMD ["/app/bin/server"]
|
||||
|
|
21
README.md
21
README.md
|
@ -1,17 +1,18 @@
|
|||
# Comfycamp
|
||||
|
||||
My personal website.
|
||||
To start your Phoenix server:
|
||||
|
||||
* Run `mix setup` to install and setup dependencies
|
||||
* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
|
||||
|
||||
## Getting started
|
||||
Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
|
||||
|
||||
```bash
|
||||
npm i
|
||||
npm run dev
|
||||
```
|
||||
Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
|
||||
|
||||
## Learn more
|
||||
|
||||
## Images
|
||||
|
||||
The original pictures are in the `public-src` directory.
|
||||
The `convert-images.py` script converts them into the required formats and resolutions.
|
||||
* Official website: https://www.phoenixframework.org/
|
||||
* Guides: https://hexdocs.pm/phoenix/overview.html
|
||||
* Docs: https://hexdocs.pm/phoenix
|
||||
* Forum: https://elixirforum.com/c/phoenix-forum
|
||||
* Source: https://github.com/phoenixframework/phoenix
|
||||
|
|
59
assets/css/admin.css
Normal file
59
assets/css/admin.css
Normal file
|
@ -0,0 +1,59 @@
|
|||
.admin-panel {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.admin-panel .menu {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.admin-panel .menu li {
|
||||
padding: 10px;
|
||||
background-color: #20232f;
|
||||
}
|
||||
|
||||
.admin-panel .menu li:first-child {
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.admin-panel .menu li:not(:first-child) {
|
||||
border-top: 1px solid #353544;
|
||||
}
|
||||
|
||||
.admin-panel .menu li:last-child {
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
.admin-panel h1,
|
||||
.admin-panel h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.admin-panel .stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.admin-panel .stat {
|
||||
background-color: #20232f;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: min-content;
|
||||
}
|
||||
|
||||
.admin-panel .stat .value {
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.admin-panel .stat .name {
|
||||
text-align: center;
|
||||
}
|
106
assets/css/core/app.css
Normal file
106
assets/css/core/app.css
Normal file
|
@ -0,0 +1,106 @@
|
|||
@import "./components.css";
|
||||
@import "./flash.css";
|
||||
|
||||
:root {
|
||||
--bg: #13151a;
|
||||
--accent: #b283e5;
|
||||
--input-bg: #28253c;
|
||||
--input-border: #4c4c6d;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: Georgia, serif;
|
||||
background-color: var(--bg);
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
*::selection {
|
||||
background-color: var(--accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 36px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
header,
|
||||
main,
|
||||
footer {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.limiter {
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.link-list {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.link-list a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.navbar .space {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #333;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: #282735;
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
349
assets/css/core/components.css
Normal file
349
assets/css/core/components.css
Normal file
|
@ -0,0 +1,349 @@
|
|||
.label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.5rem;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.list {
|
||||
margin-top: 3.5rem;
|
||||
}
|
||||
|
||||
.list-description {
|
||||
margin-top: -1rem;
|
||||
margin-bottom: -1rem;
|
||||
border-top-width: 1px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.list-item {
|
||||
gap: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.list-item-title {
|
||||
flex: none;
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.list-item-description {
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.modal-bg {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
transition-property: opacity;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 300ms;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
overflow-y: auto;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
width: 100%;
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.modal-body {
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-close-button-container {
|
||||
position: absolute;
|
||||
right: 1.25rem;
|
||||
top: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-close-button {
|
||||
padding: 0.75rem;
|
||||
margin: -0.75rem;
|
||||
flex: none;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.modal-close-button:hover {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.modal-close-button-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
display: none;
|
||||
position: relative;
|
||||
padding: 3.5rem;
|
||||
border-radius: 1rem;
|
||||
box-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||
background-color: #ffffff;
|
||||
transition-property: background-color, border-color, color, fill, stroke, opacity, box-shadow, transform;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 300ms;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.simple-form {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.simple-form-action {
|
||||
display: flex;
|
||||
margin-top: 0.5rem;
|
||||
gap: 1.5rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.5rem;
|
||||
background-color: var(--input-bg);
|
||||
border: 2px solid var(--input-border);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
background-color: var(--input-bg);
|
||||
border: 2px solid var(--input-border);
|
||||
padding: 4px 8px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: 1px solid var(--input-border);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.input {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.textarea {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
background-color: var(--input-bg);
|
||||
border: 2px solid var(--input-border);
|
||||
padding: 4px 8px;
|
||||
color: white;
|
||||
resize: vertical;
|
||||
min-height: 50px;
|
||||
max-height: 330px;
|
||||
}
|
||||
|
||||
.textarea:focus {
|
||||
outline: 1px solid var(--input-border);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.textarea {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.select {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border-width: 1px;
|
||||
border-color: #D1D5DB;
|
||||
width: 100%;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.select {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.checkbox-input {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.show {
|
||||
transition-property: all;
|
||||
transition-duration: 300ms;
|
||||
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
--transform-translate-y: 1rem;
|
||||
opacity: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.show {
|
||||
--transform-scale-x: 1;
|
||||
--transform-scale-y: 1;
|
||||
--transform-translate-y: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.hide {
|
||||
transition-property: all;
|
||||
transition-duration: 300ms;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
|
||||
--transform-translate-y: 1rem;
|
||||
opacity: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.hide {
|
||||
--transform-scale-x: .95;
|
||||
--transform-scale-y: .95;
|
||||
--transform-translate-y: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.show-modal {
|
||||
transition-property: all;
|
||||
transition-duration: 300ms;
|
||||
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.hide-modal {
|
||||
transition-property: all;
|
||||
transition-duration: 200ms;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.back-nav-link {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.back-nav-link svg {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-y: auto;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.table-container {
|
||||
overflow: visible;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.table {
|
||||
margin-top: 2.75rem;
|
||||
width: 40rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.table {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.thead {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
line-height: 1.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.th {
|
||||
padding: 0;
|
||||
padding-bottom: 1rem;
|
||||
padding-right: 1.5rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.th-actions {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tbody {
|
||||
position: relative;
|
||||
border-top-width: 1px;
|
||||
border-top-width: 1px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.td {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
}
|
51
assets/css/core/flash.css
Normal file
51
assets/css/core/flash.css
Normal file
|
@ -0,0 +1,51 @@
|
|||
.flash {
|
||||
position: fixed;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 50;
|
||||
padding: 16px;
|
||||
border-radius: 0.3rem;
|
||||
box-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||
width: 20rem;
|
||||
}
|
||||
|
||||
.flash-info {
|
||||
background-color: #15803d;
|
||||
}
|
||||
|
||||
.flash-error {
|
||||
background-color: #b91c1c;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.flash {
|
||||
width: 24rem;
|
||||
}
|
||||
}
|
||||
|
||||
.flash-title {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.flash-body {
|
||||
margin-bottom: 0;
|
||||
text-indent: 28px;
|
||||
}
|
||||
|
||||
.flash-close-button {
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.flash-close-button-icon {
|
||||
opacity: 0.6;
|
||||
}
|
22
assets/css/home.css
Normal file
22
assets/css/home.css
Normal file
|
@ -0,0 +1,22 @@
|
|||
.home .service {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.home .service h3 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.home .service .link {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.home .service svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.home .warning {
|
||||
background-color: #aa4526;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
}
|
47
assets/js/app.js
Normal file
47
assets/js/app.js
Normal file
|
@ -0,0 +1,47 @@
|
|||
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
|
||||
// to get started and then uncomment the line below.
|
||||
// import "./user_socket.js"
|
||||
|
||||
// You can include dependencies in two ways.
|
||||
//
|
||||
// The simplest option is to put them in assets/vendor and
|
||||
// import them using relative paths:
|
||||
//
|
||||
// import "../vendor/some-package.js"
|
||||
//
|
||||
// Alternatively, you can `npm install some-package --prefix assets` and import
|
||||
// them using a path starting with the package name:
|
||||
//
|
||||
// import "some-package"
|
||||
//
|
||||
|
||||
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
|
||||
import "phoenix_html"
|
||||
// Establish Phoenix Socket and LiveView configuration.
|
||||
import {Socket} from "phoenix"
|
||||
import {LiveSocket} from "phoenix_live_view"
|
||||
import topbar from "../vendor/topbar"
|
||||
import "../css/core/app.css"
|
||||
import "../css/admin.css"
|
||||
import "../css/home.css"
|
||||
|
||||
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||
let liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: {_csrf_token: csrfToken}
|
||||
})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
|
||||
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
|
||||
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
|
||||
|
||||
// connect if there are any LiveViews on the page
|
||||
liveSocket.connect()
|
||||
|
||||
// expose liveSocket on window for web console debug logs and latency simulation:
|
||||
// >> liveSocket.enableDebug()
|
||||
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
|
||||
// >> liveSocket.disableLatencySim()
|
||||
window.liveSocket = liveSocket
|
||||
|
165
assets/vendor/topbar.js
vendored
Normal file
165
assets/vendor/topbar.js
vendored
Normal file
|
@ -0,0 +1,165 @@
|
|||
/**
|
||||
* @license MIT
|
||||
* topbar 2.0.0, 2023-02-04
|
||||
* https://buunguyen.github.io/topbar
|
||||
* Copyright (c) 2021 Buu Nguyen
|
||||
*/
|
||||
(function (window, document) {
|
||||
"use strict";
|
||||
|
||||
// https://gist.github.com/paulirish/1579671
|
||||
(function () {
|
||||
var lastTime = 0;
|
||||
var vendors = ["ms", "moz", "webkit", "o"];
|
||||
for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
|
||||
window.requestAnimationFrame =
|
||||
window[vendors[x] + "RequestAnimationFrame"];
|
||||
window.cancelAnimationFrame =
|
||||
window[vendors[x] + "CancelAnimationFrame"] ||
|
||||
window[vendors[x] + "CancelRequestAnimationFrame"];
|
||||
}
|
||||
if (!window.requestAnimationFrame)
|
||||
window.requestAnimationFrame = function (callback, element) {
|
||||
var currTime = new Date().getTime();
|
||||
var timeToCall = Math.max(0, 16 - (currTime - lastTime));
|
||||
var id = window.setTimeout(function () {
|
||||
callback(currTime + timeToCall);
|
||||
}, timeToCall);
|
||||
lastTime = currTime + timeToCall;
|
||||
return id;
|
||||
};
|
||||
if (!window.cancelAnimationFrame)
|
||||
window.cancelAnimationFrame = function (id) {
|
||||
clearTimeout(id);
|
||||
};
|
||||
})();
|
||||
|
||||
var canvas,
|
||||
currentProgress,
|
||||
showing,
|
||||
progressTimerId = null,
|
||||
fadeTimerId = null,
|
||||
delayTimerId = null,
|
||||
addEvent = function (elem, type, handler) {
|
||||
if (elem.addEventListener) elem.addEventListener(type, handler, false);
|
||||
else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
|
||||
else elem["on" + type] = handler;
|
||||
},
|
||||
options = {
|
||||
autoRun: true,
|
||||
barThickness: 3,
|
||||
barColors: {
|
||||
0: "rgba(26, 188, 156, .9)",
|
||||
".25": "rgba(52, 152, 219, .9)",
|
||||
".50": "rgba(241, 196, 15, .9)",
|
||||
".75": "rgba(230, 126, 34, .9)",
|
||||
"1.0": "rgba(211, 84, 0, .9)",
|
||||
},
|
||||
shadowBlur: 10,
|
||||
shadowColor: "rgba(0, 0, 0, .6)",
|
||||
className: null,
|
||||
},
|
||||
repaint = function () {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = options.barThickness * 5; // need space for shadow
|
||||
|
||||
var ctx = canvas.getContext("2d");
|
||||
ctx.shadowBlur = options.shadowBlur;
|
||||
ctx.shadowColor = options.shadowColor;
|
||||
|
||||
var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
|
||||
for (var stop in options.barColors)
|
||||
lineGradient.addColorStop(stop, options.barColors[stop]);
|
||||
ctx.lineWidth = options.barThickness;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, options.barThickness / 2);
|
||||
ctx.lineTo(
|
||||
Math.ceil(currentProgress * canvas.width),
|
||||
options.barThickness / 2
|
||||
);
|
||||
ctx.strokeStyle = lineGradient;
|
||||
ctx.stroke();
|
||||
},
|
||||
createCanvas = function () {
|
||||
canvas = document.createElement("canvas");
|
||||
var style = canvas.style;
|
||||
style.position = "fixed";
|
||||
style.top = style.left = style.right = style.margin = style.padding = 0;
|
||||
style.zIndex = 100001;
|
||||
style.display = "none";
|
||||
if (options.className) canvas.classList.add(options.className);
|
||||
document.body.appendChild(canvas);
|
||||
addEvent(window, "resize", repaint);
|
||||
},
|
||||
topbar = {
|
||||
config: function (opts) {
|
||||
for (var key in opts)
|
||||
if (options.hasOwnProperty(key)) options[key] = opts[key];
|
||||
},
|
||||
show: function (delay) {
|
||||
if (showing) return;
|
||||
if (delay) {
|
||||
if (delayTimerId) return;
|
||||
delayTimerId = setTimeout(() => topbar.show(), delay);
|
||||
} else {
|
||||
showing = true;
|
||||
if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
|
||||
if (!canvas) createCanvas();
|
||||
canvas.style.opacity = 1;
|
||||
canvas.style.display = "block";
|
||||
topbar.progress(0);
|
||||
if (options.autoRun) {
|
||||
(function loop() {
|
||||
progressTimerId = window.requestAnimationFrame(loop);
|
||||
topbar.progress(
|
||||
"+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
|
||||
);
|
||||
})();
|
||||
}
|
||||
}
|
||||
},
|
||||
progress: function (to) {
|
||||
if (typeof to === "undefined") return currentProgress;
|
||||
if (typeof to === "string") {
|
||||
to =
|
||||
(to.indexOf("+") >= 0 || to.indexOf("-") >= 0
|
||||
? currentProgress
|
||||
: 0) + parseFloat(to);
|
||||
}
|
||||
currentProgress = to > 1 ? 1 : to;
|
||||
repaint();
|
||||
return currentProgress;
|
||||
},
|
||||
hide: function () {
|
||||
clearTimeout(delayTimerId);
|
||||
delayTimerId = null;
|
||||
if (!showing) return;
|
||||
showing = false;
|
||||
if (progressTimerId != null) {
|
||||
window.cancelAnimationFrame(progressTimerId);
|
||||
progressTimerId = null;
|
||||
}
|
||||
(function loop() {
|
||||
if (topbar.progress("+.1") >= 1) {
|
||||
canvas.style.opacity -= 0.05;
|
||||
if (canvas.style.opacity <= 0.05) {
|
||||
canvas.style.display = "none";
|
||||
fadeTimerId = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
fadeTimerId = window.requestAnimationFrame(loop);
|
||||
})();
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module === "object" && typeof module.exports === "object") {
|
||||
module.exports = topbar;
|
||||
} else if (typeof define === "function" && define.amd) {
|
||||
define(function () {
|
||||
return topbar;
|
||||
});
|
||||
} else {
|
||||
this.topbar = topbar;
|
||||
}
|
||||
}.call(this, window, document));
|
|
@ -1,6 +0,0 @@
|
|||
import { defineConfig } from 'astro/config'
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: 'https://comfycamp.space'
|
||||
})
|
56
config/config.exs
Normal file
56
config/config.exs
Normal file
|
@ -0,0 +1,56 @@
|
|||
# This file is responsible for configuring your application
|
||||
# and its dependencies with the aid of the Config module.
|
||||
#
|
||||
# This configuration file is loaded before any dependency and
|
||||
# is restricted to this project.
|
||||
|
||||
# General application configuration
|
||||
import Config
|
||||
|
||||
config :comfycamp,
|
||||
ecto_repos: [Comfycamp.Repo],
|
||||
generators: [timestamp_type: :utc_datetime]
|
||||
|
||||
config :comfycamp, ComfycampWeb.Gettext, locales: ~w(en ru), default_locale: "ru"
|
||||
|
||||
# Configures the endpoint
|
||||
config :comfycamp, ComfycampWeb.Endpoint,
|
||||
url: [host: "localhost"],
|
||||
adapter: Bandit.PhoenixAdapter,
|
||||
render_errors: [
|
||||
formats: [html: ComfycampWeb.ErrorHTML, json: ComfycampWeb.ErrorJSON],
|
||||
layout: false
|
||||
],
|
||||
pubsub_server: Comfycamp.PubSub,
|
||||
live_view: [signing_salt: "LXPLpJT8"]
|
||||
|
||||
# Configures the mailer
|
||||
#
|
||||
# By default it uses the "Local" adapter which stores the emails
|
||||
# locally. You can see the emails in your browser, at "/dev/mailbox".
|
||||
#
|
||||
# For production it's recommended to configure a different adapter
|
||||
# at the `config/runtime.exs`.
|
||||
config :comfycamp, Comfycamp.Mailer, adapter: Swoosh.Adapters.Local
|
||||
|
||||
# Configure esbuild (the version is required)
|
||||
config :esbuild,
|
||||
version: "0.17.11",
|
||||
comfycamp: [
|
||||
args:
|
||||
~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
|
||||
cd: Path.expand("../assets", __DIR__),
|
||||
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
|
||||
]
|
||||
|
||||
# Configures Elixir's Logger
|
||||
config :logger, :console,
|
||||
format: "$time $metadata[$level] $message\n",
|
||||
metadata: [:request_id]
|
||||
|
||||
# Use Jason for JSON parsing in Phoenix
|
||||
config :phoenix, :json_library, Jason
|
||||
|
||||
# Import environment specific config. This must remain at the bottom
|
||||
# of this file so it overrides the configuration defined above.
|
||||
import_config "#{config_env()}.exs"
|
84
config/dev.exs
Normal file
84
config/dev.exs
Normal file
|
@ -0,0 +1,84 @@
|
|||
import Config
|
||||
|
||||
# Configure your database
|
||||
config :comfycamp, Comfycamp.Repo,
|
||||
username: "phoenix",
|
||||
password: "simple-password",
|
||||
hostname: "localhost",
|
||||
database: "phoenix",
|
||||
stacktrace: true,
|
||||
show_sensitive_data_on_connection_error: true,
|
||||
pool_size: 10
|
||||
|
||||
# For development, we disable any cache and enable
|
||||
# debugging and code reloading.
|
||||
#
|
||||
# The watchers configuration can be used to run external
|
||||
# watchers to your application. For example, we can use it
|
||||
# to bundle .js and .css sources.
|
||||
config :comfycamp, ComfycampWeb.Endpoint,
|
||||
# Binding to loopback ipv4 address prevents access from other machines.
|
||||
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
|
||||
http: [ip: {127, 0, 0, 1}, port: 4000],
|
||||
check_origin: false,
|
||||
code_reloader: true,
|
||||
debug_errors: true,
|
||||
secret_key_base: "6TPjZ6GJcs5FerDbAdr2pHRL5JASsi04nah6WbeQfbPmnuHz0lAUu4e60HNBkKVv",
|
||||
watchers: [
|
||||
esbuild: {Esbuild, :install_and_run, [:comfycamp, ~w(--sourcemap=inline --watch)]}
|
||||
]
|
||||
|
||||
# ## SSL Support
|
||||
#
|
||||
# In order to use HTTPS in development, a self-signed
|
||||
# certificate can be generated by running the following
|
||||
# Mix task:
|
||||
#
|
||||
# mix phx.gen.cert
|
||||
#
|
||||
# Run `mix help phx.gen.cert` for more information.
|
||||
#
|
||||
# The `http:` config above can be replaced with:
|
||||
#
|
||||
# https: [
|
||||
# port: 4001,
|
||||
# cipher_suite: :strong,
|
||||
# keyfile: "priv/cert/selfsigned_key.pem",
|
||||
# certfile: "priv/cert/selfsigned.pem"
|
||||
# ],
|
||||
#
|
||||
# If desired, both `http:` and `https:` keys can be
|
||||
# configured to run both http and https servers on
|
||||
# different ports.
|
||||
|
||||
# Watch static and templates for browser reloading.
|
||||
config :comfycamp, ComfycampWeb.Endpoint,
|
||||
live_reload: [
|
||||
patterns: [
|
||||
~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
|
||||
~r"priv/gettext/.*(po)$",
|
||||
~r"lib/comfycamp_web/(controllers|live|components)/.*(ex|heex)$"
|
||||
]
|
||||
]
|
||||
|
||||
# Enable dev routes for dashboard and mailbox
|
||||
config :comfycamp, dev_routes: true
|
||||
|
||||
# Do not include metadata nor timestamps in development logs
|
||||
config :logger, :console, format: "[$level] $message\n"
|
||||
|
||||
# Set a higher stacktrace during development. Avoid configuring such
|
||||
# in production as building large stacktraces may be expensive.
|
||||
config :phoenix, :stacktrace_depth, 20
|
||||
|
||||
# Initialize plugs at runtime for faster development compilation
|
||||
config :phoenix, :plug_init_mode, :runtime
|
||||
|
||||
config :phoenix_live_view,
|
||||
# Include HEEx debug annotations as HTML comments in rendered markup
|
||||
debug_heex_annotations: true,
|
||||
# Enable helpful, but potentially expensive runtime checks
|
||||
enable_expensive_runtime_checks: true
|
||||
|
||||
# Disable swoosh api client as it is only required for production adapters.
|
||||
config :swoosh, :api_client, false
|
20
config/prod.exs
Normal file
20
config/prod.exs
Normal file
|
@ -0,0 +1,20 @@
|
|||
import Config
|
||||
|
||||
# Note we also include the path to a cache manifest
|
||||
# containing the digested version of static files. This
|
||||
# manifest is generated by the `mix assets.deploy` task,
|
||||
# which you should run after static files are built and
|
||||
# before starting your production server.
|
||||
config :comfycamp, ComfycampWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
|
||||
|
||||
# Configures Swoosh API Client
|
||||
config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: Comfycamp.Finch
|
||||
|
||||
# Disable Swoosh Local Memory Storage
|
||||
config :swoosh, local: false
|
||||
|
||||
# Do not print debug messages in production
|
||||
config :logger, level: :info
|
||||
|
||||
# Runtime production configuration, including reading
|
||||
# of environment variables, is done on config/runtime.exs.
|
119
config/runtime.exs
Normal file
119
config/runtime.exs
Normal file
|
@ -0,0 +1,119 @@
|
|||
import Config
|
||||
|
||||
# config/runtime.exs is executed for all environments, including
|
||||
# during releases. It is executed after compilation and before the
|
||||
# system starts, so it is typically used to load production configuration
|
||||
# and secrets from environment variables or elsewhere. Do not define
|
||||
# any compile-time configuration in here, as it won't be applied.
|
||||
# The block below contains prod specific runtime configuration.
|
||||
|
||||
# ## Using releases
|
||||
#
|
||||
# If you use `mix release`, you need to explicitly enable the server
|
||||
# by passing the PHX_SERVER=true when you start it:
|
||||
#
|
||||
# PHX_SERVER=true bin/comfycamp start
|
||||
#
|
||||
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
|
||||
# script that automatically sets the env var above.
|
||||
if System.get_env("PHX_SERVER") do
|
||||
config :comfycamp, ComfycampWeb.Endpoint, server: true
|
||||
end
|
||||
|
||||
if config_env() == :prod do
|
||||
database_url =
|
||||